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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ results*.json
App/BlurtiOS/BlurtiOS.xcodeproj/
App/BlurtiOS/*/Info.plist
App/BlurtiOS/*/*.entitlements

# Sign in with AssemblyAI's dev values name AssemblyAI's sandbox deployments,
# which stay out of this public repo (see App/Blurt/Config/SignIn.xcconfig).
App/Blurt/Config/SignIn.local.xcconfig
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,45 @@ 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) sleeps until the renewal is due — when the exchange said the
key rotates (`api_key_rotates_at`: AMS mints the next key and disables the old one once an exchange
lands inside its window, two days before expiry), else two days before expiry (`SignInSession.renewalDue`;
at most a day per sleep, on the continuous clock so a renewal that fell due while the Mac slept runs on
wake) — then refreshes and re-exchanges: one renewal per key, since the
refresh token's three-month sliding lifetime dwarfs a key's two weeks. `KeyRejectionReporter` wraps the
transcriber and calls `keyWasRejected()` when dictation refuses the key (401, or the API's invalid-key
404), which renews at once (at most every 15 minutes) so the next dictation has a working key; the
refused utterance itself can't be retried, its audio having streamed as it was spoken. A renewal that brings back the same key with its renewal already due (a server that didn't rotate
early) defers the next one to the key's expiry, or a day out once it has expired, instead of asking
every minute. Renewal keeps a rotated refresh token before anything else can fail; a refused refresh
or exchange means the connection was ended elsewhere and drops key and session, an unreachable server
changes nothing. A renewal suspends twice on the network, so a `generation` counter makes one that
wakes after a sign-out, reset or new sign-in write nothing, and `signOut()` waits out a renewal in
flight first. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and
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_*`, from the gitignored `App/Blurt/Config/SignIn.local.xcconfig` via `SignIn.xcconfig` on the dev configurations, 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 the app's `baseURL` closure for `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
20 changes: 20 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 @@ -484,6 +486,7 @@
0F3D1DBFA369BAF807DBB55F /* juno-1-start.m4a */ = {isa = PBXFileReference; path = "juno-1-start.m4a"; sourceTree = "<group>"; };
0FB3FBD7C42F949C52B09F0C /* juno-21-start.m4a */ = {isa = PBXFileReference; path = "juno-21-start.m4a"; sourceTree = "<group>"; };
0FB812876EC4870E9A74BB50 /* juno-55-start.m4a */ = {isa = PBXFileReference; path = "juno-55-start.m4a"; sourceTree = "<group>"; };
0FDF98A81095D247FB92B90D /* SignIn.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SignIn.xcconfig; sourceTree = "<group>"; };
10A0336924600313EE5243AE /* rom1a-8-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-8-stop.m4a"; sourceTree = "<group>"; };
1118A82ED3D1CD5CF429099E /* rom1b-23-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-23-stop.m4a"; sourceTree = "<group>"; };
114E0EA1827EA005CF188FC8 /* juno-119-start.m4a */ = {isa = PBXFileReference; path = "juno-119-start.m4a"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -860,6 +863,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 +873,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 @@ -960,6 +965,7 @@
1560402FBA5911463036085F /* Blurt */,
73649A7C02EB13B17D81A412 /* BlurtUITests */,
E0FCB536C7F1A0AD06EE625D /* brand */,
CE00AEC94B430DEF79A46820 /* Config */,
4B1375E36CD42DBEFF08BC7E /* Packages */,
D8F69C40E4A93A6B3433BAC0 /* Shared */,
10E2E11380C974390308C1D2 /* Products */,
Expand Down Expand Up @@ -1439,6 +1445,14 @@
path = Shared;
sourceTree = "<group>";
};
CE00AEC94B430DEF79A46820 /* Config */ = {
isa = PBXGroup;
children = (
0FDF98A81095D247FB92B90D /* SignIn.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
D8F69C40E4A93A6B3433BAC0 /* Shared */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1486,6 +1500,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 +2050,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 @@ -2366,6 +2384,7 @@
};
CD58E0085469061FEE527931 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
BLURT_APP_NAME = "Blurt Dev";
Expand All @@ -2386,6 +2405,7 @@
};
DEC447B087F81B0E6C3138D2 /* Debug-Local */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0FDF98A81095D247FB92B90D /* SignIn.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
BLURT_APP_NAME = "Blurt Dev";
Expand Down
26 changes: 23 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,33 @@ 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,
onKeyRejected: { Task { await account.keyWasRejected() } })

// 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 shortly before it expires;
// the schedule is 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
14 changes: 12 additions & 2 deletions App/Blurt/Blurt/DictationComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ 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.
/// `onKeyRejected` hears about a dictation refused for its key
/// (`KeyRejectionReporter`), so the account can renew at once.
static func production(
dictationOrigin: DictationOrigin? = nil, onKeyRejected: @escaping @Sendable () -> Void = {}
) -> DictationComponents {
DictationComponents(
mic: MicCapture(),
transcriber: AssemblyAITranscriber(),
transcriber: KeyRejectionReporter(
AssemblyAITranscriber(baseURL: {
dictationOrigin?.current ?? AssemblyAITranscriber.defaultBaseURL
}),
onRejected: onKeyRejected),
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
Loading
Loading