From 51b0ef53637f85149f3461b3b3b07512e87cab44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:43:10 +0000 Subject: [PATCH] Push app-shell presentation logic down into the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven places where pure logic sat in the AppKit/SwiftUI shell — which has no test target — rather than in BlurtEngine, where `swift test` covers it. Each follows the pattern already set by `OverlayUIState`, `MenuBarStatus`, `RecordingCueGate`, `MeterBarGeometry`, and `RecentDictations.reservedHeight`. Engine gains: - `UpdateAlertContent` — title, body, buttons, and the `downloadURL` the default button opens, for each update-check result. `UpdateCheckModel` kept three hand-built `NSAlert`s whose wording, button order, and "first button downloads" rule were covered by nothing; it now holds only the AppKit half. `appVersionLabel(_:)` is the shared "Blurt 0.1.31" form, so the alerts and the Settings Updates row can't word the version two ways (it was composed in three places). - `APIKeyDisplay` — the account row's masked tail, status/VoiceOver wording, and connect-vs-rotate control titles. The view's fixed `"••••\(key.suffix(4))"` rendered a key shorter than four characters whole; the mask now reveals nothing below `minimumLengthToMask`. - `APIKeySubmission.Outcome.failureReport` — whether a failure is `.inline` (recoverable, beside the field) or `.alert` (a Keychain fault retyping can't fix), messages included. Same move as `PipelinePhase.setupBlocker`: the sheet renders the engine's classification instead of re-deriving severity. - `SetupReadiness` + `PermissionStatus.lostGrant(since:)` — the fully-configured gate, the permission-poll cadence, and the revocation edge that pulls the user back into onboarding, all previously inline in `WizardController`. - `OverlayPlacement.panelSize(pillSize:shadowMargin:)` — the other half of the pill/panel relationship `panelOrigin` already corrects for, so a change to the shadow margin is checked on both sides. - `DictationLog.defaultDisplayPath` — the home-abbreviated log path, next to the URL the writer uses, keeping the `NSString` bridge out of a SwiftUI view. App-side, `@BoundTriggerKey` replaces the `@AppStorage` + `TriggerKey.fromPersisted` pair that three views each spelled out along with a restated right-⌘ default that `fromPersisted` already owns. No user-visible behavior change: every string, button order, and alert style is preserved (`NSAlert` already defaults to `.warning`, so only the caution case sets it, as before). New suites: `UpdateAlertContentTests`, `APIKeyDisplayTests`, `SetupReadinessTests`, plus additions to the submission, placement, and log suites. Docs updated in `AGENTS.md` / `BLURTENGINE.md`. Verified with `scripts/check.sh --portable` (green); the Swift build, tests, and lint run on CI (macos-26), which is the authority on green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016GUfBes8NA1SSNugHQtdb8 --- AGENTS.md | 7 +- App/Blurt/Blurt/MenuBar/MenuBarScene.swift | 14 +-- .../Overlay/OverlayWindowController.swift | 8 +- App/Blurt/Blurt/Update/UpdateCheckModel.swift | 85 +++++++------ App/Blurt/Blurt/Wizard/ReadyView.swift | 11 +- .../Blurt/Wizard/SettingsWindowRoot.swift | 22 ++-- .../Blurt/Wizard/Steps/APIKeyStepView.swift | 103 ++++++++------- .../Blurt/Wizard/Steps/HotkeyStepView.swift | 23 ++++ .../Wizard/Steps/PermissionsStepView.swift | 11 +- App/Blurt/Blurt/Wizard/WizardController.swift | 22 ++-- BLURTENGINE.md | 8 +- .../BlurtEngine/Config/APIKeyDisplay.swift | 103 +++++++++++++++ .../BlurtEngine/Config/APIKeySubmission.swift | 37 ++++++ .../Permissions/PermissionsChecker.swift | 10 ++ .../Permissions/SetupReadiness.swift | 32 +++++ .../BlurtEngine/Pipeline/DictationLog.swift | 8 ++ .../Pipeline/OverlayPlacement.swift | 16 +++ .../Update/UpdateAlertContent.swift | 107 ++++++++++++++++ .../BlurtEngineTests/APIKeyDisplayTests.swift | 117 ++++++++++++++++++ .../APIKeySubmissionTests.swift | 47 +++++++ .../BlurtEngineTests/DictationLogTests.swift | 24 ++++ .../OverlayPlacementTests.swift | 35 ++++++ .../SetupReadinessTests.swift | 62 ++++++++++ .../UpdateAlertContentTests.swift | 97 +++++++++++++++ 24 files changed, 860 insertions(+), 149 deletions(-) create mode 100644 Sources/BlurtEngine/Config/APIKeyDisplay.swift create mode 100644 Sources/BlurtEngine/Permissions/SetupReadiness.swift create mode 100644 Sources/BlurtEngine/Update/UpdateAlertContent.swift create mode 100644 Tests/BlurtEngineTests/APIKeyDisplayTests.swift create mode 100644 Tests/BlurtEngineTests/SetupReadinessTests.swift create mode 100644 Tests/BlurtEngineTests/UpdateAlertContentTests.swift diff --git a/AGENTS.md b/AGENTS.md index 5ba4a0a..95c52a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Blurt is a macOS dictation app powered by [AssemblyAI](https://www.assemblyai.co - **`Sources/BlurtEngine/`** — Swift Package (`swift-tools-version:6.2`, macOS 15+) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell dependencies and **no external SPM dependencies** (just Foundation/Security/AVFoundation plus toolchain modules like Synchronization). - **`App/Blurt/`** — AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, a single setup/settings window, and a hotkey. It has **no external SPM dependencies** — its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`); the former `mxcl/AppUpdater` dependency and its in-place self-updater were removed (see [Updates](#updates)). The rebindable dictation trigger is **home-grown** — a `CGEventTap` (`DictationKeyTap`) over a pure engine state machine (`DictationKeyGate`), no package. The engine's hard no-external-dependencies rule is a **rule**; the app merely happens to carry none today. -The AssemblyAI API key is stored in the macOS Keychain via `APIKeyStore` (`Sources/BlurtEngine/Config/APIKeyStore.swift`) and read by the transcriber. `APIKeyStore` is a thin static facade: the memo-and-write rules live in `MemoizedKeyStore`, which takes its storage as `read`/`write` closures so those rules are unit-tested against a double instead of the real Keychain item. The injectable seam over it is `APIKeyGateway` (`Sources/BlurtEngine/Config/APIKeyGateway.swift`): `ProductionAPIKeyStore` forwards to the Keychain, and `InMemoryAPIKeyStore` keeps automated runs away from the real item. +The AssemblyAI API key is stored in the macOS Keychain via `APIKeyStore` (`Sources/BlurtEngine/Config/APIKeyStore.swift`) and read by the transcriber. `APIKeyStore` is a thin static facade: the memo-and-write rules live in `MemoizedKeyStore`, which takes its storage as `read`/`write` closures so those rules are unit-tested against a double instead of the real Keychain item. The injectable seam over it is `APIKeyGateway` (`Sources/BlurtEngine/Config/APIKeyGateway.swift`): `ProductionAPIKeyStore` forwards to the Keychain, and `InMemoryAPIKeyStore` keeps automated runs away from the real item. How the stored key is _presented_ is also the engine's: `APIKeyDisplay.resolve(key:)` (`Sources/BlurtEngine/Config/APIKeyDisplay.swift`) owns the masked tail, the status/VoiceOver wording, and the connect-vs-rotate control titles — the mask reveals only the last four characters and, for a key shorter than `minimumLengthToMask`, none at all (a bare `suffix(4)` in the view once rendered a short key whole). `APIKeySubmission.Outcome.failureReport` likewise owns whether a failure is `.inline` or a `.alert`-worthy system fault, so `APIKeyStepView` can't disagree with the engine about severity. ## Common commands @@ -87,7 +87,8 @@ and the roll-forward-only policy for a bad release are documented in Update checking is **manual and download-only** — there is no in-place install and no background auto-updater (both were removed with `mxcl/AppUpdater`; see [Things deliberately not here](#things-deliberately-not-here)). Two pieces: - **`UpdateChecker`** (`Sources/BlurtEngine/Update/UpdateChecker.swift`, engine) — 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 network call goes through the injected `HTTPTransport`, so tests run against fixture JSON (`UpdateCheckerTests`); no AppKit here. -- **`UpdateCheckModel`** (`App/Blurt/Blurt/Update/UpdateCheckModel.swift`, app, `@MainActor`) — runs a user-initiated check and reports the result 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; `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. A single shared instance (owned by `AppDelegate`) backs both entry points. +- **`UpdateAlertContent`** (`Sources/BlurtEngine/Update/UpdateAlertContent.swift`, engine) — what each result _says_: title, body, button titles (first is the default), the `downloadURL` that default button opens, and the alert style. Owned in the engine for the same reason as `OverlayUIState.accessibilityLabel` — it's a pure projection of a result into wording, and the shell that draws it has no test target. `appVersionLabel(_:)` is the shared "Blurt 0.1.31" form, so the alerts and the Settings Updates row can't name the version two ways. Its memberwise init is private: a new alert comes from a named result (`upToDate` / `available` / `checkFailed`), not assembled ad hoc in the shell. +- **`UpdateCheckModel`** (`App/Blurt/Blurt/Update/UpdateCheckModel.swift`, app, `@MainActor`) — runs a user-initiated check and reports the result 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_ (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; `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. A single shared instance (owned by `AppDelegate`) backs both entry points. ## Architecture @@ -120,7 +121,7 @@ The dictation trigger is a **single lone modifier key** (tap-to-toggle or hold-t The app side, **`DictationKeyTap`** (`App/Blurt/Blurt/Hotkey/DictationKeyTap.swift`), reduces each `CGEventTap` delivery (watching `flagsChanged` for the bound modifier and `keyDown` for any other key) to a `DictationKeyRouter.Event` and owns the tap lifecycle. `AppCoordinator` calls its `syncAfterTerminalPhase()` on every terminal phase: a dictation can end with no key event to close the gate (the auto-release cap, or a refused/failed press), which would leave the gate `.latched` and silently swallow the user's next press entirely — a latched `modifierDown` returns `.none`, and the `modifierUp` after it returns `.stop`, which no-ops on an already-terminal session. It **swallows nothing** — a lone modifier types nothing, and combos pass through so normal shortcuts keep working — unlike the old chord trigger. -The trigger is editable in the Shortcut section of the setup/settings window (`HotkeyStepView`), which is a `Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore` (then `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. +The trigger is editable in the Shortcut section of the setup/settings window (`HotkeyStepView`), which is a `Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore` (then `AppCoordinator.dictationBindingChanged()` re-reads it into the tap). For display strings, use `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` + `TriggerKey.fromPersisted` pair) rather than restating that pairing — and note the unset default belongs to `fromPersisted`, which maps an absent keycode to right ⌘, so views must not re-declare `TriggerKey.rightCommand.rawValue` themselves. ## Transcription prompt diff --git a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift index 95dcb03..fc3b2d0 100644 --- a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift +++ b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift @@ -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() diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index 0e9ec73..2190157 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -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 diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift index ee1c623..29fa7ed 100644 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ b/App/Blurt/Blurt/Update/UpdateCheckModel.swift @@ -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 { @@ -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 @@ -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 } + 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 diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index 632e35b..5075bec 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -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, @@ -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) } diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 8b2ed58..4003516 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -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 @@ -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 @@ -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 - } } diff --git a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift index 86dcb43..2f82321 100644 --- a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift @@ -30,14 +30,20 @@ struct APIKeyStepView: View { @State private var savedKey = "" @State private var isPresentingEditor = false + /// How the stored key is presented — the masking rule, the status wording, and + /// the control titles all live in the engine's `APIKeyDisplay`, where they're + /// unit-tested (a fixed `suffix(4)` here once put a short key on screen whole). + /// This view just renders what it resolves to. + private var display: APIKeyDisplay { .resolve(key: savedKey) } + var body: some View { Section { SettingRow(title: "API Key", systemImage: "key.fill") { HStack(spacing: 12) { statusLabel - Button(savedKey.isEmpty ? "Connect…" : "Change…") { isPresentingEditor = true } + Button(display.editButtonTitle) { isPresentingEditor = true } .accessibilityIdentifier( - savedKey.isEmpty ? UITestIdentifiers.apiKeyConnect : UITestIdentifiers.apiKeyChange + display.isConnected ? UITestIdentifiers.apiKeyChange : UITestIdentifiers.apiKeyConnect ) } } @@ -70,27 +76,18 @@ struct APIKeyStepView: View { } /// The row's state: "Not connected", or the stored key's masked tail once one - /// exists. Monospaced so the four revealed characters line up as an identifier - /// rather than reading as prose. - @ViewBuilder + /// exists. Monospaced only when the text *is* an identifier, so the revealed + /// characters line up as a key ending rather than prose being set in code type. private var statusLabel: some View { - if savedKey.isEmpty { - Text("Not connected") - .foregroundStyle(.secondary) - .accessibilityIdentifier(UITestIdentifiers.apiKeyNotConnected) - } else { - Text(maskedKey) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - // The bullets are decoration; spell the state out for VoiceOver. - .accessibilityLabel("Connected, key ending \(savedKey.suffix(4))") - .accessibilityIdentifier(UITestIdentifiers.apiKeySavedStatus) - } + Text(display.statusText) + .font(display.rendersIdentifier ? .body.monospaced() : .body) + .foregroundStyle(.secondary) + // The bullets are decoration; the engine's label spells the state out. + .accessibilityLabel(display.accessibilityLabel) + .accessibilityIdentifier( + display.isConnected + ? UITestIdentifiers.apiKeySavedStatus : UITestIdentifiers.apiKeyNotConnected) } - - /// The stored key as `••••` plus its last four characters — enough to tell two - /// keys apart (which account is this?) without putting the secret on screen. - private var maskedKey: String { "••••\(savedKey.suffix(4))" } } /// The credential-entry task itself, presented as a sheet from the settings row. @@ -128,8 +125,9 @@ private struct APIKeyEditorSheet: View { /// A non-inline system fault (the Keychain write itself failed). Retyping the /// key can't fix it, so it's surfaced as an alert rather than footer text — /// the convention for genuine faults, and the reason the recoverable cases - /// above are *not* alerts. - @State private var showSaveFault = false + /// above are *not* alerts. Which outcomes land here rather than inline is the + /// engine's call (`APIKeySubmission.Outcome.failureReport`), wording included. + @State private var saveFault: SaveFault? /// The in-flight verify-then-store. Held so dismissing the sheet can cancel /// it: without that, Cancel/Escape during a check would let the submission run /// on and persist the key anyway — a Cancel that silently commits. @@ -146,28 +144,36 @@ private struct APIKeyEditorSheet: View { /// The draft trimmed to usable text (the engine's shared rule), nil when blank. private var trimmedKey: String? { draft.trimmedNonEmpty() } + /// The first-connect vs. rotate wording, resolved from the same engine + /// projection the row uses so the sheet and the row can't disagree about + /// whether a key is stored. + private var display: APIKeyDisplay { .resolve(key: savedKey) } + + /// The alert-shaped failure the engine reported, carried as view state so the + /// alert can present it. Purely a container — the strings are the engine's. + private struct SaveFault { + let title: String + let message: String + } + + /// Presentation binding for the fault alert: clearing it on dismiss is what + /// lets a later fault present again. + private var isPresentingSaveFault: Binding { + Binding(get: { saveFault != nil }, set: { if !$0 { saveFault = nil } }) + } + /// Enabled for any non-empty key while no validation is in flight. We don't /// gate on "differs from the saved key" — re-submitting an unchanged key is a /// harmless re-validate against AssemblyAI, and a button that's disabled for a /// reason the user can't see is worse than a redundant round trip. private var canSubmit: Bool { trimmedKey != nil && !isValidating } - /// A verb describing what the button does. "Save" describes storage; the - /// operation is really verify-then-store, and on first run it's a connection. - private var actionTitle: String { savedKey.isEmpty ? "Connect" : "Update" } - - private var rationale: String { - savedKey.isEmpty - ? "Blurt needs an AssemblyAI API key to transcribe your speech. A free-tier key works." - : "Paste a new key to replace the one Blurt is using." - } - var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("AssemblyAI API Key") .font(.headline) - Text(rationale) + Text(display.rationale) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -199,10 +205,12 @@ private struct APIKeyEditorSheet: View { // Cancel is not the only way out — the window can close under the sheet. // Catch every dismissal, not just the button. .onDisappear { submitTask?.cancel() } - .alert("Couldn’t Save Your Key", isPresented: $showSaveFault) { + .alert( + saveFault?.title ?? "", isPresented: isPresentingSaveFault, presenting: saveFault + ) { _ in Button("OK", role: .cancel) {} - } message: { - Text("Blurt couldn’t write the key to your macOS Keychain. Check Keychain access and try again.") + } message: { fault in + Text(fault.message) } } @@ -250,7 +258,7 @@ private struct APIKeyEditorSheet: View { // rather than caption-sized footer text. It doesn't dismiss the sheet, so // it sits at the leading edge, away from Cancel / the default action — // and the sheet stays open behind the browser, ready for the paste. - if savedKey.isEmpty { + if !display.isConnected { Button("Get a Free Key") { openURL(APIKeyStore.dashboardURL) } .accessibilityIdentifier(UITestIdentifiers.apiKeyGetKey) } @@ -261,7 +269,7 @@ private struct APIKeyEditorSheet: View { Button("Cancel", action: cancel) .keyboardShortcut(.cancelAction) .accessibilityIdentifier(UITestIdentifiers.apiKeyCancel) - Button(actionTitle, action: submit) + Button(display.commitButtonTitle, action: submit) .glassButtonStyleCompat(prominent: true) .keyboardShortcut(.defaultAction) .disabled(!canSubmit) @@ -292,19 +300,20 @@ private struct APIKeyEditorSheet: View { // `.saveFailed` alert would have nowhere to appear), so stop here. guard !Task.isCancelled else { return } isValidating = false - switch result { - case .valid: + // Which outcomes are recoverable-inline and which is a genuine fault (and + // what each one says) is the engine's single classification, so this can't + // disagree with it — nor ship a new outcome that reports nothing at all. + switch result.failureReport { + case nil: // Verified and stored — hand the key back to the row and close. The // controller reveals the overlay via its hasAPIKey observer. onSaved(key) dismiss() - case .invalid: - errorMessage = "AssemblyAI rejected that key. Double-check it and try again." - case .unreachable: - errorMessage = "Couldn't reach AssemblyAI. Check your connection and try again." - case .saveFailed: + case .some(.inline(let message)): + errorMessage = message + case .some(.alert(let title, let message)): // A system fault retyping can't fix — surface it as an alert, not inline. - showSaveFault = true + saveFault = SaveFault(title: title, message: message) } } } diff --git a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift index 59f51cf..1312987 100644 --- a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift @@ -45,6 +45,29 @@ struct HotkeyStepView: View { } } +/// Live-updating read of the bound dictation trigger key, for the views that +/// only *display* it ("Tap or hold right ⌘ to blurt"). +/// +/// Wraps the `@AppStorage` + `TriggerKey.fromPersisted` pair that three views — +/// the ready screen, the menu bar menu, and the permissions footer — each spelled +/// out, along with a restated `TriggerKey.rightCommand.rawValue` default that +/// `fromPersisted` already owns (an unset default reads as 0, which it maps to +/// right ⌘). Reading the raw keycode through `@AppStorage`, rather than calling +/// `TriggerKeyStore()` once, is what makes these views re-render when the key is +/// rebound in the separate Settings window. +/// +/// A `DynamicProperty` rather than a `View` because two of the three call sites +/// need the value inside a larger sentence, not a `Text` of its own. (Housed in +/// this file — the trigger-key view file — rather than its own, so the committed +/// XcodeGen project doesn't need regenerating; move it out next time +/// `xcodegen generate` runs anyway.) +@propertyWrapper +struct BoundTriggerKey: DynamicProperty { + @AppStorage(TriggerKeyStore.defaultsKey) private var keyCode = 0 + + var wrappedValue: TriggerKey { TriggerKey.fromPersisted(keyCode) } +} + /// A settings/setup-form row: an icon-and-title label on the leading edge, with /// trailing content pushed to the trailing edge and vertically centered against /// the label. A plain `HStack` (default `.center` alignment) rather than diff --git a/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift b/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift index 715ef3f..f4461a5 100644 --- a/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift @@ -12,13 +12,8 @@ struct PermissionsStepView: View { /// Observed rather than read once via `TriggerKeyStore()`: Settings is reachable /// with ⌘, while this page is showing, so a one-shot read left the footer naming /// the old key after a rebind until something else re-rendered the view. Same - /// pattern as `ReadyView` / `MenuBarScene` / `HotkeyStepView`. - @AppStorage(TriggerKeyStore.defaultsKey) private var triggerKeyCode = TriggerKey.rightCommand - .rawValue - - /// The bound trigger's display label, re-read on every render so a rebind in - /// Settings updates the footer immediately. - private var triggerLabel: String { TriggerKey.fromPersisted(triggerKeyCode).label } + /// `BoundTriggerKey` as `ReadyView` / `MenuBarScene`. + @BoundTriggerKey private var triggerKey /// Set when the user taps a settings button so the section can show a /// "waiting for you to come back" cue until the poll sees the grant. @@ -62,7 +57,7 @@ struct PermissionsStepView: View { granted: controller.permissions.microphone, waiting: "Waiting for you to turn on Blurt under Microphone…", description: - "Blurt records only after you start dictating with \(triggerLabel)." + "Blurt records only after you start dictating with \(triggerKey.label)." ) } } diff --git a/App/Blurt/Blurt/Wizard/WizardController.swift b/App/Blurt/Blurt/Wizard/WizardController.swift index 837213b..160cd0c 100644 --- a/App/Blurt/Blurt/Wizard/WizardController.swift +++ b/App/Blurt/Blurt/Wizard/WizardController.swift @@ -76,11 +76,12 @@ final class WizardController { /// The live readiness value, computed from the current inputs. Private so only /// `syncReadiness` touches the raw `permissions`/`hasAPIKey` — that keeps their /// observable dependency off view bodies (the whole point of the stored - /// `isReady`). The dictation shortcut is *not* part of this gate — it has a - /// default binding and is rebound in Settings, so a cleared shortcut surfaces as - /// a hint on the ready screen rather than trapping the user in the wizard. + /// `isReady`). The gate itself is the engine's `SetupReadiness`, where it's + /// unit-tested (including the deliberate exclusion of the dictation shortcut, + /// which has a default binding and is rebound in Settings — so a shortcut change + /// can't trap the user in the wizard). private var computedReadiness: Bool { - permissions.allGranted && apiKey.hasAPIKey + SetupReadiness.isReady(permissions: permissions, hasAPIKey: apiKey.hasAPIKey) } /// Streams the API-key model's readiness input (`hasAPIKey`) via `Observations`, @@ -113,11 +114,11 @@ final class WizardController { pollTask?.cancel() pollTask = Task { @MainActor [weak self] in while !Task.isCancelled { - // Poll briskly during setup so a freshly-granted permission shows at - // once; once configured, coast — the poll then only needs to catch a - // rare revocation, not wake the main actor every second for the app's - // whole life. (`self?` keeps the controller releasable across the sleep.) - let interval: Duration = (self?.isReady ?? false) ? .seconds(5) : .seconds(1) + // Brisk during setup so a freshly-granted permission shows at once, then + // coasting once configured — the cadence policy (and why it differs) is + // the engine's `SetupReadiness`. (`self?` keeps the controller releasable + // across the sleep.) + let interval = SetupReadiness.pollInterval(isReady: self?.isReady ?? false) try? await Task.sleep(for: interval) guard !Task.isCancelled, let self else { return } self.refreshPermissions() @@ -132,7 +133,8 @@ final class WizardController { let perms = checkPermissions() // A permission that was granted and is now gone (revoked in System Settings, // possibly while no window was open) should pull the user back into onboarding. - let revoked = permissions.allGranted && !perms.allGranted + // The edge test is the engine's, next to the `allGranted` it's built from. + let revoked = perms.lostGrant(since: permissions) if perms != permissions { permissions = perms } diff --git a/BLURTENGINE.md b/BLURTENGINE.md index 4a5923a..1629c93 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -91,7 +91,7 @@ idle → recording → transcribing → injecting → pasted | noTarget - `phaseStream()` yields the current phase immediately, then every transition. It is a **multi-observer** stream: every call gets its own continuation and all of them see later transitions, so fanning it out is supported. That's all a host needs — one renderer — but don't fan it out to multiple long-lived consumers; project the phase into your own state instead. - `.pasted` and `.noTarget` are terminal _success_ states, not errors. `.noTarget` means transcription worked but nothing editable was focused (or the target app quit), so the text was left on the clipboard — show a quiet "copied" notice, not a failure. - Two ready-made projections keep UI mapping out of your shell: `phase.overlayState` (`OverlayUIState`: idle / recording / processing / error(message:) / pasted / noTarget, with accessibility labels and — for the transient notices — `noticeDwellSeconds`, how long to hold one before reverting to idle) and `phase.menuBarStatus` (coarser: idle / recording / transcribing, never shows errors, with `symbolName`/`accessibilityLabel` presentation). -- Pill geometry is available too, if you're drawing something like Blurt's overlay: `OverlayPlacement` resolves where the panel goes (clearance, clamping a dragged origin back on screen), and `MeterBarGeometry` gives the level meter its shape. Build a `MeterBarRow(availableSize:)` once per layout — it resolves how many bars fit and how tall they may be — then ask it for `height(at:level:time:animated:)` per bar; `MeterBarGeometry.breathingOpacity(time:period:minOpacity:)` is the pulse the record dot and status label share. All pure math; pass `animated: false` to honor Reduce Motion. +- Pill geometry is available too, if you're drawing something like Blurt's overlay: `OverlayPlacement` resolves how big the panel is (`panelSize(pillSize:shadowMargin:)`, sized to hold the pill plus room for its shadow) and where it goes (clearance, clamping a dragged origin back on screen), and `MeterBarGeometry` gives the level meter its shape. Build a `MeterBarRow(availableSize:)` once per layout — it resolves how many bars fit and how tall they may be — then ask it for `height(at:level:time:animated:)` per bar; `MeterBarGeometry.breathingOpacity(time:period:minOpacity:)` is the pulse the record dot and status label share. All pure math; pass `animated: false` to honor Reduce Motion. ### Errors @@ -156,9 +156,11 @@ Recognition quality comes from per-utterance priming, assembled automatically in - **`TranscriptionPrompt.build(context:)`** renders that into the Sync request's `config.prompt`, opening with the fixed `baseInstruction` ("Transcribe without speaker labels, audio event descriptions, or emotion markers.") and staying under a self-imposed 4096-character ceiling (`characterCap`; the Sync API documents no cap on `config.prompt`). An empty context yields `nil`, which omits the field so the server applies its own default. Two deliberate omissions, both regression-tested: no language directive (pinning to English hurt non-English speech) and no "remove filler words" clause (not in the model's trained instruction set — a no-op). Don't reintroduce either. - **`KeyTermsStore`** persists the user's domain vocabulary (names, jargon) in `UserDefaults`; `DictationSession` re-reads it at every press via its `keyTermsProvider` closure, so Settings edits apply to the next utterance without rebuilding the session. Pass your own provider to source terms from elsewhere. -For key storage, compose against **`APIKeyGateway`** — the injectable `current` / `save(_:)` / `hasKey` seam over the key store. `ProductionAPIKeyStore` forwards to the Keychain-backed `APIKeyStore`; `InMemoryAPIKeyStore` is a ready-made in-memory conformance for tests and harnesses (Blurt's XCUITest runs use it so the real Keychain item is never touched, and its `hasKey` backs the session's `readinessCheck`). For a settings UI, **`APIKeySubmission`** wraps the gateway with the validate-then-save flow (`submit(_:)` → valid / invalid / unreachable / saveFailed, via `APIKeyValidator`): it saves only a key AssemblyAI actively accepts, so an unverified key never persists. +For key storage, compose against **`APIKeyGateway`** — the injectable `current` / `save(_:)` / `hasKey` seam over the key store. `ProductionAPIKeyStore` forwards to the Keychain-backed `APIKeyStore`; `InMemoryAPIKeyStore` is a ready-made in-memory conformance for tests and harnesses (Blurt's XCUITest runs use it so the real Keychain item is never touched, and its `hasKey` backs the session's `readinessCheck`). For a settings UI, **`APIKeySubmission`** wraps the gateway with the validate-then-save flow (`submit(_:)` → valid / invalid / unreachable / saveFailed, via `APIKeyValidator`): it saves only a key AssemblyAI actively accepts, so an unverified key never persists. Two projections keep the surrounding UI out of your views: `Outcome.failureReport` classifies a failure as `.inline(message:)` (recoverable — show it beside the field) or `.alert(title:message:)` (a Keychain fault retyping can't fix), and **`APIKeyDisplay.resolve(key:)`** renders the stored key for an account row — masked tail, status and VoiceOver wording, and the connect-vs-rotate control titles. The mask reveals only the last `revealedTailLength` characters and, below `minimumLengthToMask`, none at all, so a short key can't be shown whole. -Each completed dictation is appended to **`DictationLog`** (a local JSONL history at `~/Library/Logs/Blurt/dictations.jsonl`, the path `DictationLog.defaultURL` exposes) with its context snapshot — but only while developer mode is switched on. **`DeveloperModeStore`** persists that opt-in in `UserDefaults` (`BlurtDeveloperMode`, off by default); with it off, nothing is written to disk. Blurt surfaces the switch (and the log path) in the Settings window's Developer section. +Setup gating has a projection too: **`SetupReadiness.isReady(permissions:hasAPIKey:)`** is the "fully configured" rule (deliberately excluding the trigger key, which has a default), `SetupReadiness.pollInterval(isReady:)` is the permission-poll cadence (brisk during setup, coasting once ready), and `PermissionStatus.lostGrant(since:)` detects a permission revoked out from under a configured app. + +Each completed dictation is appended to **`DictationLog`** (a local JSONL history at `~/Library/Logs/Blurt/dictations.jsonl` — `DictationLog.defaultURL`, or `defaultDisplayPath` for the home-abbreviated form to show in UI) with its context snapshot — but only while developer mode is switched on. **`DeveloperModeStore`** persists that opt-in in `UserDefaults` (`BlurtDeveloperMode`, off by default); with it off, nothing is written to disk. Blurt surfaces the switch (and the log path) in the Settings window's Developer section. ## Hotkey building blocks diff --git a/Sources/BlurtEngine/Config/APIKeyDisplay.swift b/Sources/BlurtEngine/Config/APIKeyDisplay.swift new file mode 100644 index 0000000..81af4ce --- /dev/null +++ b/Sources/BlurtEngine/Config/APIKeyDisplay.swift @@ -0,0 +1,103 @@ +/// How the stored AssemblyAI key is presented in the setup/settings account +/// row — and the wording of the controls around it. +/// +/// The engine already owns everything else about the key (`APIKeyStore`, +/// `APIKeyGateway`, `APIKeySubmission`, `APIKeyValidator`); this is the display +/// projection of it, owned here for the same reason as `OverlayUIState`: it's a +/// pure mapping from state to wording, and the SwiftUI shell that renders it has +/// no test target. +/// +/// The masking rule is the part that most wants a test. A stored key is a +/// secret, and revealing a fixed-length tail of it only distinguishes two +/// accounts while the key is comfortably longer than that tail — below that, +/// "the last four characters" and "the whole key" are the same string. +public enum APIKeyDisplay: Equatable, Sendable { + /// No key is stored. + case notConnected + /// A key is stored. `maskedTail` is `••••` plus its last few characters, or + /// `nil` when the key is too short to reveal any of it (see + /// `minimumLengthToMask`) — the row then says just "Connected". + case connected(maskedTail: String?) + + /// How many trailing characters a masked key reveals: enough to tell two + /// accounts apart, not enough to be useful to anyone reading the screen. + public static let revealedTailLength = 4 + + /// Shortest stored key that gets a revealed tail at all. Twice the tail, so at + /// least half the key always stays hidden — a real AssemblyAI key is far + /// longer, and a short one is a typo or a test fixture rather than a + /// credential worth identifying by its ending. + public static let minimumLengthToMask = revealedTailLength * 2 + + /// Resolves what to show for the stored key. Trims first (the engine's shared + /// rule), so a whitespace-only value reads as no key at all — the same way + /// `APIKeyGateway` treats it. + public static func resolve(key: String?) -> APIKeyDisplay { + guard let key = key?.trimmedNonEmpty() else { return .notConnected } + guard key.count >= minimumLengthToMask else { return .connected(maskedTail: nil) } + return .connected(maskedTail: "••••\(key.suffix(revealedTailLength))") + } + + /// Whether a key is stored. Drives the first-connect vs. rotate wording below + /// and the row's accessibility identifiers. + public var isConnected: Bool { + switch self { + case .notConnected: false + case .connected: true + } + } + + /// The row's status text: the masked key once one exists (an *identity* — the + /// question a checkmark can't answer), otherwise plain prose. + public var statusText: String { + switch self { + case .notConnected: "Not connected" + case .connected(let maskedTail): maskedTail ?? "Connected" + } + } + + /// Whether `statusText` is an identifier rather than prose, so the shell knows + /// to monospace it (the revealed characters line up as a key ending instead of + /// reading as a sentence). False for the bare "Connected"/"Not connected". + public var rendersIdentifier: Bool { + switch self { + case .connected(let maskedTail): maskedTail != nil + case .notConnected: false + } + } + + /// Spoken by VoiceOver: the bullets are decoration, so spell the state out. + /// Owned here alongside `statusText` so the two can't describe different + /// things — the same split as `OverlayUIState.accessibilityLabel`. + public var accessibilityLabel: String { + switch self { + case .notConnected: "Not connected" + case .connected(let maskedTail): + // Drop the mask bullets and name just the revealed characters, which is + // what a screen-reader user needs to identify the account. + maskedTail.map { "Connected, key ending \($0.suffix(Self.revealedTailLength))" } + ?? "Connected" + } + } + + /// Title of the row's button. Carries an ellipsis because it opens a sheet + /// that needs more input before the action completes — the same rule that + /// gives "Open Accessibility Settings…" its ellipsis. + public var editButtonTitle: String { + isConnected ? "Change…" : "Connect…" + } + + /// Title of the sheet's default action. "Save" would describe storage; the + /// operation is really verify-then-store, and on first run it's a connection. + public var commitButtonTitle: String { + isConnected ? "Update" : "Connect" + } + + /// The sheet's headline explanation — why a key is needed on first run, what + /// pasting one does once a key already exists. + public var rationale: String { + isConnected + ? "Paste a new key to replace the one Blurt is using." + : "Blurt needs an AssemblyAI API key to transcribe your speech. A free-tier key works." + } +} diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index a4df24c..a5c3621 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -16,6 +16,22 @@ public struct APIKeySubmission: Sendable { case saveFailed } + /// How a failed submission should be reported. The *classification* is the + /// case: a problem the user can fix by editing the field belongs inline, while + /// a system fault retyping can't touch belongs in an alert — the convention + /// that makes the inline cases legible as "retype this" rather than "something + /// broke". Owned here, next to the outcomes it classifies, for the same reason + /// as `PipelinePhase.setupBlocker`: the shell renders the engine's single + /// judgement instead of re-deriving it, so adding an `Outcome` case can't + /// silently ship with the wrong severity. + public enum FailureReport: Sendable, Equatable { + /// Recoverable — show `message` beside the field and let the user retry. + case inline(message: String) + /// A genuine fault (the Keychain write itself failed). Retyping the key + /// can't fix it, so present it as an alert. + case alert(title: String, message: String) + } + private let keyStore: any APIKeyGateway private let validate: @Sendable (String) async -> APIKeyValidator.Result @@ -55,3 +71,24 @@ public struct APIKeySubmission: Sendable { } } } + +extension APIKeySubmission.Outcome { + /// How to report this outcome, or `nil` for `.valid` — which has nothing to + /// report: the key verified and stored, so the sheet just closes. + public var failureReport: APIKeySubmission.FailureReport? { + switch self { + case .valid: + nil + case .invalid: + .inline(message: "AssemblyAI rejected that key. Double-check it and try again.") + case .unreachable: + .inline(message: "Couldn't reach AssemblyAI. Check your connection and try again.") + case .saveFailed: + .alert( + title: "Couldn’t Save Your Key", + message: + "Blurt couldn’t write the key to your macOS Keychain. Check Keychain access and try again." + ) + } + } +} diff --git a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift index cdd7d47..ce18ee9 100644 --- a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift +++ b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift @@ -13,6 +13,16 @@ public struct PermissionStatus: Equatable, Sendable { } public var allGranted: Bool { microphone && accessibility } + + /// True when `previous` had every permission and this reading no longer does — + /// i.e. the user revoked one in System Settings, possibly while no window was + /// open. The shell reacts by pulling them back into onboarding rather than + /// leaving a dead overlay, so this is a behavioural edge worth a test; it lives + /// next to `allGranted`, the derivation it's built from, rather than being + /// spelled out at the one call site that watches for it. + public func lostGrant(since previous: PermissionStatus) -> Bool { + previous.allGranted && !allGranted + } } public enum PermissionsChecker { diff --git a/Sources/BlurtEngine/Permissions/SetupReadiness.swift b/Sources/BlurtEngine/Permissions/SetupReadiness.swift new file mode 100644 index 0000000..598d8c8 --- /dev/null +++ b/Sources/BlurtEngine/Permissions/SetupReadiness.swift @@ -0,0 +1,32 @@ +/// The policy behind first-run setup: what counts as "fully configured", and how +/// briskly to poll the permissions that feed that answer. +/// +/// Owned in the engine because none of it needs AppKit and all of it is a rule +/// rather than plumbing — `WizardController`, which applied it inline, has no +/// test target, so the gate and the cadence were covered by nothing. +public enum SetupReadiness { + /// Whether the app is fully configured: every permission granted and an API + /// key saved. + /// + /// The dictation shortcut is deliberately **not** part of this gate — it has a + /// default binding and is rebound in Settings, so a shortcut change can never + /// trap the user back in the wizard. + public static func isReady(permissions: PermissionStatus, hasAPIKey: Bool) -> Bool { + permissions.allGranted && hasAPIKey + } + + /// Poll cadence while the user is still setting up: brisk, so a permission + /// granted over in System Settings shows as "Granted" almost at once — the + /// user is watching this window. + public static let settingUpPollInterval: Duration = .seconds(1) + + /// Poll cadence once configured: coasting. The poll then only has to catch a + /// rare revocation, so there's no reason to wake the main actor every second + /// for the rest of the app's life. + public static let readyPollInterval: Duration = .seconds(5) + + /// How long to wait before the next permission poll, given where setup stands. + public static func pollInterval(isReady: Bool) -> Duration { + isReady ? readyPollInterval : settingUpPollInterval + } +} diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index c2c070e..99c5016 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -34,6 +34,14 @@ public enum DictationLog { /// appended, so reading this never touches the disk. public static let defaultURL = URL.libraryDirectory.appending(path: "Logs/Blurt/dictations.jsonl") + /// `defaultURL` as a home-abbreviated path (`~/Library/Logs/…`) for the label + /// beside the developer-mode switch. Derived here, next to the URL the writer + /// actually uses, so the displayed path can't drift from where the log lands — + /// and so the `NSString` bridge this needs stays out of a SwiftUI view. + public static var defaultDisplayPath: String { + (defaultURL.path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath + } + // .sortedKeys keeps the on-disk JSONL deterministic (stable diff for tests // and post-hoc grep). static func makeEncoder() -> JSONEncoder { diff --git a/Sources/BlurtEngine/Pipeline/OverlayPlacement.swift b/Sources/BlurtEngine/Pipeline/OverlayPlacement.swift index 5fa659b..ac8a300 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayPlacement.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayPlacement.swift @@ -15,6 +15,22 @@ public enum OverlayPlacement { /// changing the shadow margin silently moved the pill and no test noticed. public static let defaultBottomClearance: CGFloat = 80 + /// The panel size that holds a `pillSize` pill with `shadowMargin` of + /// transparent room on all four sides, so the pill's drop shadow renders its + /// full falloff instead of being clipped by the window's contentRect. + /// + /// Here rather than at the AppKit call site for the same reason + /// `panelOrigin` backs the clearance off by the margin: the pill/panel + /// relationship is one fact, and half of it living in the shell meant a change + /// to the margin only got checked on one side. Same split as + /// `RecentDictations.reservedHeight` — the shell owns the measurements, the + /// engine owns the arithmetic over them. + public static func panelSize(pillSize: CGSize, shadowMargin: CGFloat) -> CGSize { + CGSize( + width: pillSize.width + shadowMargin * 2, + height: pillSize.height + shadowMargin * 2) + } + /// Resolves the *panel* origin from what the shell genuinely owns — the screen's /// visible frame, the persisted drag origin, and the panel's transparent shadow /// inset — keeping the clearance policy and the pill/panel conversion in here. diff --git a/Sources/BlurtEngine/Update/UpdateAlertContent.swift b/Sources/BlurtEngine/Update/UpdateAlertContent.swift new file mode 100644 index 0000000..fdc994f --- /dev/null +++ b/Sources/BlurtEngine/Update/UpdateAlertContent.swift @@ -0,0 +1,107 @@ +import Foundation + +/// The user-facing content of the alert an update check ends in: title, body, +/// button titles, and — for an available update — the URL the default button +/// downloads. +/// +/// Owned here rather than in the AppKit shell for the same reason as +/// `OverlayUIState.accessibilityLabel` and `MenuBarStatus.symbolName`: it is a +/// pure projection of an engine result into wording, and the shell that draws it +/// has no test target, so wording assembled at the `NSAlert` call site was +/// covered by nothing. `UpdateCheckModel` keeps only the AppKit plumbing — +/// resolve one of these, build the alert from it, and open `downloadURL` when +/// the default button comes back. +public struct UpdateAlertContent: Equatable, Sendable { + /// How prominently the shell presents the alert. Only "couldn't check" is a + /// caution: a result the user asked for isn't a warning. + public enum Style: Equatable, Sendable { + case informational + case warning + } + + /// Alert title (`NSAlert.messageText`). + public let title: String + /// Explanatory body (`NSAlert.informativeText`). + public let message: String + /// Button titles in presentation order; the first is the default action. + /// Never empty — an alert with no button can't be dismissed. + public let buttons: [String] + /// What the *default* button downloads, or `nil` when it only dismisses. + /// + /// Carrying the URL here is what keeps "which button downloads" from being + /// re-derived at the call site by matching a button title: the shell opens + /// this if and only if the first button was chosen. + public let downloadURL: URL? + public let style: Style + + /// Private so every value comes from one of the named results below — a new + /// alert can't be assembled ad hoc in the shell, which is the whole point of + /// owning the wording here. + private init( + title: String, message: String, buttons: [String], downloadURL: URL? = nil, + style: Style = .informational + ) { + self.title = title + self.message = message + self.buttons = buttons + self.downloadURL = downloadURL + self.style = style + } + + /// "Blurt 0.1.31" — the running-version label, also shown as the title of the + /// Settings window's Updates row. Takes an optional so the unparseable-bundle + /// -version case has one answer too (the bare product name; the check still + /// works, it just can't name a version). Published here so the alerts and the + /// Settings row can't word the same fact two ways. + public static func appVersionLabel(_ version: SemanticVersion?) -> String { + guard let version else { return productName } + return "\(productName) \(version)" + } + + /// The product name as it appears in user-facing copy — distinct from + /// `BlurtIdentity.subsystem`, which is the reverse-DNS identity. + private static let productName = "Blurt" + + /// The reassuring result a user-initiated check always shows, so pressing + /// "Check for Updates" visibly confirms it ran. + public static func upToDate(current: SemanticVersion) -> UpdateAlertContent { + UpdateAlertContent( + title: "You’re up to date", + message: "\(appVersionLabel(current)) is the latest version.", + buttons: ["OK"]) + } + + /// A newer release exists. **Download** (the default) opens the release DMG in + /// the browser; **Later** dismisses. There is no in-place install, so these are + /// the only two choices — see the manual-update policy in `UpdateChecker`. + public static func available( + current: SemanticVersion, latest: SemanticVersion, dmgURL: URL + ) -> UpdateAlertContent { + UpdateAlertContent( + title: "A new version of \(productName) is available", + message: "\(appVersionLabel(latest)) is available—you have \(current). Download it now?", + buttons: ["Download", "Later"], + downloadURL: dmgURL) + } + + /// A recoverable "couldn't check" — offline, GitHub unreachable, a malformed + /// response, or a bundle version that wouldn't parse. Every one of those is the + /// same thing to the user (try again later), which is why `UpdateChecker` + /// throws rather than enumerating them. + public static let checkFailed = UpdateAlertContent( + title: "Couldn’t check for updates", + message: "Check your internet connection and try again.", + buttons: ["OK"], + style: .warning) + + /// The alert for a completed check. The throwing paths map to `checkFailed` + /// at the call site, where the error is caught. + public init(result: UpdateCheckResult, current: SemanticVersion) { + switch result { + case .upToDate: + self = .upToDate(current: current) + case .available(let version, let dmgURL): + self = .available(current: current, latest: version, dmgURL: dmgURL) + } + } +} diff --git a/Tests/BlurtEngineTests/APIKeyDisplayTests.swift b/Tests/BlurtEngineTests/APIKeyDisplayTests.swift new file mode 100644 index 0000000..7a8c1a7 --- /dev/null +++ b/Tests/BlurtEngineTests/APIKeyDisplayTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The account row's projection of the stored key. The masking rule is the point: +/// it lived in a SwiftUI view as a bare `"••••\(savedKey.suffix(4))"`, where a +/// key shorter than the revealed tail rendered the *whole* secret on screen and +/// nothing could catch it. +@Suite("APIKeyDisplay") +struct APIKeyDisplayTests { + /// A realistically-shaped AssemblyAI key (32 hex characters). + private let realKey = "0123456789abcdef0123456789abcdef" + + @Test("no key reads as not connected") + func noKey() { + #expect(APIKeyDisplay.resolve(key: nil) == .notConnected) + #expect(APIKeyDisplay.resolve(key: "") == .notConnected) + } + + @Test("a whitespace-only key is no key at all") + func blankKey() { + // Matches how `APIKeyGateway` treats it (a whitespace write is a delete), so + // the row can't claim "Connected" over a key the store considers absent. + #expect(APIKeyDisplay.resolve(key: " \n\t ") == .notConnected) + } + + @Test("a stored key shows only its last four characters") + func maskedTail() { + let display = APIKeyDisplay.resolve(key: realKey) + #expect(display == .connected(maskedTail: "••••cdef")) + #expect(display.statusText == "••••cdef") + #expect(display.isConnected) + // The four revealed characters read as an identifier, so the shell monospaces + // them rather than setting them as prose. + #expect(display.rendersIdentifier) + } + + @Test("the mask never reveals more than the tail length") + func maskRevealsOnlyTheTail() { + let display = APIKeyDisplay.resolve(key: realKey) + let text = display.statusText + // Everything but the tail must be gone from the rendered string — not merely + // shortened. This is the assertion the old view code would have failed. + let hidden = String(realKey.dropLast(APIKeyDisplay.revealedTailLength)) + #expect(!text.contains(hidden)) + #expect(text.hasSuffix(String(realKey.suffix(APIKeyDisplay.revealedTailLength)))) + } + + @Test("a key too short to mask is never partially revealed") + func shortKeyRevealsNothing() { + // The regression this rule exists for: with a fixed `suffix(4)`, a 4- or + // 3-character key put the entire secret on screen. Below the threshold the + // row says "Connected" and shows none of it. + for short in ["k", "ab", "sk-1", "sk-1234"] { + let display = APIKeyDisplay.resolve(key: short) + #expect(display == .connected(maskedTail: nil)) + #expect(display.statusText == "Connected") + #expect(display.isConnected) + // Prose, not an identifier — nothing to line up. + #expect(!display.rendersIdentifier) + #expect(!display.statusText.contains(short)) + } + } + + @Test("the threshold keeps at least half of any masked key hidden") + func thresholdIsTwiceTheTail() { + #expect(APIKeyDisplay.minimumLengthToMask == APIKeyDisplay.revealedTailLength * 2) + // Exactly at the threshold: masked, and half the key is still hidden. + let atThreshold = String(repeating: "x", count: APIKeyDisplay.minimumLengthToMask) + #expect(APIKeyDisplay.resolve(key: atThreshold).rendersIdentifier) + // One character below: not masked at all. + #expect(!APIKeyDisplay.resolve(key: String(atThreshold.dropLast())).rendersIdentifier) + } + + @Test("the key is trimmed before masking, so the tail is real characters") + func trailingWhitespaceDoesNotBecomeTheTail() { + // A pasted key with a trailing newline must not render "••••def\n" — the + // store trims on write, so the display has to agree. + let display = APIKeyDisplay.resolve(key: " \(realKey)\n") + #expect(display == .connected(maskedTail: "••••cdef")) + } + + @Test("VoiceOver spells the state out instead of speaking the bullets") + func accessibilityLabels() { + #expect(APIKeyDisplay.resolve(key: nil).accessibilityLabel == "Not connected") + #expect( + APIKeyDisplay.resolve(key: realKey).accessibilityLabel == "Connected, key ending cdef") + // No tail to name, so no misleading "key ending" clause. + #expect(APIKeyDisplay.resolve(key: "sk-1").accessibilityLabel == "Connected") + } + + @Test("the accessibility label never speaks the mask characters") + func accessibilityLabelDropsTheMask() { + #expect(!APIKeyDisplay.resolve(key: realKey).accessibilityLabel.contains("•")) + } + + @Test("control titles switch between first-connect and rotate wording") + func buttonTitles() { + let empty = APIKeyDisplay.resolve(key: nil) + let stored = APIKeyDisplay.resolve(key: realKey) + // The row's button opens a sheet, so both titles keep the ellipsis. + #expect(empty.editButtonTitle == "Connect…") + #expect(stored.editButtonTitle == "Change…") + // The sheet's default action commits, so neither does. + #expect(empty.commitButtonTitle == "Connect") + #expect(stored.commitButtonTitle == "Update") + #expect(!empty.commitButtonTitle.hasSuffix("…")) + #expect(!stored.commitButtonTitle.hasSuffix("…")) + } + + @Test("the sheet's rationale explains the first connect, then the rotation") + func rationale() { + #expect(APIKeyDisplay.resolve(key: nil).rationale.contains("needs an AssemblyAI API key")) + #expect(APIKeyDisplay.resolve(key: realKey).rationale.contains("replace")) + } +} diff --git a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift index 09c918d..47f9aa4 100644 --- a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift +++ b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift @@ -80,4 +80,51 @@ struct APIKeySubmissionTests { #expect(keySubmission.save("sk-good")) #expect(store.current == "sk-good") } + + // MARK: - Failure reporting + // + // Which outcomes are recoverable-inline and which is a genuine fault used to be + // decided in the settings sheet's `switch`. Owning the classification here is + // the same move as `PipelinePhase.setupBlocker`: adding an `Outcome` case can't + // ship with the wrong severity, because that judgement now has a test. + + @Test("a stored key has nothing to report") + func validReportsNothing() { + #expect(APIKeySubmission.Outcome.valid.failureReport == nil) + } + + @Test("a rejected key is inline and recoverable") + func invalidIsInline() { + #expect( + APIKeySubmission.Outcome.invalid.failureReport + == .inline(message: "AssemblyAI rejected that key. Double-check it and try again.")) + } + + @Test("an unreachable server is inline and recoverable") + func unreachableIsInline() { + #expect( + APIKeySubmission.Outcome.unreachable.failureReport + == .inline(message: "Couldn't reach AssemblyAI. Check your connection and try again.")) + } + + @Test("a Keychain write fault is an alert, not inline text") + func saveFailedIsAnAlert() { + // Retyping the key can't fix a failed Keychain write, so it must not be + // shown as field text the user is invited to correct. + let report = APIKeySubmission.Outcome.saveFailed.failureReport + guard case .some(.alert(let title, let message)) = report else { + Issue.record("saveFailed must report as an alert, got \(String(describing: report))") + return + } + #expect(title == "Couldn’t Save Your Key") + #expect(message.contains("Keychain")) + } + + @Test("every failing outcome reports something the user can read") + func everyFailureIsReported() { + // A failure with no report would leave the sheet silently doing nothing. + for outcome in [APIKeySubmission.Outcome.invalid, .unreachable, .saveFailed] { + #expect(outcome.failureReport != nil) + } + } } diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index df90a11..163fabb 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -198,3 +198,27 @@ struct DictationLogGateTests { #expect(readLog(onURL).contains("hunter2")) } } + +/// The displayed log location. The Settings window's Developer section shows this +/// beside the switch that enables writing, so it has to name the file the writer +/// actually appends to — the reason the formatting moved out of the view. +@Suite("DictationLog.defaultDisplayPath") +struct DictationLogDisplayPathTests { + @Test("abbreviates the home directory with a tilde") + func abbreviatesHome() { + let shown = DictationLog.defaultDisplayPath + #expect(shown.hasPrefix("~/")) + // No absolute home path leaking into the UI (the label is selectable, and a + // user's account name isn't wanted in a screenshot). + #expect(!shown.contains(NSHomeDirectory())) + } + + @Test("names the same file the writer appends to") + func matchesTheWriteTarget() { + // The whole point of deriving this next to `defaultURL`: the two can't drift. + #expect(DictationLog.defaultDisplayPath.hasSuffix("Library/Logs/Blurt/dictations.jsonl")) + #expect( + (DictationLog.defaultDisplayPath as NSString).expandingTildeInPath + == DictationLog.defaultURL.path(percentEncoded: false)) + } +} diff --git a/Tests/BlurtEngineTests/OverlayPlacementTests.swift b/Tests/BlurtEngineTests/OverlayPlacementTests.swift index 741a9e1..8fa677e 100644 --- a/Tests/BlurtEngineTests/OverlayPlacementTests.swift +++ b/Tests/BlurtEngineTests/OverlayPlacementTests.swift @@ -12,6 +12,41 @@ struct OverlayPlacementTests { private let screen = CGRect(x: 0, y: 0, width: 1000, height: 600) private let panel = CGSize(width: 200, height: 60) + @Test("the panel grows by the shadow margin on every side") + func panelSizeAddsTheMarginTwice() { + // The other half of the pill/panel relationship `panelOrigin` corrects for. + // It lived in the AppKit controller as `pill + shadowMargin * 2`, so a change + // to the margin was only checked on the origin side. + let pill = CGSize(width: 168, height: 28) + let size = OverlayPlacement.panelSize(pillSize: pill, shadowMargin: 28) + #expect(size == CGSize(width: 224, height: 84)) + // Stated as the invariant rather than the arithmetic: the pill is centered in + // the panel with exactly `shadowMargin` of transparent room all round. + #expect((size.width - pill.width) / 2 == 28) + #expect((size.height - pill.height) / 2 == 28) + } + + @Test("a zero margin leaves the panel exactly pill-sized") + func panelSizeWithoutMargin() { + let pill = CGSize(width: 100, height: 20) + #expect(OverlayPlacement.panelSize(pillSize: pill, shadowMargin: 0) == pill) + } + + @Test("the panel's placement and its size agree about the margin") + func panelSizeAndOriginUseTheSameMargin() { + // Together these put the *pill's* bottom edge at the default clearance: the + // panel sits `margin` lower, and its bottom `margin` is transparent. + let margin: CGFloat = 28 + let pill = CGSize(width: 168, height: 28) + let size = OverlayPlacement.panelSize(pillSize: pill, shadowMargin: margin) + let origin = OverlayPlacement.panelOrigin( + panelSize: size, visibleFrame: screen, customOrigin: nil, shadowMargin: margin) + #expect(origin.y + margin == OverlayPlacement.defaultBottomClearance) + // And the pill ends up horizontally centered on screen, not the panel's + // transparent box being centered while the pill drifts. + #expect(origin.x + margin + pill.width / 2 == screen.midX) + } + @Test("panelOrigin measures the clearance to the pill, not the panel") func panelOriginBacksOffTheShadowMargin() { // This arithmetic used to live at the AppKit call site as diff --git a/Tests/BlurtEngineTests/SetupReadinessTests.swift b/Tests/BlurtEngineTests/SetupReadinessTests.swift new file mode 100644 index 0000000..e9745a0 --- /dev/null +++ b/Tests/BlurtEngineTests/SetupReadinessTests.swift @@ -0,0 +1,62 @@ +import Testing + +@testable import BlurtEngine + +/// The first-run setup gate and its poll cadence. Both lived in +/// `WizardController` — an app-shell type with no test target — so the readiness +/// rule and the revocation edge that pulls the user back into onboarding were +/// uncovered. +@Suite("SetupReadiness") +struct SetupReadinessTests { + private let all = PermissionStatus(microphone: true, accessibility: true) + private let noMic = PermissionStatus(microphone: false, accessibility: true) + private let noAX = PermissionStatus(microphone: true, accessibility: false) + private let neither = PermissionStatus(microphone: false, accessibility: false) + + @Test("ready needs every permission and a key") + func readyRequiresEverything() { + #expect(SetupReadiness.isReady(permissions: all, hasAPIKey: true)) + } + + @Test("any missing input keeps setup unfinished") + func anyMissingInputBlocks() { + #expect(!SetupReadiness.isReady(permissions: all, hasAPIKey: false)) + #expect(!SetupReadiness.isReady(permissions: noMic, hasAPIKey: true)) + #expect(!SetupReadiness.isReady(permissions: noAX, hasAPIKey: true)) + #expect(!SetupReadiness.isReady(permissions: neither, hasAPIKey: false)) + } + + @Test("the poll coasts once configured and stays brisk while setting up") + func pollCadence() { + #expect(SetupReadiness.pollInterval(isReady: false) == SetupReadiness.settingUpPollInterval) + #expect(SetupReadiness.pollInterval(isReady: true) == SetupReadiness.readyPollInterval) + // The direction is the point: polling a configured app as often as one being + // set up wakes the main actor every second for the app's whole life. + #expect(SetupReadiness.readyPollInterval > SetupReadiness.settingUpPollInterval) + } + + @Test("losing a granted permission is detected") + func revocationDetected() { + // The edge that pulls a configured app back into onboarding. + #expect(noMic.lostGrant(since: all)) + #expect(noAX.lostGrant(since: all)) + #expect(neither.lostGrant(since: all)) + } + + @Test("a steady state is not a revocation") + func steadyStateIsNotRevocation() { + // Every poll tick passes through this; treating no-change as a revocation + // would surface the setup window on a timer. + #expect(!all.lostGrant(since: all)) + #expect(!noMic.lostGrant(since: noMic)) + } + + @Test("a permission granted, or one still missing, is not a revocation") + func grantIsNotRevocation() { + // Going the other way (setup progressing) must not fire. + #expect(!all.lostGrant(since: noMic)) + // Never fully granted to begin with, so nothing was lost — the user is still + // mid-onboarding and shouldn't be "kicked back" to where they already are. + #expect(!neither.lostGrant(since: noMic)) + } +} diff --git a/Tests/BlurtEngineTests/UpdateAlertContentTests.swift b/Tests/BlurtEngineTests/UpdateAlertContentTests.swift new file mode 100644 index 0000000..9eb76b7 --- /dev/null +++ b/Tests/BlurtEngineTests/UpdateAlertContentTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The wording an update check ends in. This used to be assembled inline at the +/// `NSAlert` call site in the app shell, which has no test target — so the +/// version arithmetic in the copy, the button order, and "which button +/// downloads" were all uncovered. +@Suite("UpdateAlertContent") +struct UpdateAlertContentTests { + private func version(_ string: String) throws -> SemanticVersion { + try #require(SemanticVersion(string)) + } + + private let dmg = URL(staticString: "https://example.com/Blurt.dmg") + + @Test("up to date names the running version and only dismisses") + func upToDate() throws { + let content = UpdateAlertContent.upToDate(current: try version("0.1.31")) + #expect(content.title == "You’re up to date") + #expect(content.message == "Blurt 0.1.31 is the latest version.") + #expect(content.buttons == ["OK"]) + // Nothing to download, so the shell must not open anything. + #expect(content.downloadURL == nil) + #expect(content.style == .informational) + } + + @Test("an available update names both versions and carries the DMG") + func available() throws { + let content = UpdateAlertContent.available( + current: try version("0.1.31"), latest: try version("0.2.0"), dmgURL: dmg) + #expect(content.title == "A new version of Blurt is available") + #expect(content.message == "Blurt 0.2.0 is available—you have 0.1.31. Download it now?") + #expect(content.downloadURL == dmg) + } + + @Test("Download is the default action and Later dismisses") + func availableButtonOrder() throws { + let content = UpdateAlertContent.available( + current: try version("1.0"), latest: try version("1.1"), dmgURL: dmg) + // The shell opens `downloadURL` when the *first* button comes back, so the + // order here is load-bearing: flipping it would make "Later" download. + #expect(content.buttons == ["Download", "Later"]) + #expect(content.buttons.first == "Download") + } + + @Test("couldn't check is a warning with no download") + func checkFailed() { + let content = UpdateAlertContent.checkFailed + #expect(content.title == "Couldn’t check for updates") + #expect(content.message == "Check your internet connection and try again.") + #expect(content.buttons == ["OK"]) + #expect(content.downloadURL == nil) + // The one caution: a result the user asked for isn't a warning, a failure is. + #expect(content.style == .warning) + } + + @Test("every alert offers at least one way out") + func everyAlertIsDismissible() throws { + let all = [ + UpdateAlertContent.upToDate(current: try version("1.0")), + UpdateAlertContent.available(current: try version("1.0"), latest: try version("2.0"), dmgURL: dmg), + .checkFailed, + ] + for content in all { + #expect(!content.buttons.isEmpty) + } + } + + @Test("the result initializer maps both check outcomes") + func resultMapping() throws { + let current = try version("1.0.0") + let latest = try version("1.1.0") + #expect( + UpdateAlertContent(result: .upToDate, current: current) + == UpdateAlertContent.upToDate(current: current)) + #expect( + UpdateAlertContent(result: .available(version: latest, dmgURL: dmg), current: current) + == UpdateAlertContent.available(current: current, latest: latest, dmgURL: dmg)) + } + + @Test("the version label is shared by the alerts and the Settings row") + func versionLabel() throws { + #expect(UpdateAlertContent.appVersionLabel(try version("0.1.31")) == "Blurt 0.1.31") + // A `v`-prefixed GitHub tag is stripped by SemanticVersion, so the label + // never reads "Blurt v0.1.31". + #expect(UpdateAlertContent.appVersionLabel(try version("v0.1.31")) == "Blurt 0.1.31") + } + + @Test("an unparseable bundle version still yields a usable label") + func versionLabelWithoutVersion() { + // The Settings row's title falls back to the bare product name; the check + // itself still runs, it just can't name what's running. + #expect(UpdateAlertContent.appVersionLabel(nil) == "Blurt") + } +}