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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,36 @@ status and VoiceOver wording, connect-vs-rotate control titles. The mask reveals
characters and, below `minimumLengthToMask`, none at all (a bare `suffix(4)` in the view once rendered
a short key whole).

Sign in with AssemblyAI: the other way to fill that same key item. **`AssemblyAIAccount`**
(`@MainActor @Observable`, in `Sources/BlurtEngine/Account/`) runs the authorization-code + PKCE flow
against a public Connected App: the shell's `AccountSignIn` opens the consent page in an
`ASWebAuthenticationSession` (redirect `com.assemblyai.blurt://oauth/callback`, caught by the session, so no URL
handler is registered), and the engine trades the code for tokens and the access token for the
connection's API key (`POST /api/v2/oauth/api-key`), which it saves through `APIKeyModel` like a
pasted key — the transcriber, the press gate and the TTS clients can't tell the two apart. Beside it,
a second Keychain item (`AssemblyAISignIn`, `SignInStore`) holds the **`SignInSession`**: refresh
token, email, key expiry and last renewal. Its read keeps "none" apart from "unavailable" (a locked
Keychain), for the reason `MemoizedKeyStore` gives: an unreadable item must not read as signed out,
or Sign Out would delete the key without ending the connection. `runRenewal()` (started once at
launch) calls `renewIfDue()` hourly, which refreshes and re-exchanges at most daily or within two days
of the key's expiry, keeping a rotated refresh token before anything else can fail; a refused refresh
or exchange means the connection was ended elsewhere and drops key and session, an unreachable server
changes nothing. A renewal suspends twice on the network, so a `generation` counter makes one that
wakes after a sign-out, reset or new sign-in write nothing, and `signOut()` waits out a renewal in
flight first. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and
only then forgets it here; a revoke the server couldn't confirm (503) leaves everything in place and
returns an inline report so the user can retry (`SignInFailure.failureReport` makes the same
inline-vs-alert split as `APIKeySubmission`). `CredentialRows.resolve` decides which of the account
and key rows show and which button is filled. `InstallReset` clears the session item with the key.
The wire half (`OAuthAuthorization`, `OAuthClient`) is in the `AssemblyAI` library. The build turns
sign-in on with four Info.plist values (`BLURT_OAUTH_*` in `App/Blurt/project.yml`, read by
`OAuthConfiguration.blurt`); any one empty hides it, which is how release ships until a production
Connected App exists, and a session inherited by such a build can still be signed out (locally). A signed-in key also
dictates against the host its sign-in configures (`BLURT_OAUTH_DICTATION_ORIGIN`, optional; a sandbox
key only works against its sandbox's host): the account keeps a `DictationOrigin` current as it signs
in and out, and `AssemblyAITranscriber` reads it at every request and warm-up. A pasted key, or no
origin configured, dictates against production.

Setup gating: **`SetupReadiness.isReady(permissions:hasAPIKey:)`** is the fully-configured rule
(deliberately excluding the trigger key, which has a default binding, so a shortcut change can't trap
the user in the wizard), `SetupReadiness.pollInterval(isReady:)` the permission-poll cadence (brisk
Expand Down
23 changes: 23 additions & 0 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
2F42560EDE7C8F097889A32D /* juno-33-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F7FE2B1E0DFA593ACFCEDA76 /* juno-33-start.m4a */; };
2F5526E7BF0E08FE4E74F2B2 /* juno-69-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DED921D522FA87E2970C2EFF /* juno-69-stop.m4a */; };
3015C1E46AE0ECA3A96F819C /* juno-69-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 6A29FFB86C18EA299EAFFCFD /* juno-69-start.m4a */; };
3182C793A397A4AC6BF4F24D /* AccountSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC096B44D037E7BB119609A /* AccountSignIn.swift */; };
3246BF1C37C7A027817D9458 /* juno-110-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E552326770AD5967B8128420 /* juno-110-stop.m4a */; };
336F75E5060815018D79ACEB /* rom1b-21-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 01B663528C4E1AFEF24675E1 /* rom1b-21-start.m4a */; };
33C581B5DE3847C05F540701 /* juno-81-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3FCE941C19A3FF0DC61462B7 /* juno-81-stop.m4a */; };
Expand Down Expand Up @@ -174,6 +175,7 @@
5EF49E6114C9C7BF79F857D9 /* juno-8-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */; };
60CE1FFB2833C564F335DFF3 /* juno-47-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 11F215137F50A2D4AC61E414 /* juno-47-stop.m4a */; };
623F42C6E50E5B9EBF9F207A /* juno-22-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD303A24D4133CE3F1F1DE23 /* juno-22-start.m4a */; };
625949CE5B21104715419724 /* AccountRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75844EDF6969530EFB56CF4 /* AccountRowView.swift */; };
643FE1E9922319122B1C087D /* rom1a-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED3C851EF55F6BBEBFDAE53A /* rom1a-17-stop.m4a */; };
645A40A3B0E75729A0E3D1AC /* juno-66-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0CDBED88D2D7CE84776B7574 /* juno-66-start.m4a */; };
65B01C04BC66F1F633EEA966 /* juno-124-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 143E94106D9A6342EFD4F909 /* juno-124-start.m4a */; };
Expand Down Expand Up @@ -860,6 +862,7 @@
E617272806BFEB303D94F315 /* rom1b-16-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-16-stop.m4a"; sourceTree = "<group>"; };
E6A271DA085CFB136745066C /* juno-36-stop.m4a */ = {isa = PBXFileReference; path = "juno-36-stop.m4a"; sourceTree = "<group>"; };
E6D106BBE0E8586827C14E59 /* rom1b-14-start.m4a */ = {isa = PBXFileReference; path = "rom1b-14-start.m4a"; sourceTree = "<group>"; };
E75844EDF6969530EFB56CF4 /* AccountRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountRowView.swift; sourceTree = "<group>"; };
E77ECDD66C1549039CB276A6 /* juno-25-stop.m4a */ = {isa = PBXFileReference; path = "juno-25-stop.m4a"; sourceTree = "<group>"; };
E83BDAC7AB4479E8A12A5613 /* juno-74-start.m4a */ = {isa = PBXFileReference; path = "juno-74-start.m4a"; sourceTree = "<group>"; };
E84C190EF80F6B36F8241BFC /* juno-82-stop.m4a */ = {isa = PBXFileReference; path = "juno-82-stop.m4a"; sourceTree = "<group>"; };
Expand All @@ -869,6 +872,7 @@
E97DEAE3F8FD9ADD7DB41B02 /* juno-63-stop.m4a */ = {isa = PBXFileReference; path = "juno-63-stop.m4a"; sourceTree = "<group>"; };
EA933A670288BEAFD43D468E /* rom1b-7-start.m4a */ = {isa = PBXFileReference; path = "rom1b-7-start.m4a"; sourceTree = "<group>"; };
EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */ = {isa = PBXFileReference; path = "rom1b-23-start.m4a"; sourceTree = "<group>"; };
EAC096B44D037E7BB119609A /* AccountSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignIn.swift; sourceTree = "<group>"; };
EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */ = {isa = PBXFileReference; path = "juno-109-stop.m4a"; sourceTree = "<group>"; };
EB504AAB7330EE4D4C1E4948 /* rom1a-26-start.m4a */ = {isa = PBXFileReference; path = "rom1a-26-start.m4a"; sourceTree = "<group>"; };
EBB8641EF9B0108DC0B103B7 /* juno-120-stop.m4a */ = {isa = PBXFileReference; path = "juno-120-stop.m4a"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1486,6 +1490,8 @@
F61744A7BF2B24E0C8078DE6 /* Steps */ = {
isa = PBXGroup;
children = (
E75844EDF6969530EFB56CF4 /* AccountRowView.swift */,
EAC096B44D037E7BB119609A /* AccountSignIn.swift */,
3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */,
50FFCDBD1FB2CF0A81A68D18 /* HotkeyStepView.swift */,
C59092F9220BBE7465068325 /* KeyTermsStepView.swift */,
Expand Down Expand Up @@ -2034,6 +2040,8 @@
buildActionMask = 2147483647;
files = (
0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */,
625949CE5B21104715419724 /* AccountRowView.swift in Sources */,
3182C793A397A4AC6BF4F24D /* AccountSignIn.swift in Sources */,
73FA46BD466212E9AE09C847 /* App.swift in Sources */,
B391EA5F093506B3DF318F10 /* AppCoordinator+UITesting.swift in Sources */,
3407E8759D61D569A998EE78 /* AppCoordinator.swift in Sources */,
Expand Down Expand Up @@ -2255,6 +2263,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
BLURT_APP_NAME = Blurt;
BLURT_OAUTH_API_ORIGIN = "";
BLURT_OAUTH_AUTHORIZE_URL = "";
BLURT_OAUTH_CLIENT_ID = "";
BLURT_OAUTH_DICTATION_ORIGIN = "";
BLURT_OAUTH_TOKEN_URL = "";
CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Blurt/Info.plist;
Expand Down Expand Up @@ -2369,6 +2382,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
BLURT_APP_NAME = "Blurt Dev";
BLURT_OAUTH_API_ORIGIN = "https://explore-git-staging-assemblyai.vercel.app";
BLURT_OAUTH_AUTHORIZE_URL = "https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize";
BLURT_OAUTH_CLIENT_ID = "connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff";
BLURT_OAUTH_DICTATION_ORIGIN = "https://dictation.sandbox000.assemblyai-labs.com";
BLURT_OAUTH_TOKEN_URL = "https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token";
CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Blurt/Info.plist;
Expand All @@ -2389,6 +2407,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
BLURT_APP_NAME = "Blurt Dev";
BLURT_OAUTH_API_ORIGIN = "https://explore-git-staging-assemblyai.vercel.app";
BLURT_OAUTH_AUTHORIZE_URL = "https://dashboard-assemblyai.vercel.app/dashboard/oauth/authorize";
BLURT_OAUTH_CLIENT_ID = "connected-app-test-2030e449-137e-47bc-a09c-2edbf6aa86ff";
BLURT_OAUTH_DICTATION_ORIGIN = "https://dictation.sandbox000.assemblyai-labs.com";
BLURT_OAUTH_TOKEN_URL = "https://snapdragon-coyote-7054.customers.stytch.dev/v1/oauth2/token";
CODE_SIGN_ENTITLEMENTS = Blurt/Blurt.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Blurt/Info.plist;
Expand Down
22 changes: 19 additions & 3 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import AssemblyAI
import BlurtEngine
import Foundation
import Observation

@Observable
Expand All @@ -25,6 +27,9 @@ final class AppCoordinator {
/// pipeline↔UI wiring. The wizard and the API-key view observe this directly
/// rather than reaching through the coordinator (see `APIKeyModel`).
let apiKey: APIKeyModel
/// "Sign in with AssemblyAI", which fills the same key `apiKey` holds. Its
/// configuration comes from Info.plist; a build without one hides sign-in.
let account: AssemblyAIAccount
@ObservationIgnored private var phaseObserver: Task<Void, Never>?
@ObservationIgnored private var levelsObserver: Task<Void, Never>?
@ObservationIgnored var keyTap: DictationKeyTap?
Expand Down Expand Up @@ -58,18 +63,29 @@ final class AppCoordinator {
/// its `.recording` half (via `menuBarStatus`) drives the listening state.
private(set) var isCapturing = false

/// `components` defaults to the production pipeline; `apiKey` defaults to the
/// `components` defaults to the production pipeline, its transcriber dictating
/// against the account's `dictationOrigin`; `apiKey` defaults to the
/// production Keychain-backed model. Tests/UI-tests inject deterministic
/// doubles (see `DictationComponents`) and an `APIKeyModel` built over an
/// in-memory store with an offline validator — the engine's `APIKeySubmission`
/// still owns the never-persist-an-unverified-key invariant either way.
init(
onSetupBlocked: @escaping @MainActor () -> Void,
components: DictationComponents = .production(),
apiKey: APIKeyModel = APIKeyModel()
components: DictationComponents? = nil,
apiKey: APIKeyModel = APIKeyModel(),
account: AssemblyAIAccount? = nil
) {
self.onSetupBlocked = onSetupBlocked
self.apiKey = apiKey
let info = Bundle.main.infoDictionary ?? [:]
let account =
account
?? AssemblyAIAccount(
configuration: .blurt(infoDictionary: info),
dictationHost: OAuthConfiguration.blurtDictationOrigin(infoDictionary: info),
apiKey: apiKey)
self.account = account
let components = components ?? .production(dictationOrigin: account.dictationOrigin)

// Buffering the newest is enough: each element is the *whole* ring as of that
// delivery, not a delta, so a value dropped under contention is one the next
Expand Down
13 changes: 10 additions & 3 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// normal launch is untouched — running the UI tests locally does reset
// these, by design.
PersistedSettings.resetAll()
let apiKey = APIKeyModel(
keyStore: InMemoryAPIKeyStore(),
validateKey: { UITestKeyValidation.result(for: $0) })
coord = AppCoordinator(
onSetupBlocked: onSetupBlocked,
components: .uiTest(),
apiKey: APIKeyModel(
keyStore: InMemoryAPIKeyStore(),
validateKey: { UITestKeyValidation.result(for: $0) }))
apiKey: apiKey,
// No configuration: the suite covers the paste-a-key path, and sign-in
// would open a real browser.
account: AssemblyAIAccount(configuration: nil, apiKey: apiKey, store: .inMemory()))
} else {
coord = AppCoordinator(onSetupBlocked: onSetupBlocked)
}
Expand All @@ -127,6 +131,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// on a configured launch, which is what actually installs the tap. If the
// wizard ran first the tap wouldn't exist yet and would never come up.
coord.start()
// A signed-in Mac renews its connection's key at most once a day; the
// cadence and the rule are the engine's (`AssemblyAIAccount.runRenewal`).
Task { await coord.account.runRenewal() }

// Created before the run loop presents any scene, so `MainWindowRoot` always
// finds it. On a configured launch its init reveals the overlay pill even
Expand Down
6 changes: 4 additions & 2 deletions App/Blurt/Blurt/DictationComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ struct DictationComponents {
/// The real pipeline: a fresh `MicCapture`, the AssemblyAI dictation transcriber,
/// and the clipboard-paste injector. This is what `AppCoordinator` builds, so
/// production behavior is unchanged by the test seam existing.
static func production() -> DictationComponents {
/// `dictationOrigin` is the signed-in key's dictation host
/// (`AssemblyAIAccount.dictationOrigin`); production when it holds none.
static func production(dictationOrigin: DictationOrigin? = nil) -> DictationComponents {
DictationComponents(
mic: MicCapture(),
transcriber: AssemblyAITranscriber(),
transcriber: AssemblyAITranscriber(origin: dictationOrigin),
injector: KeyInjector()
)
}
Expand Down
10 changes: 10 additions & 0 deletions App/Blurt/Blurt/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BlurtOAuthAPIOrigin</key>
<string>$(BLURT_OAUTH_API_ORIGIN)</string>
<key>BlurtOAuthAuthorizeURL</key>
<string>$(BLURT_OAUTH_AUTHORIZE_URL)</string>
<key>BlurtOAuthClientID</key>
<string>$(BLURT_OAUTH_CLIENT_ID)</string>
<key>BlurtOAuthDictationOrigin</key>
<string>$(BLURT_OAUTH_DICTATION_ORIGIN)</string>
<key>BlurtOAuthTokenURL</key>
<string>$(BLURT_OAUTH_TOKEN_URL)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
Expand Down
4 changes: 3 additions & 1 deletion App/Blurt/Blurt/Wizard/DeveloperSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,10 @@ struct ResetSection: View {
private func reset() {
let report = InstallReset(
bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem,
keyStore: coordinator.apiKey.storage
keyStore: coordinator.apiKey.storage,
signInStore: coordinator.account.storage
).run()
coordinator.account.refreshStatus()
// `hasAPIKey` is a mirror of the store, not a read-through, so it has to be
// re-read for the wizard to see the key go — which matters on the failure
// path, where the app stays running.
Expand Down
2 changes: 1 addition & 1 deletion App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ private struct GeneralSettingsTab: View {

var body: some View {
SettingsPane {
APIKeyStepView(apiKey: coordinator.apiKey)
APIKeyStepView(apiKey: coordinator.apiKey, account: coordinator.account)
HotkeyStepView(coordinator: coordinator)
MicrophoneStepView()
SoundStepView(coordinator: coordinator)
Expand Down
40 changes: 28 additions & 12 deletions App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ import SwiftUI
/// Everything the row can't hold — why a key is needed at all, where it's
/// stored, how to get one, and what went wrong when AssemblyAI rejects one —
/// lives in the sheet, which has room for it without crowding the form.
///
/// With sign-in configured (`AssemblyAIAccount.isAvailable`), an account row
/// comes first: signing in fills the same key a paste would, so once signed in
/// the key row gives way to "Signed in as …" and a Sign Out button, and the key
/// is the connection's to rotate, not the user's to change.
struct APIKeyStepView: View {
var apiKey: APIKeyModel
var account: AssemblyAIAccount

/// The key currently in the Keychain, loaded on appear (empty when none).
@State private var savedKey = ""
Expand All @@ -36,20 +42,29 @@ struct APIKeyStepView: View {
/// This view just renders what it resolves to.
private var display: APIKeyDisplay { .resolve(key: savedKey) }

/// Which rows show and which button is filled — the engine's
/// `CredentialRows`, so the rule is tested rather than restated here.
private var rows: CredentialRows {
.resolve(isKeyStored: display.isConnected, isSignInAvailable: account.isAvailable, status: account.status)
}

var body: some View {
Section {
SettingRow(title: "API Key", systemImage: "key.fill") {
HStack(spacing: 12) {
statusLabel
Button(display.editButtonTitle) { isPresentingEditor = true }
// Prominent (brand-green fill) only while there's no key: "Connect…"
// is the one thing setup is waiting on, but once a key is stored,
// "Change…" is a rare errand, and a lone filled button in Settings
// marked it as the pane's main action.
.glassButtonStyleCompat(prominent: !display.isConnected)
.accessibilityIdentifier(
display.isConnected ? UITestIdentifiers.apiKeyChange : UITestIdentifiers.apiKeyConnect
)
if rows.showsAccountRow {
AccountRowView(account: account, isPrimary: rows.primary == .signIn)
}
if rows.showsKeyRow {
SettingRow(title: "API Key", systemImage: "key.fill") {
HStack(spacing: 12) {
statusLabel
Button(display.editButtonTitle) { isPresentingEditor = true }
// Prominent (brand-green fill) only while it is the one thing
// setup is waiting on; see `CredentialRows.primary`.
.glassButtonStyleCompat(prominent: rows.primary == .connect)
.accessibilityIdentifier(
display.isConnected ? UITestIdentifiers.apiKeyChange : UITestIdentifiers.apiKeyConnect
)
}
}
}
} header: {
Expand All @@ -66,6 +81,7 @@ struct APIKeyStepView: View {
// a key is already saved, this flips `hasAPIKey` true so the wizard advances
// to the ready screen instead of stranding the user on a setup step.
apiKey.refreshStatus()
account.refreshStatus()
}
// The setup window and Settings both host this section, so a key connected
// in one has to reach the other's row — a snapshot taken once on appear
Expand Down
Loading
Loading