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
64 changes: 49 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,12 @@ framework, or notarization rejects the build; roll-forward-only for a bad releas
- **Unit tests use Swift Testing** (`@Suite`/`@Test`/`#expect`), not XCTest. The XCUITest bundle is
the one exception (XCTest, because XCUIAutomation requires it).
- **Pure projections over shell logic**: phase→UI mapping (`OverlayUIState`, `MenuBarStatus`),
chime edges (`RecordingCueGate`), geometry (`OverlayPlacement`, `MeterBarGeometry`) and history
(`RecentDictations`) live in the engine as value types so they're unit-testable; the AppKit side
just renders whatever they resolve to. Keep new logic on that side of the line.
chime edges (`RecordingCueGate`), geometry (`OverlayPlacement`, `MeterBarGeometry`), history
(`RecentDictations`), alert wording (`UpdateAlertContent`, `APIKeySubmission.FailureReport`),
credential display (`APIKeyDisplay`) and setup policy (`SetupReadiness`) live in the engine as value
types so they're unit-testable; the AppKit side just renders whatever they resolve to. Keep new logic
on that side of the line — the shell has no test target, so wording and classification assembled at
a SwiftUI/`NSAlert` call site is covered by nothing.
- **Docs**: prose lines are wrapped ~100 cols in this file; prettier owns Markdown formatting
(`proseWrap: preserve`, so wrapping is yours to keep tidy) and `MD013` is off.

Expand Down Expand Up @@ -357,9 +360,11 @@ working.
The trigger is editable in the Shortcut section of the setup/settings UI (`HotkeyStepView`) — a
`Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore`, after which
`AppCoordinator.dictationBindingChanged()` re-reads it into the tap. For display strings, use
`TriggerKeyStore().triggerKey.label` for one-shot reads, or `@AppStorage(TriggerKeyStore.defaultsKey)`

- `TriggerKey.fromPersisted` in views that must re-render live on a Settings change.
`TriggerKeyStore().triggerKey.label` for one-shot reads; in views that must re-render live on a
Settings change, use **`@BoundTriggerKey`** — a `DynamicProperty` in `HotkeyStepView.swift` wrapping
the `@AppStorage(TriggerKeyStore.defaultsKey)` + `TriggerKey.fromPersisted` pair — rather than
restating that pairing per view. The unset default belongs to `fromPersisted` (an absent keycode maps
to right ⌘), so views must not re-declare `TriggerKey.rightCommand.rawValue` themselves.

## Transcription prompt

Expand Down Expand Up @@ -409,9 +414,29 @@ resolves.

History: **`RecentDictations`** is an in-memory, newest-first ring shown in the ready window (never
written to disk). **`DictationLog`** appends each completed dictation with its context snapshot to
`~/Library/Logs/Blurt/dictations.jsonl` (`DictationLog.defaultURL`) — but **only** while developer
mode is on; with it off, nothing is written. The Settings window's Developer section surfaces both the
switch and the path.
`~/Library/Logs/Blurt/dictations.jsonl` (`DictationLog.defaultURL`, or `defaultDisplayPath` for the
home-abbreviated form to show in UI — derived next to the URL so the label can't drift from the write
target) — but **only** while developer mode is on; with it off, nothing is written. The Settings
window's Developer section surfaces both the switch and the path.

API key: stored in the macOS Keychain via **`APIKeyStore`**, a thin static facade over
**`MemoizedKeyStore`** (which takes its storage as `read`/`write` closures, so the memo-and-write rules
are unit-tested against a double instead of the real item). The injectable seam is **`APIKeyGateway`**
— `ProductionAPIKeyStore` forwards to the Keychain, `InMemoryAPIKeyStore` keeps automated runs away
from it. **`APIKeySubmission`** owns the validate-then-save flow (an unverified key never persists) and
its `Outcome.failureReport` classifies a failure as `.inline` (recoverable, shown beside the field) or
`.alert` (a Keychain fault retyping can't fix), so `APIKeyStepView` can't disagree with the engine
about severity. **`APIKeyDisplay.resolve(key:)`** owns how a stored key is _presented_ — masked tail,
status and VoiceOver wording, connect-vs-rotate control titles. The mask reveals only the last four
characters and, below `minimumLengthToMask`, none at all (a bare `suffix(4)` in the view once rendered
a short key whole).

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
during setup, coasting once ready), and `PermissionStatus.lostGrant(since:)` the revocation edge that
pulls a configured app back into onboarding. `WizardController` applies all three rather than
restating them.

## App shell surfaces

Expand All @@ -433,21 +458,30 @@ switch and the path.
## Updates

Update checking is **manual and download-only** — no in-place install, no background auto-updater.
Two pieces:
Three pieces:

- **`UpdateChecker`** (`Sources/BlurtEngine/Update/UpdateChecker.swift`) — a `Sendable` struct that
`GET`s `https://api.github.com/repos/AssemblyAI/blurt/releases/latest`, decodes `GitHubRelease`
(internal), parses the tag as a `SemanticVersion`, and returns `.upToDate` or
`.available(version:, dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag,
or a newer release with no `.dmg` asset. The call goes through the injected `HTTPTransport`, so
tests run against fixture JSON (`UpdateCheckerTests`); no AppKit here.
- **`UpdateAlertContent`** (`Sources/BlurtEngine/Update/UpdateAlertContent.swift`) — what each result
_says_: title, body, button titles (the first is the default), the `downloadURL` that default button
opens, and the alert style. A pure projection of a result into wording, owned in the engine for the
same reason as `OverlayUIState.accessibilityLabel`. `appVersionLabel(_:)` is the shared "Blurt
0.1.31" form, so the alerts and the Settings Updates row can't name the version two ways. The
memberwise init is private: an alert comes from a named result (`upToDate` / `available` /
`checkFailed`), never assembled ad hoc in the shell.
- **`UpdateCheckModel`** (`App/Blurt/Blurt/Update/UpdateCheckModel.swift`, `@MainActor`) — runs a
user-initiated check and reports it in a Sparkle-style alert: _up to date_, _available_ (**Download**
opens the release DMG in the browser, **Later** dismisses), or a recoverable _couldn't check_. It
presents via `beginSheetModal` on the host window (a nested `runModal()` loop was reported as an app
hang, so `runModal()` is the fallback only when no window can host a sheet), and an `isChecking`
guard stops the Settings button and the app-menu command from stacking two alerts. One shared
instance (owned by `AppDelegate`) backs both entry points.
opens the release DMG in the browser, **Later** dismisses), or a recoverable _couldn't check_ (every
throw, plus an unparseable bundle version, collapses to that one content). It holds only the AppKit
half: build an `NSAlert` from an `UpdateAlertContent`, then open `downloadURL` if the default button
came back. It presents via `beginSheetModal` on the host window (a nested `runModal()` loop was
reported as an app hang, so `runModal()` is the fallback only when no window can host a sheet), and
an `isChecking` guard stops the Settings button and the app-menu command from stacking two alerts.
One shared instance (owned by `AppDelegate`) backs both entry points.

## Tests

Expand Down
14 changes: 4 additions & 10 deletions App/Blurt/Blurt/MenuBar/MenuBarScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,14 @@ struct MenuBarContent: View {
var appDelegate: AppDelegate
@Environment(\.openSettings) private var openSettings

// Read the persisted trigger keycode directly (as the ready screen does) so
// the reminder line updates live when the dictation key is rebound in
// Settings; reading `TriggerKeyStore` once wouldn't re-render the menu.
@AppStorage(TriggerKeyStore.defaultsKey) private var triggerKeyCode: Int =
TriggerKey.rightCommand.rawValue

private var triggerLabel: String {
TriggerKey.fromPersisted(triggerKeyCode).label
}
// Observed (as the ready screen does) so the reminder line updates live when
// the dictation key is rebound in Settings — see `BoundTriggerKey`.
@BoundTriggerKey private var triggerKey

var body: some View {
// Disabled informational row: the dictation trigger is an invisible lone
// modifier, so spell it out here as the menu bar's discoverability anchor.
Text("Tap or hold \(triggerLabel) to dictate and paste")
Text("Tap or hold \(triggerKey.label) to dictate and paste")

Divider()

Expand Down
8 changes: 5 additions & 3 deletions App/Blurt/Blurt/Overlay/OverlayWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ final class OverlayWindowController {
// `radius: 10, y: 3` shadow — otherwise the falloff is cut off mid-gradient and
// reads as a hard line around the pill rather than a soft shadow.
static let shadowMargin: CGFloat = 28
private static let panelSize = CGSize(
width: pillSize.width + shadowMargin * 2,
height: pillSize.height + shadowMargin * 2)
// The pill/panel size relationship is the engine's, next to the `panelOrigin`
// that backs the placement clearance off by the same margin — so a change to
// `shadowMargin` is checked on both sides rather than only one.
private static let panelSize = OverlayPlacement.panelSize(
pillSize: pillSize, shadowMargin: shadowMargin)

private let panel: NSPanel
private let hosting: NSHostingView<OverlayView>
Expand Down
85 changes: 42 additions & 43 deletions App/Blurt/Blurt/Update/UpdateCheckModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import Observation
/// instance backs both (owned by `AppDelegate`). No in-place install: on an
/// available update the alert offers **Download** (opens the release DMG in the
/// browser) or **Later**.
///
/// What each result *says* — titles, bodies, button titles, and which button
/// downloads — is the engine's `UpdateAlertContent`, where `swift test` covers
/// it. What's left here is the AppKit half: turning a content value into an
/// `NSAlert`, hosting it as a sheet, and opening the URL the default button
/// carries.
@MainActor
@Observable
final class UpdateCheckModel {
Expand All @@ -24,9 +30,12 @@ final class UpdateCheckModel {
/// itself while a check runs — the feedback a slow connection otherwise lacks.
private(set) var isChecking = false

/// The running app version for display in the Settings "Updates" section
/// (nil when the bundle version can't be parsed), e.g. "0.1.31".
var currentVersionText: String? { currentVersion.map { "\($0)" } }
/// Title of the Settings "Updates" row, e.g. "Blurt 0.1.31" — or just "Blurt"
/// when the bundle version can't be parsed (the button still works; the check
/// reads the version itself). The wording is the engine's, shared with the
/// alerts, so the row and the "you have …" line can't name the version two
/// different ways.
var versionLabel: String { UpdateAlertContent.appVersionLabel(currentVersion) }

/// `currentVersion`, `openURL`, and `presentingWindow` are injected (with
/// sensible production defaults) so this stays exercisable without a real
Expand All @@ -49,61 +58,51 @@ final class UpdateCheckModel {
guard !isChecking else { return }
guard let currentVersion else {
log.error("no parseable CFBundleShortVersionString; can't check for updates")
Task { await presentFailure() }
// Indistinguishable from any other failed check as far as the user is
// concerned — which is why it shares the one `.checkFailed` content
// rather than a second hand-rolled alert.
Task { await present(.checkFailed) }
return
}
isChecking = true
Task {
defer { isChecking = false }
do {
switch try await checker.check(current: currentVersion) {
case .upToDate:
await presentUpToDate(current: currentVersion)
case .available(let version, let dmgURL):
await presentAvailable(current: currentVersion, version: version, dmgURL: dmgURL)
}
} catch {
log.error("update check failed: \(error.localizedDescription, privacy: .public)")
await presentFailure()
}
await present(await resolveContent(current: currentVersion))
}
}

/// "You're up to date" — the reassuring result the classic updater shows so a
/// user-initiated check always visibly confirms it ran.
private func presentUpToDate(current: SemanticVersion) async {
let alert = NSAlert()
alert.messageText = "You’re up to date"
alert.informativeText = "Blurt \(current) is the latest version."
alert.addButton(withTitle: "OK")
_ = await runAlert(alert)
/// Runs the check and resolves what to show. Every throw — offline, GitHub
/// unreachable, malformed JSON, an unparseable tag — is the same recoverable
/// "couldn't check" to the user, so they collapse to one content value here.
private func resolveContent(current: SemanticVersion) async -> UpdateAlertContent {
do {
return UpdateAlertContent(result: try await checker.check(current: current), current: current)
} catch {
log.error("update check failed: \(error.localizedDescription, privacy: .public)")
return .checkFailed
}
}

/// "A new version is available" — **Download** (default) opens the release DMG
/// in the browser; **Later** dismisses. `current` is the running version the
/// check compared against (always known by the time we get here).
private func presentAvailable(current: SemanticVersion, version: SemanticVersion, dmgURL: URL) async {
/// Presents `content` as an alert and performs its default action. The only
/// judgement left here is AppKit-shaped: the *first* button is the default, so
/// that response — and only when the content carries a URL — is the download.
private func present(_ content: UpdateAlertContent) async {
let alert = NSAlert()
alert.messageText = "A new version of Blurt is available"
alert.informativeText = "Blurt \(version) is available—you have \(current). Download it now?"
alert.addButton(withTitle: "Download") // default (first button)
alert.addButton(withTitle: "Later")
if await runAlert(alert) == .alertFirstButtonReturn {
alert.messageText = content.title
alert.informativeText = content.message
// `NSAlert` already defaults to `.warning`, and on current macOS `.warning`
// and `.informational` render identically (only `.critical` adds the caution
// badge), so only the caution case is worth stating — same presentation the
// three hand-built alerts had.
if content.style == .warning { alert.alertStyle = .warning }
Comment on lines +93 to +97
for title in content.buttons {
alert.addButton(withTitle: title) // first added is the default button
}
if await runAlert(alert) == .alertFirstButtonReturn, let dmgURL = content.downloadURL {
openURL(dmgURL)
}
}

/// A recoverable "couldn't check" result (offline, GitHub unreachable, a
/// malformed response). The user just tries again.
private func presentFailure() async {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Couldn’t check for updates"
alert.informativeText = "Check your internet connection and try again."
alert.addButton(withTitle: "OK")
_ = await runAlert(alert)
}

/// Presents `alert` as a sheet on the host window and awaits the choice. A
/// sheet keeps the main thread on its run loop, unlike `runModal()`'s nested
/// modal loop (reported as an app hang). Falls back to `runModal()` only when
Expand Down
11 changes: 4 additions & 7 deletions App/Blurt/Blurt/Wizard/ReadyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ import SwiftUI
struct ReadyView: View {
var coordinator: AppCoordinator
var openSettings: () -> Void
// Observe the persisted trigger keycode directly so changing the dictation key
// in the (separate) Settings window re-renders this window's keycap live.
// `@AppStorage` reflects writes to the same default across windows; reading
// `TriggerKeyStore` (plain UserDefaults) would not trigger a re-render.
@AppStorage(TriggerKeyStore.defaultsKey) private var triggerKeyCode: Int =
TriggerKey.rightCommand.rawValue
// Observed (not read once) so changing the dictation key in the separate
// Settings window re-renders this window's keycap live — see `BoundTriggerKey`.
@BoundTriggerKey private var triggerKey

var body: some View {
// Sections sit 20 pt apart; the logo and shortcut readout are one idea,
Expand Down Expand Up @@ -54,7 +51,7 @@ struct ReadyView: View {
HStack(spacing: 6) {
Text("Tap or hold")
.foregroundStyle(.secondary)
KeyCap(label: TriggerKey.fromPersisted(triggerKeyCode).label)
KeyCap(label: triggerKey.label)
Text("to blurt")
.foregroundStyle(.secondary)
}
Expand Down
22 changes: 7 additions & 15 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ private struct UpdateSection: View {

var body: some View {
Section {
SettingRow(title: versionTitle, systemImage: "arrow.triangle.2.circlepath") {
// "Blurt 0.1.31" — the label is the engine's (shared with the result
// alerts, so the two can't name the version differently).
SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") {
HStack(spacing: 8) {
// A user-initiated check that can stall on a slow connection needs
// visible progress, or the button reads as dead until the result
Expand All @@ -110,12 +112,6 @@ private struct UpdateSection: View {
Text("Updates")
}
}

/// "Blurt 0.1.31" when the version is known, otherwise a plain "Blurt" (the
/// button still works — the check reads the version itself).
private var versionTitle: String {
model.currentVersionText.map { "Blurt \($0)" } ?? "Blurt"
}
}

/// The Developer section of the Settings window: an opt-in switch for developer
Expand All @@ -137,15 +133,11 @@ private struct DeveloperSection: View {
} header: {
Text("Developer")
} footer: {
Text("Logs each dictation to \(Self.logPath).")
// The home-abbreviated path is derived in the engine next to the URL the
// writer appends to, so this label can never drift from where the log
// actually lands.
Text("Logs each dictation to \(DictationLog.defaultDisplayPath).")
.textSelection(.enabled)
}
}

/// The dictation log's location, home-abbreviated for display (the engine
/// exposes the real URL so this label can never drift from where the log is
/// actually written).
private static var logPath: String {
(DictationLog.defaultURL.path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath
}
}
Loading