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
40 changes: 33 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ from inheriting a released Blurt's TCC rows while failing the requirement stored
means both can be installed and run side by side. The debug id is the _default_ and `Release` opts
in to the shipping one, so a configuration added later can't accidentally ship under it.

**The Keychain is the one store the id split doesn't separate for you.** Keychain items are per
_login keychain_, not per app, so both builds resolved the same `blurt` generic-password item however
different their bundle ids were — a dev build read the shipping app's API key, could overwrite it,
and (once Settings grew a Reset) could delete it. `HostIdentity.blurtDev` gives the debug build its
own `blurt-dev` item, and `BlurtApp.init` picks between the two identities by the **running bundle
id** — which follows `PRODUCT_BUNDLE_IDENTIFIER`, so a new configuration inherits the dev identity
rather than reaching for the shipping key because someone forgot a `#if DEBUG`. Only that one field
differs: the log subsystem stays shared (the documented `log show` predicates have to work whichever
build is running) and so does the defaults prefix (defaults are per-app already). The cost is one
extra trip through the wizard the first time you run a dev build after this landed.

`scripts/dev-build.sh` wraps that for everyday local dev — it runs the **signed** `Debug-Local` build
(so the install step actually fires, unlike `check.sh`, which disables codesigning for CI) and pipes
through `xcbeautify` when present. [`CONTRIBUTING.md`](./CONTRIBUTING.md) is the setup guide for
Expand Down Expand Up @@ -283,8 +294,9 @@ identity recorded in `accessibility.lastSigningTeam`; if it changed and the app
`tccutil reset Accessibility` on **the running bundle id** (never `HostIdentity.current.subsystem`, which
would have a dev build clearing the release's grant) so the wizard's normal grant flow captures a
matching requirement, and records the new identity only when the reset succeeded.
`SigningIdentityMigration` is the pure decision; `SigningIdentity` is the thin `Security`/`tccutil`
adapter.
`SigningIdentityMigration` is the pure decision; `SigningIdentity` reads the requirement (the thin
`Security` adapter) and `PermissionsReset` runs the `tccutil` call — the same adapter the Settings
window's reset button sweeps every grant through.

Recording the requirement itself, rather than a proxy for it, is the point: this used to record the
**Team ID**, which is stable across exactly the collision above (release and dev builds share team
Expand Down Expand Up @@ -792,8 +804,10 @@ developer-mode logs go to, the product name update alerts say, and the GitHub re
check reads. These were hard constants, which made them Blurt's with no opt-out — a second app
embedding the engine wrote into _Blurt's_ Keychain item, log directory and defaults keys. They are
one value now; `HostIdentity.current` is what the engine reads, `.blurt` is what an unconfigured host
inherits (so nothing about this app changed), and `BlurtApp.init` calls `HostIdentity.configure(_:)`
with it because the identity belongs to the host, not the engine. It is process-wide rather than
inherits, and `BlurtApp.init` calls `HostIdentity.configure(_:)` because the identity belongs to the
host, not the engine — with `.blurt` or `.blurtDev` depending on the running bundle id, the two
differing only in the Keychain service (see
[Dev builds are a separate app](#regenerating-and-installing-the-app)). It is process-wide rather than
injected for the obvious reason: its readers are `static let` loggers, an enum of defaults keys and a
Keychain facade, none of which a caller constructs. The derivations are pure functions of the value
(`defaultsKey(_:)`, `logURL(_:)`, `queueLabel(_:)`, `logger(_:)`) so the tests exercise them against
Expand Down Expand Up @@ -894,7 +908,9 @@ sites, so a failure path added later is logged by construction; `.noTarget` and
a phase change rather than dropped. A failed append itself goes to `os_log` (never the entry, which
would leak transcripts system-wide) — it's the one error that can't be reported through the error log,
and it must never throw onto the dictation path. The Settings window's Developer section surfaces the
switch and both paths, and `scripts/reset-install.sh` removes both files.
switch and both paths, and both `scripts/reset-install.sh` and the in-app reset
(`DictationLog.removeStoredLogs()`, via `InstallReset`) remove both files — plus the directory, once
it holds nothing else.

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
Expand Down Expand Up @@ -929,8 +945,18 @@ restating them.
time, swapped for a Copy affordance on hover; and a Settings button at the
foot). Standard titlebar, always presented at launch.
- **Settings** (`SettingsWindowRoot`) — a `TabView` reached via ⌘, / the menu bar,
never at launch. Note macOS titles a preference window after its selected pane, which is why the
UI-test suite aliases the window title to the first tab's label.
never at launch. General holds the everyday setup (key, shortcut, cue, key terms); Advanced holds
the enhanced-transcripts switch, the style profiles, the update check, the developer-mode toggle,
and the **reset** — one destructive button running the engine's `InstallReset` (the same sweep as
`scripts/reset-install.sh`: settings, Keychain key, TCC grants, dictation logs), which confirms
first and then **restarts the app** (a detached `sh -c 'sleep 1; open -n <bundle>'`, since whatever
reopens Blurt has to outlive it). The restart is load-bearing, not a courtesy: macOS prompts for a
TCC grant once per process, so only a process started after the sweep gets the prompts back — and
the fresh one, having no key and no grants, opens on the setup wizard by the same
`SetupReadiness` gate as a first run. A _partial_ reset skips the restart and reports what
survived instead. Note macOS titles a preference window
after its selected pane, which is why the UI-test suite aliases the window title to the first
tab's label.
- **`MenuBarExtra`** (`MenuBar/MenuBarScene.swift`) — live dictation indicator plus a
discoverability menu for the otherwise-invisible hotkey. Convenience layered on the Dock icon.
- **Overlay** (`Overlay/OverlayWindowController` + `OverlayView`) — the floating pill: meter, phase
Expand Down
8 changes: 8 additions & 0 deletions App/Blurt/Blurt/APIKeyModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ final class APIKeyModel {
/// run sees the injected in-memory store instead of the real Keychain.
var current: String? { keyStore.current }

/// The storage seam itself, handed to the Settings window's full-install reset
/// (`InstallReset`) so the sweep deletes the key from the *injected* store —
/// the in-memory one under UI testing — rather than reaching past this model
/// to the production Keychain item. Exposed rather than wrapped in a `clear()`
/// here because the reset owns the sweep; what this model owns is
/// `refreshStatus()`, which is how `hasAPIKey` catches up afterwards.
var storage: any APIKeyGateway { keyStore }

/// A `@Sendable` snapshot of the readiness gate for `DictationSession`: a press
/// with no key saved fails fast as `.failed(.apiKeyMissing)` before any capture.
/// Captures the (Sendable) store, not this main-actor model, so it can cross
Expand Down
29 changes: 23 additions & 6 deletions App/Blurt/Blurt/App.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BlurtEngine
import Foundation
import SwiftUI

@main
Expand All @@ -9,13 +10,29 @@ struct BlurtApp: App {
/// logging subsystem, the `UserDefaults` prefix, the log directory, the product
/// name in update alerts, and the release feed the update check reads.
///
/// `.blurt` *is* the engine's default, so this call changes nothing about how
/// Blurt behaves. It is here because it is the composition root: the identity
/// belongs to the host, and a fork or a second embedder replaces exactly this
/// one line rather than hunting constants through the engine. Earliest hook a
/// SwiftUI `App` has, which is what `HostIdentity.configure(_:)` asks for.
/// This is the composition root: the identity belongs to the host, and a fork
/// or a second embedder replaces exactly these lines rather than hunting
/// constants through the engine. Earliest hook a SwiftUI `App` has, which is
/// what `HostIdentity.configure(_:)` asks for.
///
/// **Which** identity depends on which app this is. Debug builds ship as
/// `dev.alex.blurt.dev` / "Blurt Dev" (see `project.yml`), which macOS already
/// treats as a separate app — its own TCC rows, its own defaults domain — but
/// *not* its own Keychain item, since Keychain items are per login keychain
/// rather than per app. Shared, a dev build read the shipping app's API key,
/// could overwrite it, and could delete it outright through Settings → Reset.
/// `.blurtDev` differs from `.blurt` in exactly that one field.
///
/// Decided from the **running bundle id** rather than `#if DEBUG`: the id is
/// what actually makes these two apps to macOS, and it follows
/// `PRODUCT_BUNDLE_IDENTIFIER`, so a configuration added later gets the dev
/// identity by default (the same reasoning that made the debug id the default
/// in `project.yml`) instead of silently reaching for the shipping key because
/// someone forgot a compilation condition. `HostIdentity.blurt.subsystem` is
/// the shipping id — the one place that string is written down.
init() {
HostIdentity.configure(.blurt)
let isShippingBuild = Bundle.main.bundleIdentifier == HostIdentity.blurt.subsystem
HostIdentity.configure(isShippingBuild ? .blurt : .blurtDev)
}

var body: some Scene {
Expand Down
4 changes: 2 additions & 2 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// whose permissions this process has no business touching. The constant is
// the fallback for the unreachable case of a bundle with no id at all.
reset: {
SigningIdentity.resetAccessibilityGrant(
bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem)
PermissionsReset.reset(
.accessibility, bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem)
}
)
if let persist { defaults.set(persist, forKey: key) }
Expand Down
162 changes: 162 additions & 0 deletions App/Blurt/Blurt/Wizard/DeveloperSection.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import AppKit
import BlurtEngine
import OSLog
import SwiftUI

// The Advanced pane's two standalone sections: the developer-mode switch and
// the start-over button. Both are Settings-only (neither gates setup, so
// neither is a wizard step), and they live here rather than in
// `SettingsWindowRoot` because that file is at the repo's file-length limit.

/// The Developer section of the Settings window: an opt-in switch for developer
/// mode. While on, every completed dictation is appended to the local JSONL log
/// and every failed one to a sibling error log (see `DictationLog` — both gates
Expand Down Expand Up @@ -32,3 +39,158 @@ struct DeveloperSection: View {
}
}
}

/// The Reset section of the Settings window: one destructive button that hands
/// the install back to the state a fresh download starts from — no API key, no
/// settings, no dictation logs, and none of the TCC grants — for the user whose
/// permissions have got into a state no toggle in System Settings will fix. The
/// sweep itself is the engine's `InstallReset`, the same set of steps
/// `scripts/reset-install.sh` performs, so someone who can't (or shouldn't have
/// to) run a shell script has the same way out.
///
/// **Blurt restarts itself when it finishes**, which the confirmation says up
/// front. That isn't politeness: the running process is the one holding the TCC
/// grants the sweep just revoked, and macOS re-prompts per process — so only a
/// process started *after* the reset gets the permission prompts back. The new
/// one comes up with no key and no grants, which is exactly the state
/// `SetupReadiness` reads as "not configured", so it opens on the setup wizard
/// (`MainWindowRoot`) rather than the ready screen. Restarting rather than
/// merely quitting is what makes the button finish the job the user asked for
/// instead of leaving them at a closed app.
struct ResetSection: View {
/// What the section is asking or telling, or nil while it's silent.
///
/// **One piece of state and one `.alert` modifier**, because two `.alert`s on
/// the same view is the classic SwiftUI conflict where only one of them ever
/// presents — with the report attached second, the confirmation never opened
/// at all, which is how `SettingsUITests` caught it.
private enum Prompt {
/// Asked before anything happens. A reset is irreversible and machine-wide,
/// so the button opens this rather than acting on the click.
case confirm
/// Only shown when part of the sweep survived. A clean reset says nothing:
/// the app restarting into setup is the confirmation.
case failed(InstallReset.AlertContent)

var title: String {
switch self {
case .confirm: "Reset Blurt?"
case .failed(let content): content.title
}
}

var message: String {
switch self {
case .confirm:
"This can’t be undone. Your AssemblyAI API key, every setting, the dictation logs, and "
+ "Blurt’s microphone, accessibility and input-monitoring permissions are all removed.\n\n"
+ "Blurt then restarts and takes you back through setup."
case .failed(let content): content.message
}
}
}

let coordinator: AppCoordinator

@State private var prompt: Prompt?

var body: some View {
Section {
// Ellipsis for the same reason as "Connect…" and "Add Style…": the button
// opens something rather than completing the action.
SettingRow(title: "Reset Blurt", systemImage: "arrow.counterclockwise") {
Button("Reset…", role: .destructive) { prompt = .confirm }
.accessibilityIdentifier(UITestIdentifiers.installReset)
}
} header: {
Text("Reset")
} footer: {
Text(
"Deletes your AssemblyAI API key, clears every setting, removes the dictation logs, and "
+ "revokes Blurt’s microphone, accessibility and input-monitoring permissions.")
}
// Alert buttons are addressed by the words on them in the UI suite, like the
// update alert's "OK" — an identifier here wouldn't survive AppKit's alert
// bridging. Cancel stays the default action; the destructive one never takes
// Return.
.alert(prompt?.title ?? "", isPresented: isPrompting, presenting: prompt) { prompt in
switch prompt {
case .confirm:
// Deferred a turn: setting `prompt` straight from an alert action
// re-enters presentation while this alert is still dismissing, and
// SwiftUI swallows it — so the failure report would never appear.
Button("Reset and Restart", role: .destructive) { Task { @MainActor in reset() } }
Button("Cancel", role: .cancel) {}
case .failed:
Button("OK", role: .cancel) {}
}
} message: { prompt in
Text(prompt.message)
}
}

/// Presentation binding derived from `prompt`, so there's one piece of state
/// rather than a bool that can disagree with it.
private var isPrompting: Binding<Bool> {
Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })
}

/// Runs the sweep, then restarts — or reports what survived and stays put.
///
/// The bundle id is the **running** one, never `HostIdentity.current.subsystem`:
/// debug builds ship under `dev.alex.blurt.dev`, and the constant would have a
/// dev build clearing the released Blurt's grants (the same rule
/// `AppDelegate.runAccessibilityGrantMigration` follows). The key is cleared
/// through the model's own storage seam, so a UI-test run sweeps its in-memory
/// store instead of the developer's Keychain item.
private func reset() {
let report = InstallReset(
bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem,
keyStore: coordinator.apiKey.storage
).run()
// `hasAPIKey` is a mirror of the store, not a read-through, so it has to be
// re-read for the wizard to see the key go — which matters on the failure
// path, where the app stays running.
coordinator.apiKey.refreshStatus()
// Nothing to report means the install is clean, and the fresh copy opening
// on the setup wizard is the whole confirmation — a success alert would only
// be one more click between the user and the setup they came for.
guard let report else {
restart()
return
}
prompt = .failed(report)
}

/// Replaces this process with a fresh one, so the permission prompts come back
/// (macOS asks once per process, and this one has already been asked).
///
/// A detached `sh` does the launching because *we* can't: whatever reopens
/// Blurt has to outlive Blurt. It sleeps first so `open` runs against a bundle
/// with no live instance, and takes `-n` so that if the old process is somehow
/// still shutting down, LaunchServices starts a new copy rather than
/// re-activating the dying one and leaving the user with nothing. The overlap
/// that `-n` risks is harmless here: a just-reset copy has no key and no
/// grants, so it installs no event tap and shows no pill — it opens the wizard
/// and waits.
///
/// The bundle path is passed as an argument rather than interpolated into the
/// script, so a path with spaces (`/Applications/Blurt Dev.app`) can't split
/// into two words.
///
/// A failed launch is not worth an alert: the sweep itself already succeeded,
/// and the recovery — open Blurt again — is the thing the user was about to do
/// anyway.
private func restart() {
let relauncher = Process()
relauncher.executableURL = URL(fileURLWithPath: "/bin/sh")
relauncher.arguments = ["-c", "sleep 1; /usr/bin/open -n \"$0\"", Bundle.main.bundlePath]
do {
try relauncher.run()
} catch {
HostIdentity.current.logger("reset").error(
"relaunch after reset failed to spawn: \(error.localizedDescription, privacy: .public)")
}
NSApp.terminate(nil)
}
}
7 changes: 5 additions & 2 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ struct SettingsWindowRoot: View {
GeneralSettingsTab(coordinator: coordinator)
.tabItem { Label(UITestIdentifiers.generalSettingsTab, systemImage: "gearshape") }
.tag(Tab.general)
AdvancedSettingsTab(updateModel: appDelegate.updateCheckModel)
AdvancedSettingsTab(coordinator: coordinator, updateModel: appDelegate.updateCheckModel)
.tabItem { Label(UITestIdentifiers.advancedSettingsTab, systemImage: "gearshape.2") }
.tag(Tab.advanced)
}
Expand Down Expand Up @@ -80,9 +80,11 @@ private struct GeneralSettingsTab: View {
}

/// The occasional stuff: the enhanced-transcripts switch, the style profiles,
/// checking for an update, and the developer-mode log toggle.
/// checking for an update, the developer-mode log toggle, and the
/// start-over button.
/// Kept out of General so the common pane stays short.
private struct AdvancedSettingsTab: View {
let coordinator: AppCoordinator
let updateModel: UpdateCheckModel

var body: some View {
Expand All @@ -91,6 +93,7 @@ private struct AdvancedSettingsTab: View {
StyleProfilesSection()
UpdateSection(model: updateModel)
DeveloperSection()
ResetSection(coordinator: coordinator)
}
}
}
Expand Down
Loading