From 45e195bbe4413ad586b50e99b829f5dfc454e1a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:23:40 +0000 Subject: [PATCH 1/8] =?UTF-8?q?Add=20a=20Reset=20button=20to=20Settings=20?= =?UTF-8?q?=E2=86=92=20Advanced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blurt could only be handed back to a preinstall state by running scripts/reset-install.sh from a terminal, which is not a thing to ask of someone whose install is stuck. The same sweep is now a button. Engine: - InstallReset composes the four steps (settings, Keychain key, TCC grants, dictation logs) behind injectable closures, runs all of them without short-circuiting on a failure, and returns either nil (clean — the shell's cue to quit) or the alert naming what survived. The wording lives here for the reason UpdateAlertContent's does. - PermissionsReset is the tccutil adapter, typed over the three services an install holds grants under. SigningIdentity's Accessibility-only copy of it is gone; the launch-time grant migration calls the new one. - DictationLog.removeStoredLogs deletes both log files, and the directory they share once it holds nothing else. App: the Advanced pane's new Reset section confirms first, then sweeps and quits — the running process holds the grants it just revoked, so the prompts only reappear for a process started afterwards. A partial reset reports what it couldn't clear instead of quitting. It lives in DeveloperSection.swift because SettingsWindowRoot.swift is at the file-length limit and a new app-target file needs xcodegen on a Mac. Verified with scripts/check.sh --portable; the Swift half (build, tests, swift-format, swiftlint, periphery, UI tests) runs on CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- AGENTS.md | 19 ++- App/Blurt/Blurt/APIKeyModel.swift | 8 ++ App/Blurt/Blurt/AppDelegate.swift | 4 +- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 104 +++++++++++++++ .../Blurt/Wizard/SettingsWindowRoot.swift | 7 +- App/Blurt/BlurtUITests/SettingsUITests.swift | 32 +++++ App/Blurt/Shared/UITestIdentifiers.swift | 4 + CONTRIBUTING.md | 4 +- README.md | 4 +- Sources/BlurtEngine/Config/InstallReset.swift | 114 +++++++++++++++++ .../Permissions/PermissionsReset.swift | 83 ++++++++++++ .../Permissions/SigningIdentity.swift | 32 +---- .../BlurtEngine/Pipeline/DictationLog.swift | 49 +++++++ Sources/BlurtEngine/README.md | 2 + .../BlurtEngineTests/DictationLogTests.swift | 66 ++++++++++ .../BlurtEngineTests/InstallResetTests.swift | 121 ++++++++++++++++++ .../PermissionsResetTests.swift | 31 +++++ scripts/reset-install.sh | 7 + 18 files changed, 653 insertions(+), 38 deletions(-) create mode 100644 Sources/BlurtEngine/Config/InstallReset.swift create mode 100644 Sources/BlurtEngine/Permissions/PermissionsReset.swift create mode 100644 Tests/BlurtEngineTests/InstallResetTests.swift create mode 100644 Tests/BlurtEngineTests/PermissionsResetTests.swift diff --git a/AGENTS.md b/AGENTS.md index b89f5db3..f787d634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,8 +283,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 @@ -894,7 +895,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 @@ -929,8 +932,14 @@ 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 quits the app afterwards, since the process holding the grants that were just revoked is + the one that has to be replaced for the prompts to reappear. 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 diff --git a/App/Blurt/Blurt/APIKeyModel.swift b/App/Blurt/Blurt/APIKeyModel.swift index 107144c3..a77c7160 100644 --- a/App/Blurt/Blurt/APIKeyModel.swift +++ b/App/Blurt/Blurt/APIKeyModel.swift @@ -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 diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 961346b8..088ebc75 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -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) } diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 7e5eb277..43201f1d 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -1,6 +1,12 @@ +import AppKit import BlurtEngine 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 @@ -32,3 +38,101 @@ 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 quits when it finishes**, which the confirmation says up front: the +/// running process is what holds the TCC grants that were just revoked, so the +/// permission prompts only reappear for a process started after the reset. +/// Quitting rather than relaunching itself keeps that a single, predictable +/// step — two Blurts running at once would mean two event taps racing for the +/// trigger key. +struct ResetSection: View { + let coordinator: AppCoordinator + + /// Drives the confirmation. A reset is irreversible and machine-wide, so the + /// button asks first — and the alert's default action is Cancel (the + /// destructive button never takes Return). + @State private var isConfirming = false + /// The partial-reset alert's content, or nil when there's nothing to report. + /// Set only when a step failed; a clean reset says nothing, because the app + /// quitting is the confirmation. + @State private var failure: InstallReset.AlertContent? + + 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) { isConfirming = true } + .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("Reset Blurt?", isPresented: $isConfirming) { + // Matched by title in the UI suite, like the update alert's "OK": alert + // buttons are addressed by the words on them, and an identifier here + // wouldn't survive AppKit's alert bridging anyway. + Button("Reset and Quit", role: .destructive, action: reset) + Button("Cancel", role: .cancel) {} + } message: { + Text( + "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 quits. Open it again to set up from scratch.") + } + // Only a *partial* reset is reported, and as an alert rather than inline: + // the app is still running with some of its old state, which is a fault the + // user has to know about — retrying the button is the only thing that can + // fix it. + .alert(failure?.title ?? "", isPresented: isReportingFailure, presenting: failure) { _ in + Button("OK") {} + } message: { content in + Text(content.message) + } + } + + /// Presentation binding for the failure alert, derived from the content so + /// there's one piece of state rather than a bool that can disagree with it. + private var isReportingFailure: Binding { + Binding(get: { failure != nil }, set: { if !$0 { failure = nil } }) + } + + /// Runs the sweep, then quits — 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 alert = 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; the app quitting is the + // whole confirmation, so a success alert would only be one more click + // between the user and the relaunch they need. + guard let alert else { + NSApp.terminate(nil) + return + } + failure = alert + } +} diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index a4cffa13..9e403154 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -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) } @@ -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 { @@ -91,6 +93,7 @@ private struct AdvancedSettingsTab: View { StyleProfilesSection() UpdateSection(model: updateModel) DeveloperSection() + ResetSection(coordinator: coordinator) } } } diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index fbd8bba4..62791c38 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -126,6 +126,38 @@ final class SettingsUITests: BlurtUITestCase { alert.buttons["OK"].click() } + /// The Advanced pane's reset button asks first, and Cancel leaves the install + /// exactly as it was — the stored key is still connected afterwards. + /// + /// The *confirming* path is deliberately not exercised: it revokes the app's + /// TCC grants and quits Blurt, so an automated click of it would take the + /// runner's machine (and the rest of the suite) with it. What a confirmed + /// reset does is covered where it lives, over doubles — the engine's + /// `InstallResetTests`. + func testResetAsksBeforeDoingAnything() { + let settings = openSettingsWindow() + connectValidKey(settings) + let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) + + let button = advanced.anyDescendant(identified: UITestIdentifiers.installReset) + XCTAssertTrue(button.waitForExistence(timeout: 10), "Reset button not found") + button.click() + + let confirmation = settings.sheets.firstMatch + XCTAssertTrue( + confirmation.waitForExistence(timeout: 10), + "Reset should ask for confirmation rather than acting on the click") + XCTAssertTrue(confirmation.staticTexts["Reset Blurt?"].exists) + confirmation.buttons["Cancel"].click() + + XCTAssertTrue( + confirmation.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") + let general = selectSettingsTab(settings, named: UITestIdentifiers.generalSettingsTab) + XCTAssertTrue( + general.staticTexts[UITestIdentifiers.apiKeySavedStatus].waitForExistence(timeout: 5), + "Cancelling the reset should leave the stored key alone") + } + /// After a key is stored, "Change…" re-opens the sheet so it can be rotated. func testChangeReopensEditorAfterConnecting() { let settings = openSettingsWindow() diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 50554d44..f16cab73 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -65,6 +65,10 @@ enum UITestIdentifiers { static let developerToggle = "settings.developer.toggle" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" static let updateCheck = "settings.update.check" + /// The Advanced pane's "Reset…" button (`SettingsWindowRoot`'s reset section). + /// Only the row button is identified: the confirmation it opens is an alert, + /// whose buttons the suite addresses by title. + static let installReset = "settings.reset.button" // The Styles section and the sheet it opens (`SettingsWindowRoot`). The Edit // buttons are per-profile, so they are indexed by row rather than named one // by one — the identifier has to be distinct per control, and the profiles diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c724b54c..8914ebe8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,9 @@ once; they stick across rebuilds. They do share the Keychain item holding your AssemblyAI key, so you won't be asked for it twice (macOS may ask once whether the other app may read it). -`scripts/reset-install.sh` wipes both. +`scripts/reset-install.sh` wipes both. Settings → Advanced → **Reset** does the same from +inside the app, for whichever build you're running (it quits afterwards, so the permission +prompts come back on the next launch). ### A note on signing diff --git a/README.md b/README.md index 813b2256..476bf1fa 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ utterance. Roland Juno-106 sounds, or turned off. - **Guided setup** — a first-run wizard walks through Microphone permission, Accessibility trust, and your API key; the same window later hosts settings - for the trigger key, key terms, and sound pack. + for the trigger key, key terms, and sound pack. Settings → Advanced also has a + **Reset** that deletes the key, the settings and the permission grants and + quits, so an install whose permissions have got stuck can start clean. - **No surprises** — installing an update is always yours to do: Blurt looks for a newer release once a day (and whenever you ask) and, if there is one, offers to open the DMG — it never replaces itself. There's no telemetry of any kind. diff --git a/Sources/BlurtEngine/Config/InstallReset.swift b/Sources/BlurtEngine/Config/InstallReset.swift new file mode 100644 index 00000000..2d6bdf17 --- /dev/null +++ b/Sources/BlurtEngine/Config/InstallReset.swift @@ -0,0 +1,114 @@ +import Foundation + +/// The in-app equivalent of `scripts/reset-install.sh`: everything Blurt has put +/// on this machine, removed in one pass — the persisted settings, the Keychain +/// API key, the TCC grants (Accessibility, Microphone, Input Monitoring), and +/// the developer-mode logs — so someone whose install is in a bad state can get +/// back to preinstall without a terminal. +/// +/// The one thing the script does that this can't is the LaunchServices +/// unregister sweep: that clears *other* copies of the app (DerivedData, stale +/// checkouts), which is a developer's problem and not something a running copy +/// can meaningfully do to itself. The script also covers both bundle ids, where +/// a running app can only reset its own. +/// +/// **Not `Sendable`, and it doesn't need to be**: it is built and run in one +/// synchronous step on the main actor, from the Settings window's reset button. +/// The four steps are closures so the whole thing is exercisable without +/// touching the developer's own defaults, Keychain item, TCC rows, or logs — +/// `run()` is otherwise a function whose every effect is irreversible and +/// machine-wide, which is the last shape you want covered only by reading it. +public struct InstallReset { + /// Title and body for the partial-reset alert. A named type rather than a + /// tuple so the wording is assertable as a value, and so the shell has one + /// thing to render. + public struct AlertContent: Equatable, Sendable { + public let title: String + public let message: String + } + + /// What survived the sweep. Each flag is one fallible step; clearing the + /// settings can't fail (removing an absent default is a no-op), so it has no + /// flag here. + /// + /// Internal: what the shell needs is "say this, or quit", which is what + /// `run()` hands back. Keeping the per-step detail in here means the alert can + /// name a step added later without the call site learning about it. + struct Report { + let apiKeyCleared: Bool + let permissionsCleared: Bool + let logsCleared: Bool + + /// What to tell the user when part of the reset didn't land, or `nil` when + /// it all did. Owned here rather than at the `NSAlert` call site for the + /// reason `UpdateAlertContent` is: it's a pure projection of a result into + /// wording, and the shell that draws it has no test target — so a step + /// added later can't ship with an alert that forgets to name it. + var failureAlert: AlertContent? { + let survivors = [ + apiKeyCleared ? nil : "your AssemblyAI API key", + permissionsCleared ? nil : "Blurt’s microphone, accessibility and input-monitoring permissions", + logsCleared ? nil : "the dictation logs", + ].compactMap { $0 } + guard !survivors.isEmpty else { return nil } + return AlertContent( + title: "Blurt wasn’t fully reset", + message: "Couldn’t clear \(survivors.formatted(.list(type: .and))). " + + "Everything else was reset. Quit Blurt and try again.") + } + } + + private let clearSettings: () -> Void + private let clearAPIKey: () -> Bool + private let resetPermissions: () -> Bool + private let clearLogs: () -> Bool + + /// The production composition. `keyStore` is the *host's* storage seam rather + /// than `APIKeyStore` directly, so a UI-test run clears its in-memory store + /// instead of the developer's real Keychain item; `bundleID` must be the + /// **running** one (`Bundle.main.bundleIdentifier`) for the reason spelled out + /// on `PermissionsReset` — a dev build must never clear the released Blurt's + /// grants. + public init(bundleID: String, keyStore: any APIKeyGateway) { + self.init( + clearSettings: { PersistedSettings.resetAll() }, + clearAPIKey: { keyStore.save(nil) }, + resetPermissions: { PermissionsReset.resetAll(bundleID: bundleID) }, + clearLogs: { DictationLog.removeStoredLogs() }) + } + + /// The injectable composition the tests drive. Internal on purpose: the + /// production entry point above is the door, and a public one here would + /// invite a caller to assemble a reset that quietly skips a step. + init( + clearSettings: @escaping () -> Void, + clearAPIKey: @escaping () -> Bool, + resetPermissions: @escaping () -> Bool, + clearLogs: @escaping () -> Bool + ) { + self.clearSettings = clearSettings + self.clearAPIKey = clearAPIKey + self.resetPermissions = resetPermissions + self.clearLogs = clearLogs + } + + /// Runs every step. Returns `nil` when the install is clean — the caller's cue + /// that there is nothing to say — or the alert naming what survived. + /// + /// **No short-circuit.** A failed step never skips the ones after it: a + /// half-reset install is exactly the state this exists to get out of, so + /// stopping at the first failure would leave more behind than reporting it + /// does. The order matches the script's — settings, key, permissions, logs — + /// and nothing depends on it. + public func run() -> AlertContent? { + clearSettings() + let apiKeyCleared = clearAPIKey() + let permissionsCleared = resetPermissions() + let logsCleared = clearLogs() + return Report( + apiKeyCleared: apiKeyCleared, + permissionsCleared: permissionsCleared, + logsCleared: logsCleared + ).failureAlert + } +} diff --git a/Sources/BlurtEngine/Permissions/PermissionsReset.swift b/Sources/BlurtEngine/Permissions/PermissionsReset.swift new file mode 100644 index 00000000..15216a15 --- /dev/null +++ b/Sources/BlurtEngine/Permissions/PermissionsReset.swift @@ -0,0 +1,83 @@ +import Foundation +import os + +/// Clears the TCC grants an install holds, by running `tccutil reset ` +/// against a bundle id. The in-process half of what `scripts/reset-install.sh` +/// does from a terminal, so the Settings window's reset can offer the same thing +/// without one. +/// +/// Resetting a bundle's *own* grants needs no admin rights, which is what makes +/// this usable from the app at all. It is still a system call with no dry run: +/// pass the **running** bundle id (`Bundle.main.bundleIdentifier`), never +/// `HostIdentity.current.subsystem` — debug builds ship under +/// `dev.alex.blurt.dev` (see `project.yml`), so the constant would have a dev +/// build clearing the released Blurt's grants, the one app whose permissions +/// this process has no business touching. +public enum PermissionsReset { + private static let log = HostIdentity.current.logger("PermissionsReset") + + /// The TCC services a Blurt install can hold a grant under — the set + /// `resetAll` sweeps, and the same three `scripts/reset-install.sh` lists + /// (bash can't read this enum, so the two are kept in step by hand). + /// + /// Raw values are `tccutil`'s own service names, which are not all the names + /// System Settings shows. + public enum Service: String, CaseIterable, Sendable { + /// Typing into other apps, and the focused-field reads that feed the paste + /// path (`KeyInjector`, `FocusCapture`). + case accessibility = "Accessibility" + /// Recording (`MicCapture`). + case microphone = "Microphone" + /// Input Monitoring — the `CGEventTap` behind the hold-to-dictate hotkey + /// (`DictationKeyTap`). `tccutil` knows it by its internal name, + /// `ListenEvent`. + case inputMonitoring = "ListenEvent" + } + + /// Clears `service` for `bundleID`. Returns whether `tccutil` exited 0 — a + /// non-zero exit (or a launch failure) is reported rather than thrown, since + /// every caller's answer to a failed reset is the same: say so, and leave the + /// rest of the sweep to run. + @discardableResult + public static func reset(_ service: Service, bundleID: String) -> Bool { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + proc.arguments = ["reset", service.rawValue, bundleID] + do { + try proc.run() + proc.waitUntilExit() + let ok = proc.terminationStatus == 0 + if !ok { + log.warning( + "tccutil reset \(service.rawValue, privacy: .public) exited \(proc.terminationStatus)") + } + return ok + } catch { + let reason = error.localizedDescription + log.error( + "tccutil reset \(service.rawValue, privacy: .public) failed to launch: \(reason, privacy: .public)") + return false + } + } + + /// The services a full reset sweeps. Spelled out case by case rather than + /// taken from `allCases` because Periphery can't follow a case that is only + /// ever reached through `allCases` and reports it as dead; + /// `PermissionsResetTests` pins this list against `allCases`, so a service + /// added to the enum and forgotten here fails there rather than silently + /// surviving every "full" reset. + static let sweep: [Service] = [.accessibility, .microphone, .inputMonitoring] + + /// Clears every service in `sweep` for `bundleID`, returning true only when + /// all of them succeeded. Every service is attempted even after one fails: a + /// half-reset install is the state this exists to get *out* of, so stopping at + /// the first failure would leave more behind than reporting it does. + @discardableResult + static func resetAll(bundleID: String) -> Bool { + var allCleared = true + for service in sweep where !reset(service, bundleID: bundleID) { + allCleared = false + } + return allCleared + } +} diff --git a/Sources/BlurtEngine/Permissions/SigningIdentity.swift b/Sources/BlurtEngine/Permissions/SigningIdentity.swift index f51c121c..d57fe439 100644 --- a/Sources/BlurtEngine/Permissions/SigningIdentity.swift +++ b/Sources/BlurtEngine/Permissions/SigningIdentity.swift @@ -1,14 +1,12 @@ import Foundation import Security -import os -/// Integration adapters for the signing-identity migration: read the identity -/// this process's signature pins into its designated requirement, and clear its -/// Accessibility grant. Kept separate from the pure `SigningIdentityMigration` -/// so the decision logic stays testable and these system calls stay thin. +/// The integration adapter for the signing-identity migration: reads the +/// identity this process's signature pins into its designated requirement. Kept +/// separate from the pure `SigningIdentityMigration` so the decision logic stays +/// testable and this `Security` handshake stays thin. Clearing the stale grant +/// the migration decides on is `PermissionsReset`'s half. public enum SigningIdentity { - private static let log = HostIdentity.current.logger("SigningIdentity") - /// Namespace marker on a recorded identity. Every build before this one recorded /// a bare Team ID (10 alphanumerics, no colon), so the prefix keeps the two /// shapes disjoint — a marker left by an older build can never accidentally @@ -94,24 +92,4 @@ public enum SigningIdentity { else { return nil } return text as String } - - /// Clears Blurt's Accessibility TCC grant so the next authorization recaptures a - /// code requirement matching the current signature. Resetting a bundle's own - /// grant needs no admin rights. Returns whether `tccutil` exited 0. - @discardableResult - public static func resetAccessibilityGrant(bundleID: String) -> Bool { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") - proc.arguments = ["reset", "Accessibility", bundleID] - do { - try proc.run() - proc.waitUntilExit() - let ok = proc.terminationStatus == 0 - if !ok { log.warning("tccutil reset Accessibility exited \(proc.terminationStatus)") } - return ok - } catch { - log.error("tccutil reset Accessibility failed to launch: \(error.localizedDescription)") - return false - } - } } diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index a87eb017..dd05a497 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -106,6 +106,55 @@ public enum DictationLog { (defaultURL.path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath } + /// Deletes both halves of the developer-mode log — the transcript corpus and + /// the sibling error log — and the directory they share once it's empty. The + /// file half of a full reset (`InstallReset`), matching what + /// `scripts/reset-install.sh` removes: a preinstall state has neither file, + /// and the logs are the one place transcripts are written to disk, so leaving + /// them behind would be the most sensitive half of the reset going undone. + /// + /// A file that isn't there is a success, not a failure — that's the state + /// being asked for, and it's the normal case for anyone who never switched + /// developer mode on. False means a file exists and couldn't be removed. + /// + /// The directory removal is deliberately conditional on it being empty: it is + /// `~/Library/Logs/`, and anything else that ended up there is not this + /// function's to delete. + /// + /// Internal, like every other step of the sweep: `InstallReset` is the door + /// the shell uses, so this needs no more reach than the engine itself. + @discardableResult + static func removeStoredLogs() -> Bool { + removeStoredLogs(dictations: defaultURL, errors: defaultErrorURL) + } + + /// The addressable half of `removeStoredLogs()`, taking both URLs for the + /// reason `append` takes one: otherwise the only way to exercise this is + /// against the developer's real `~/Library/Logs`. + @discardableResult + static func removeStoredLogs(dictations: URL, errors: URL) -> Bool { + let manager = FileManager.default + var allRemoved = true + for url in [dictations, errors] where manager.fileExists(atPath: url.path(percentEncoded: false)) { + do { + try manager.removeItem(at: url) + } catch { + allRemoved = false + // The name only, never the absolute path — it carries the user's account + // name, and this is the system-wide log. + let reason = error.localizedDescription + logger.error( + "removing \(url.lastPathComponent, privacy: .public) failed: \(reason, privacy: .public)") + } + } + let directory = dictations.deletingLastPathComponent() + let remaining = try? manager.contentsOfDirectory(atPath: directory.path(percentEncoded: false)) + if remaining?.isEmpty == true { + try? manager.removeItem(at: directory) + } + return allRemoved + } + // .sortedKeys keeps the on-disk JSONL deterministic (stable diff for tests // and post-hoc grep). static func makeEncoder() -> JSONEncoder { diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 09e76a61..cf134608 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -241,6 +241,8 @@ Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `Enhance The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. +**`InstallReset`** is the whole-install sweep behind Blurt's Settings → Advanced → Reset button: `PersistedSettings.resetAll()`, the API key deleted through the host's own `APIKeyGateway` (so a UI-test run clears its in-memory store, not the real Keychain item), `PermissionsReset.resetAll(bundleID:)` over the three TCC services an install holds grants under (`Accessibility`, `Microphone`, and Input Monitoring's internal `ListenEvent`), and `DictationLog.removeStoredLogs()`. Pass the **running** bundle id — `Bundle.main.bundleIdentifier`, never `HostIdentity.current.subsystem`, or a debug build clears the shipping app's grants. No step short-circuits the ones after it: `run()` returns `nil` when the install came out clean (Blurt's shell quits on that — the process holding the grants it just revoked is the one that has to be replaced) or an `AlertContent` naming what survived, wording owned here for the same reason `UpdateAlertContent` is. It is the in-app half of `scripts/reset-install.sh`; the only thing the script does that this can't is unregister _other_ copies of the app from LaunchServices. + Two string helpers carry the "usable text" rule shared by focus capture, the context/prompt, and the stores: `trimmedNonEmpty()` (on both `String` and `String?` — trims surrounding whitespace, treats blank as absent) and `prefix(maxUTF8Bytes:)`, which drops whole `Character`s from the end so a multi-scalar emoji is removed intact rather than sliced into an invalid fragment. The latter is the single truncation rule behind the custom-style budget, shared by the Settings field's counter and `CleanupInstruction.sendable(appending:)` — the two disagreed once, and that shipped. ## Update checking diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index 8f553ac3..ed14d272 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -318,3 +318,69 @@ struct DictationLogDisplayPathTests { == DictationLog.defaultURL.path(percentEncoded: false)) } } + +/// The delete half of the log: `InstallReset` (and `scripts/reset-install.sh`) +/// both exist to return the machine to a preinstall state, and the transcript +/// corpus is the most sensitive thing an install leaves behind — so "removed it" +/// has to mean removed, and "there was nothing to remove" has to read as success +/// rather than as a failed reset. +@Suite("DictationLog.removeStoredLogs") +struct DictationLogRemovalTests { + /// Both files in one directory, as the real pair are — so the empty-directory + /// sweep is exercised the way it runs in production. + private func makeTempPair() -> (dictations: URL, errors: URL) { + let dictations = makeTempLogURL() + return (dictations, dictations.deletingLastPathComponent().appendingPathComponent("errors.jsonl")) + } + + @Test("removes both halves of the log") + func removesBothFiles() throws { + let (dictations, errors) = makeTempPair() + DictationLog.write(transcript: "hello", to: dictations, now: Date()) + DictationLog.writeError(.apiKeyMissing, to: errors, now: Date()) + try #require(FileManager.default.fileExists(atPath: dictations.path)) + try #require(FileManager.default.fileExists(atPath: errors.path)) + + #expect(DictationLog.removeStoredLogs(dictations: dictations, errors: errors)) + + #expect(!FileManager.default.fileExists(atPath: dictations.path)) + #expect(!FileManager.default.fileExists(atPath: errors.path)) + } + + @Test("an absent log is already in the state being asked for") + func absentFilesSucceed() { + let (dictations, errors) = makeTempPair() + // Nothing written: the normal case for anyone who never switched developer + // mode on. Reporting that as a failure would put a "wasn't fully reset" + // alert in front of every such user. + #expect(DictationLog.removeStoredLogs(dictations: dictations, errors: errors)) + } + + @Test("removes the log directory once it is empty") + func removesEmptyDirectory() throws { + let (dictations, errors) = makeTempPair() + DictationLog.write(transcript: "hello", to: dictations, now: Date()) + let directory = dictations.deletingLastPathComponent() + try #require(FileManager.default.fileExists(atPath: directory.path)) + + DictationLog.removeStoredLogs(dictations: dictations, errors: errors) + + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } + + @Test("leaves a directory holding anything else alone") + func keepsNonEmptyDirectory() throws { + let (dictations, errors) = makeTempPair() + DictationLog.write(transcript: "hello", to: dictations, now: Date()) + let directory = dictations.deletingLastPathComponent() + // `~/Library/Logs/Blurt` is the app's directory, not this function's: a file + // Blurt didn't write is not a reset's to delete. + let bystander = directory.appendingPathComponent("crash.log") + try Data("boom".utf8).write(to: bystander) + + DictationLog.removeStoredLogs(dictations: dictations, errors: errors) + + #expect(FileManager.default.fileExists(atPath: bystander.path)) + #expect(!FileManager.default.fileExists(atPath: dictations.path)) + } +} diff --git a/Tests/BlurtEngineTests/InstallResetTests.swift b/Tests/BlurtEngineTests/InstallResetTests.swift new file mode 100644 index 00000000..a0abc8eb --- /dev/null +++ b/Tests/BlurtEngineTests/InstallResetTests.swift @@ -0,0 +1,121 @@ +import Testing + +@testable import BlurtEngine + +/// Records which steps a reset actually ran, and answers each one however the +/// test asks. `InstallReset` is a sweep whose every real effect is irreversible +/// and machine-wide (defaults, the Keychain item, TCC rows, the log files), so +/// the composition is what's covered here — against doubles, never the real +/// ones. +private final class ResetSpy { + private(set) var clearedSettings = false + private(set) var clearedAPIKey = false + private(set) var resetPermissions = false + private(set) var clearedLogs = false + + let apiKeyResult: Bool + let permissionsResult: Bool + let logsResult: Bool + + init(apiKey: Bool = true, permissions: Bool = true, logs: Bool = true) { + self.apiKeyResult = apiKey + self.permissionsResult = permissions + self.logsResult = logs + } + + func makeReset() -> InstallReset { + InstallReset( + clearSettings: { self.clearedSettings = true }, + clearAPIKey: { + self.clearedAPIKey = true + return self.apiKeyResult + }, + resetPermissions: { + self.resetPermissions = true + return self.permissionsResult + }, + clearLogs: { + self.clearedLogs = true + return self.logsResult + }) + } +} + +@Suite("InstallReset") +struct InstallResetTests { + @Test("runs every step, and a clean sweep has nothing to say") + func runsEveryStep() { + let spy = ResetSpy() + + let alert = spy.makeReset().run() + + #expect(spy.clearedSettings) + #expect(spy.clearedAPIKey) + #expect(spy.resetPermissions) + #expect(spy.clearedLogs) + // nil is the shell's cue that the install is clean — it quits on it. + #expect(alert == nil) + } + + /// The property the whole type exists for: a half-reset install is the state + /// this gets the user *out* of, so a failed step must not skip the ones after + /// it. Failing the first two proves the tail still ran. + @Test("a failed step doesn't skip the ones after it") + func doesNotShortCircuit() { + let spy = ResetSpy(apiKey: false, permissions: false) + + let alert = spy.makeReset().run() + + #expect(spy.resetPermissions) + #expect(spy.clearedLogs) + #expect(alert != nil) + } + + @Test("the alert reports the step that actually failed") + func reportsTheFailedStep() throws { + let alert = try #require(ResetSpy(apiKey: true, permissions: false, logs: true).makeReset().run()) + + // Each step's answer lands in its own field of the report behind this — a + // swapped pair would name the wrong thing here. + #expect(alert.message.contains("permissions")) + #expect(!alert.message.contains("API key")) + } +} + +/// What a partial reset *says*. The shell has no test target, so the wording is +/// the engine's — and the thing worth pinning is that every step that failed is +/// named, since the user's only next move is knowing what survived. +@Suite("InstallReset.Report.failureAlert") +struct InstallResetAlertTests { + @Test("a complete reset has nothing to report") + func completeSaysNothing() { + let report = InstallReset.Report( + apiKeyCleared: true, permissionsCleared: true, logsCleared: true) + #expect(report.failureAlert == nil) + } + + @Test("names the step that failed") + func namesTheFailedStep() throws { + let report = InstallReset.Report( + apiKeyCleared: true, permissionsCleared: false, logsCleared: true) + + let alert = try #require(report.failureAlert) + + #expect(alert.message.contains("permissions")) + #expect(!alert.message.contains("API key")) + #expect(!alert.message.contains("dictation logs")) + #expect(!alert.title.isEmpty) + } + + @Test("names every step that failed") + func namesEveryFailedStep() throws { + let report = InstallReset.Report( + apiKeyCleared: false, permissionsCleared: false, logsCleared: false) + + let alert = try #require(report.failureAlert) + + #expect(alert.message.contains("API key")) + #expect(alert.message.contains("permissions")) + #expect(alert.message.contains("dictation logs")) + } +} diff --git a/Tests/BlurtEngineTests/PermissionsResetTests.swift b/Tests/BlurtEngineTests/PermissionsResetTests.swift new file mode 100644 index 00000000..4fd0eee0 --- /dev/null +++ b/Tests/BlurtEngineTests/PermissionsResetTests.swift @@ -0,0 +1,31 @@ +import Testing + +@testable import BlurtEngine + +/// The service names are the whole contract with `tccutil`: it exits 0 for a +/// name it doesn't know as readily as for one it does, so a typo here is a reset +/// that silently clears nothing while reporting success. `reset` itself isn't +/// exercised — running it would clear the developer's own grants — so the names +/// and the roster are what a test can hold. +@Suite("PermissionsReset.Service") +struct PermissionsResetTests { + @Test("names the services tccutil knows") + func serviceNames() { + #expect(PermissionsReset.Service.accessibility.rawValue == "Accessibility") + #expect(PermissionsReset.Service.microphone.rawValue == "Microphone") + // Input Monitoring's internal name — what `tccutil` takes, and not what + // System Settings calls the row. + #expect(PermissionsReset.Service.inputMonitoring.rawValue == "ListenEvent") + } + + /// `resetAll` walks the hand-written `sweep` (Periphery can't follow a case + /// reached only through `allCases`), so this is what keeps that list honest: a + /// service added to the enum and forgotten there would otherwise survive every + /// "full" reset in silence. The three are also the ones + /// `scripts/reset-install.sh` lists — a fourth belongs in both places. + @Test("the sweep covers every service the enum names") + func sweepCoversEveryService() { + #expect(Set(PermissionsReset.sweep) == Set(PermissionsReset.Service.allCases)) + #expect(Set(PermissionsReset.sweep) == [.accessibility, .microphone, .inputMonitoring]) + } +} diff --git a/scripts/reset-install.sh b/scripts/reset-install.sh index 97a8d7e9..48e18f2b 100755 --- a/scripts/reset-install.sh +++ b/scripts/reset-install.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +# The terminal half of a full reset. Blurt ships the same sweep in-app — +# Settings > Advanced > Reset, the engine's `InstallReset` — for the user who +# shouldn't have to run a shell script; keep the two in step. This script stays +# the fuller one: it covers *both* bundle ids (a running copy can only reset its +# own) and unregisters stale app copies from LaunchServices, neither of which an +# app can meaningfully do to itself. + # Both bundle ids Blurt ships under. Lowercase to match how macOS records the # Accessibility TCC client (see the PRODUCT_BUNDLE_IDENTIFIER note in # App/Blurt/project.yml), where the split is also explained: releases are From e22708572749b6c0f91e0cb2211ff3c194510413 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:37:51 +0000 Subject: [PATCH 2/8] Fix the reset confirmation never opening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two .alert modifiers on one view is the SwiftUI conflict where only one of them ever presents: with the partial-reset report attached second, the confirmation never appeared and clicking Reset… did nothing. SettingsUITests caught it on CI. One piece of state and one modifier now — a Prompt enum carrying the confirmation and the report, matching the shape APIKeyStepView's save- fault alert already uses. The report is also raised a turn later, since setting the state straight from an alert action re-enters presentation while the first alert is still dismissing. The test queries the confirmation off the app rather than the settings window: a SwiftUI .alert is not necessarily a sheet of the window it was declared in, so it's identified by the words on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 94 ++++++++++++------- App/Blurt/BlurtUITests/SettingsUITests.swift | 16 +++- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 43201f1d..975474ad 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -54,23 +54,48 @@ struct DeveloperSection: View { /// step — two Blurts running at once would mean two event taps racing for the /// trigger key. 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 quitting 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 quits. Open it again to set up from scratch." + case .failed(let content): content.message + } + } + } + let coordinator: AppCoordinator - /// Drives the confirmation. A reset is irreversible and machine-wide, so the - /// button asks first — and the alert's default action is Cancel (the - /// destructive button never takes Return). - @State private var isConfirming = false - /// The partial-reset alert's content, or nil when there's nothing to report. - /// Set only when a step failed; a clean reset says nothing, because the app - /// quitting is the confirmation. - @State private var failure: InstallReset.AlertContent? + @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) { isConfirming = true } + Button("Reset…", role: .destructive) { prompt = .confirm } .accessibilityIdentifier(UITestIdentifiers.installReset) } } header: { @@ -80,33 +105,30 @@ struct ResetSection: View { "Deletes your AssemblyAI API key, clears every setting, removes the dictation logs, and " + "revokes Blurt’s microphone, accessibility and input-monitoring permissions.") } - .alert("Reset Blurt?", isPresented: $isConfirming) { - // Matched by title in the UI suite, like the update alert's "OK": alert - // buttons are addressed by the words on them, and an identifier here - // wouldn't survive AppKit's alert bridging anyway. - Button("Reset and Quit", role: .destructive, action: reset) - Button("Cancel", role: .cancel) {} - } message: { - Text( - "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 quits. Open it again to set up from scratch.") - } - // Only a *partial* reset is reported, and as an alert rather than inline: - // the app is still running with some of its old state, which is a fault the - // user has to know about — retrying the button is the only thing that can - // fix it. - .alert(failure?.title ?? "", isPresented: isReportingFailure, presenting: failure) { _ in - Button("OK") {} - } message: { content in - Text(content.message) + // 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 Quit", role: .destructive) { Task { @MainActor in reset() } } + Button("Cancel", role: .cancel) {} + case .failed: + Button("OK", role: .cancel) {} + } + } message: { prompt in + Text(prompt.message) } } - /// Presentation binding for the failure alert, derived from the content so - /// there's one piece of state rather than a bool that can disagree with it. - private var isReportingFailure: Binding { - Binding(get: { failure != nil }, set: { if !$0 { failure = nil } }) + /// 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 { + Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } }) } /// Runs the sweep, then quits — or reports what survived and stays put. @@ -118,7 +140,7 @@ struct ResetSection: View { /// 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 alert = InstallReset( + let report = InstallReset( bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem, keyStore: coordinator.apiKey.storage ).run() @@ -129,10 +151,10 @@ struct ResetSection: View { // Nothing to report means the install is clean; the app quitting is the // whole confirmation, so a success alert would only be one more click // between the user and the relaunch they need. - guard let alert else { + guard let report else { NSApp.terminate(nil) return } - failure = alert + prompt = .failed(report) } } diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 62791c38..ab158654 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -143,15 +143,21 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertTrue(button.waitForExistence(timeout: 10), "Reset button not found") button.click() - let confirmation = settings.sheets.firstMatch + // Queried off `app`, not off the settings window: a SwiftUI `.alert` isn't + // necessarily a sheet of the window it was declared in (the API-key + // `.sheet` above is), so the confirmation is identified by the words on it + // wherever AppKit chose to put it. + let confirmationTitle = app.staticTexts["Reset Blurt?"] XCTAssertTrue( - confirmation.waitForExistence(timeout: 10), + confirmationTitle.waitForExistence(timeout: 10), "Reset should ask for confirmation rather than acting on the click") - XCTAssertTrue(confirmation.staticTexts["Reset Blurt?"].exists) - confirmation.buttons["Cancel"].click() + XCTAssertTrue( + app.buttons["Reset and Quit"].exists, + "The confirmation should say that Blurt quits when the reset finishes") + app.buttons["Cancel"].click() XCTAssertTrue( - confirmation.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") + confirmationTitle.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") let general = selectSettingsTab(settings, named: UITestIdentifiers.generalSettingsTab) XCTAssertTrue( general.staticTexts[UITestIdentifiers.apiKeySavedStatus].waitForExistence(timeout: 5), From 14eb7a1ef5ae9bd8a791ff42b7f5e22a6fed0660 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:53:17 +0000 Subject: [PATCH 3/8] Restart Blurt after a reset, and unstick the UI test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful reset quit the app, which left the user at a closed Blurt and only got them to the setup wizard once they reopened it themselves. It now relaunches: a detached `sh -c 'sleep 1; open -n '`, since whatever reopens Blurt has to outlive it. The delay and `-n` are so the launch lands after this process is gone and starts a fresh copy rather than re-activating a dying one. The restart is the point, not a courtesy — macOS prompts for a TCC grant once per process, so only a process started after the sweep gets the prompts back. The new copy has no key and no grants, which is what SetupReadiness reads as "not configured", so it opens on the wizard. A partial reset still skips the restart and reports what survived. The UI test's Cancel click was ambiguous — the API-key sheet it opens earlier leaves its own Cancel in the accessibility tree — so it dismisses with Escape, which is what the .cancel role binds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- AGENTS.md | 8 ++- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 64 +++++++++++++++---- App/Blurt/BlurtUITests/SettingsUITests.swift | 15 +++-- CONTRIBUTING.md | 4 +- README.md | 5 +- Sources/BlurtEngine/README.md | 2 +- 6 files changed, 72 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f787d634..5e100eeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -936,8 +936,12 @@ restating them. 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 quits the app afterwards, since the process holding the grants that were just revoked is - the one that has to be replaced for the prompts to reappear. Note macOS titles a preference window + first and then **restarts the app** (a detached `sh -c 'sleep 1; open -n '`, 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 diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 975474ad..9f26ce54 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -1,5 +1,6 @@ import AppKit import BlurtEngine +import OSLog import SwiftUI // The Advanced pane's two standalone sections: the developer-mode switch and @@ -47,12 +48,15 @@ struct DeveloperSection: View { /// `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 quits when it finishes**, which the confirmation says up front: the -/// running process is what holds the TCC grants that were just revoked, so the -/// permission prompts only reappear for a process started after the reset. -/// Quitting rather than relaunching itself keeps that a single, predictable -/// step — two Blurts running at once would mean two event taps racing for the -/// trigger key. +/// **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. /// @@ -65,7 +69,7 @@ struct ResetSection: View { /// 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 quitting is the confirmation. + /// the app restarting into setup is the confirmation. case failed(InstallReset.AlertContent) var title: String { @@ -80,7 +84,7 @@ struct ResetSection: View { 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 quits. Open it again to set up from scratch." + + "Blurt then restarts and takes you back through setup." case .failed(let content): content.message } } @@ -115,7 +119,7 @@ struct ResetSection: View { // 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 Quit", role: .destructive) { Task { @MainActor in reset() } } + Button("Reset and Restart", role: .destructive) { Task { @MainActor in reset() } } Button("Cancel", role: .cancel) {} case .failed: Button("OK", role: .cancel) {} @@ -131,7 +135,7 @@ struct ResetSection: View { Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } }) } - /// Runs the sweep, then quits — or reports what survived and stays put. + /// 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 @@ -148,13 +152,45 @@ struct ResetSection: View { // 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; the app quitting is the - // whole confirmation, so a success alert would only be one more click - // between the user and the relaunch they need. + // 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 { - NSApp.terminate(nil) + 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) + } } diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index ab158654..c7b87ee0 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -130,7 +130,7 @@ final class SettingsUITests: BlurtUITestCase { /// exactly as it was — the stored key is still connected afterwards. /// /// The *confirming* path is deliberately not exercised: it revokes the app's - /// TCC grants and quits Blurt, so an automated click of it would take the + /// TCC grants and restarts Blurt, so an automated click of it would take the /// runner's machine (and the rest of the suite) with it. What a confirmed /// reset does is covered where it lives, over doubles — the engine's /// `InstallResetTests`. @@ -152,12 +152,17 @@ final class SettingsUITests: BlurtUITestCase { confirmationTitle.waitForExistence(timeout: 10), "Reset should ask for confirmation rather than acting on the click") XCTAssertTrue( - app.buttons["Reset and Quit"].exists, - "The confirmation should say that Blurt quits when the reset finishes") - app.buttons["Cancel"].click() + app.buttons["Reset and Restart"].exists, + "The confirmation should say that Blurt restarts when the reset finishes") + // Dismissed with Escape, not by clicking "Cancel": this test opened the + // API-key sheet earlier and its own Cancel is still in the accessibility + // tree, so an app-level query for that title matches more than one element. + // Escape is what the `.cancel` role binds, so it exercises the same button. + app.typeKey(.escape, modifierFlags: []) XCTAssertTrue( - confirmationTitle.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") + confirmationTitle.waitForNonExistence(timeout: 5), + "Escape should dismiss the confirmation without resetting anything") let general = selectSettingsTab(settings, named: UITestIdentifiers.generalSettingsTab) XCTAssertTrue( general.staticTexts[UITestIdentifiers.apiKeySavedStatus].waitForExistence(timeout: 5), diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8914ebe8..df3c3aa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,8 +70,8 @@ They do share the Keychain item holding your AssemblyAI key, so you won't be asked for it twice (macOS may ask once whether the other app may read it). `scripts/reset-install.sh` wipes both. Settings → Advanced → **Reset** does the same from -inside the app, for whichever build you're running (it quits afterwards, so the permission -prompts come back on the next launch). +inside the app, for whichever build you're running — and restarts it, so the +permission prompts come back. ### A note on signing diff --git a/README.md b/README.md index 476bf1fa..97428882 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,9 @@ utterance. - **Guided setup** — a first-run wizard walks through Microphone permission, Accessibility trust, and your API key; the same window later hosts settings for the trigger key, key terms, and sound pack. Settings → Advanced also has a - **Reset** that deletes the key, the settings and the permission grants and - quits, so an install whose permissions have got stuck can start clean. + **Reset** that deletes the key, the settings and the permission grants, then + restarts Blurt into first-run setup — so an install whose permissions have got + stuck can start clean. - **No surprises** — installing an update is always yours to do: Blurt looks for a newer release once a day (and whenever you ask) and, if there is one, offers to open the DMG — it never replaces itself. There's no telemetry of any kind. diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index cf134608..98e42912 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -241,7 +241,7 @@ Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `Enhance The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. -**`InstallReset`** is the whole-install sweep behind Blurt's Settings → Advanced → Reset button: `PersistedSettings.resetAll()`, the API key deleted through the host's own `APIKeyGateway` (so a UI-test run clears its in-memory store, not the real Keychain item), `PermissionsReset.resetAll(bundleID:)` over the three TCC services an install holds grants under (`Accessibility`, `Microphone`, and Input Monitoring's internal `ListenEvent`), and `DictationLog.removeStoredLogs()`. Pass the **running** bundle id — `Bundle.main.bundleIdentifier`, never `HostIdentity.current.subsystem`, or a debug build clears the shipping app's grants. No step short-circuits the ones after it: `run()` returns `nil` when the install came out clean (Blurt's shell quits on that — the process holding the grants it just revoked is the one that has to be replaced) or an `AlertContent` naming what survived, wording owned here for the same reason `UpdateAlertContent` is. It is the in-app half of `scripts/reset-install.sh`; the only thing the script does that this can't is unregister _other_ copies of the app from LaunchServices. +**`InstallReset`** is the whole-install sweep behind Blurt's Settings → Advanced → Reset button: `PersistedSettings.resetAll()`, the API key deleted through the host's own `APIKeyGateway` (so a UI-test run clears its in-memory store, not the real Keychain item), `PermissionsReset.resetAll(bundleID:)` over the three TCC services an install holds grants under (`Accessibility`, `Microphone`, and Input Monitoring's internal `ListenEvent`), and `DictationLog.removeStoredLogs()`. Pass the **running** bundle id — `Bundle.main.bundleIdentifier`, never `HostIdentity.current.subsystem`, or a debug build clears the shipping app's grants. No step short-circuits the ones after it: `run()` returns `nil` when the install came out clean (Blurt's shell restarts the app on that — TCC prompts once per process, so only a process started after the sweep is asked again) or an `AlertContent` naming what survived, wording owned here for the same reason `UpdateAlertContent` is. It is the in-app half of `scripts/reset-install.sh`; the only thing the script does that this can't is unregister _other_ copies of the app from LaunchServices. Two string helpers carry the "usable text" rule shared by focus capture, the context/prompt, and the stores: `trimmedNonEmpty()` (on both `String` and `String?` — trims surrounding whitespace, treats blank as absent) and `prefix(maxUTF8Bytes:)`, which drops whole `Character`s from the end so a multi-scalar emoji is removed intact rather than sliced into an invalid fragment. The latter is the single truncation rule behind the custom-style budget, shared by the Settings field's counter and `CleanupInstruction.sendable(appending:)` — the two disagreed once, and that shipped. From eeb2a1670e48d0cdbca720ea2f03f2a66246a479 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:04:44 +0000 Subject: [PATCH 4/8] Reopen Settings after the reset confirmation is dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unmodified Escape falls through to the window behind the alert, so the settings window can close with the confirmation — leaving the follow-up assertion looking for tabs on a window that is gone ("Settings tab 'General' not found"). The assertion is about the stored key surviving a cancelled reset, not about which window survived the keystroke, so it reopens Settings first; openSettingsWindow no-ops when one is still up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- App/Blurt/BlurtUITests/SettingsUITests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index c7b87ee0..44a8f27f 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -163,10 +163,15 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertTrue( confirmationTitle.waitForNonExistence(timeout: 5), "Escape should dismiss the confirmation without resetting anything") - let general = selectSettingsTab(settings, named: UITestIdentifiers.generalSettingsTab) + + // Reopened rather than reused: an unmodified Escape falls through to the + // window behind the alert, so the settings window may have closed with it. + // What this assertion is about is the stored key, not which window survived + // the keystroke — and `openSettingsWindow` no-ops when one is still up. + let general = selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.generalSettingsTab) XCTAssertTrue( general.staticTexts[UITestIdentifiers.apiKeySavedStatus].waitForExistence(timeout: 5), - "Cancelling the reset should leave the stored key alone") + "Dismissing the confirmation should leave the stored key alone") } /// After a key is stored, "Change…" re-opens the sheet so it can be rotated. From 004d0dc6b12d7cd8711fa0fdad3e8ee10f22763d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:16:15 +0000 Subject: [PATCH 5/8] Keep the reset UI test on one pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test kept failing one step past the last fix, each time on window mechanics rather than on the feature: the API-key sheet it opened first leaves its own "Cancel" in the accessibility tree (so the alert's Cancel was ambiguous), Escape dismissed the alert but closed the settings window with it, and ⌘, didn't reliably bring it back. It now stays on the Advanced pane and uses the developer-mode switch — one of the settings the sweep clears — as the "nothing was reset" signal. No sheet, so Cancel is unambiguous and clicking it leaves the window open; no tab switching, so nothing depends on the window surviving a keystroke. What it asserts is unchanged: the button asks before acting, and dismissing leaves the install alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- App/Blurt/BlurtUITests/SettingsUITests.swift | 56 +++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 44a8f27f..cf316d61 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -126,8 +126,16 @@ final class SettingsUITests: BlurtUITestCase { alert.buttons["OK"].click() } - /// The Advanced pane's reset button asks first, and Cancel leaves the install - /// exactly as it was — the stored key is still connected afterwards. + /// The Advanced pane's reset button asks first, and dismissing the + /// confirmation leaves the install exactly as it was. + /// + /// Everything happens on the Advanced pane, against the developer-mode switch + /// — one of the settings `PersistedSettings.resetAll` clears — so "nothing was + /// reset" is observable without switching tabs or opening the API-key sheet. + /// That isn't tidiness: the sheet leaves its own "Cancel" in the accessibility + /// tree, which made an app-level query for the alert's Cancel ambiguous, and + /// dismissing with Escape instead closed the settings window along with the + /// alert and took the follow-up assertions with it. /// /// The *confirming* path is deliberately not exercised: it revokes the app's /// TCC grants and restarts Blurt, so an automated click of it would take the @@ -136,17 +144,23 @@ final class SettingsUITests: BlurtUITestCase { /// `InstallResetTests`. func testResetAsksBeforeDoingAnything() { let settings = openSettingsWindow() - connectValidKey(settings) let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) - let button = advanced.anyDescendant(identified: UITestIdentifiers.installReset) - XCTAssertTrue(button.waitForExistence(timeout: 10), "Reset button not found") - button.click() + // Switch on a setting the sweep would clear, so a reset that ran anyway is + // visible in the same pane. (The UI-test launch resets persisted settings, + // so this starts off.) + let developerMode = advanced.anyDescendant(identified: UITestIdentifiers.developerToggle) + XCTAssertTrue(developerMode.waitForExistence(timeout: 10), "Developer mode toggle not found") + developerMode.click() + XCTAssertEqual("\(developerMode.value ?? "")", "1", "Clicking should switch developer mode on") + + let reset = advanced.anyDescendant(identified: UITestIdentifiers.installReset) + XCTAssertTrue(reset.waitForExistence(timeout: 10), "Reset button not found") + reset.click() // Queried off `app`, not off the settings window: a SwiftUI `.alert` isn't - // necessarily a sheet of the window it was declared in (the API-key - // `.sheet` above is), so the confirmation is identified by the words on it - // wherever AppKit chose to put it. + // necessarily a sheet of the window it was declared in, so the confirmation + // is identified by the words on it wherever AppKit chose to put it. let confirmationTitle = app.staticTexts["Reset Blurt?"] XCTAssertTrue( confirmationTitle.waitForExistence(timeout: 10), @@ -154,24 +168,16 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertTrue( app.buttons["Reset and Restart"].exists, "The confirmation should say that Blurt restarts when the reset finishes") - // Dismissed with Escape, not by clicking "Cancel": this test opened the - // API-key sheet earlier and its own Cancel is still in the accessibility - // tree, so an app-level query for that title matches more than one element. - // Escape is what the `.cancel` role binds, so it exercises the same button. - app.typeKey(.escape, modifierFlags: []) + + // `.firstMatch` so the query resolves to an element rather than erroring out + // if anything else on screen ever carries the same title. + app.buttons["Cancel"].firstMatch.click() XCTAssertTrue( - confirmationTitle.waitForNonExistence(timeout: 5), - "Escape should dismiss the confirmation without resetting anything") - - // Reopened rather than reused: an unmodified Escape falls through to the - // window behind the alert, so the settings window may have closed with it. - // What this assertion is about is the stored key, not which window survived - // the keystroke — and `openSettingsWindow` no-ops when one is still up. - let general = selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.generalSettingsTab) - XCTAssertTrue( - general.staticTexts[UITestIdentifiers.apiKeySavedStatus].waitForExistence(timeout: 5), - "Dismissing the confirmation should leave the stored key alone") + confirmationTitle.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") + XCTAssertEqual( + "\(developerMode.value ?? "")", "1", + "Cancelling the confirmation should leave the settings alone") } /// After a key is stored, "Change…" re-opens the sheet so it can be rotated. From 84b2ff44fca95c530d5afd1e2143bc17e05299d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:27:50 +0000 Subject: [PATCH 6/8] Scope the reset confirmation's buttons away from the Touch Bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "cannot be called with Touch Bar elements, not valid for Cancel Button": the runner mirrors an alert's buttons into a simulated Touch Bar, so an app-level query for "Cancel" matches twice and resolves to the copy XCUITest refuses to click. That — not the API-key sheet — is what made the earlier query ambiguous too. The button queries are scoped to the alert element now, accepting either a sheet or a dialog since AppKit decides which a SwiftUI alert becomes. If it turns out to be neither, the test dumps the element tree instead of just failing, so the next red run says what the alert actually is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- App/Blurt/BlurtUITests/SettingsUITests.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index cf316d61..61e68409 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -165,13 +165,25 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertTrue( confirmationTitle.waitForExistence(timeout: 10), "Reset should ask for confirmation rather than acting on the click") + + // Every button query below is scoped to the alert rather than to `app`: + // the runner mirrors an alert's buttons into a simulated Touch Bar, so an + // app-level "Cancel" matches twice and resolves to the Touch Bar copy, + // which XCUITest refuses to click ("cannot be called with Touch Bar + // elements"). AppKit decides whether a SwiftUI alert is a sheet on its + // window or a free-standing dialog, so accept either. + guard let confirmation = [app.sheets.firstMatch, app.dialogs.firstMatch].first(where: { $0.exists }) + else { + // Dump the tree rather than just failing: which element the alert is, is + // the one thing this test can't find out from a red CI run. + XCTFail("The confirmation is neither a sheet nor a dialog:\n\(app.debugDescription)") + return + } XCTAssertTrue( - app.buttons["Reset and Restart"].exists, + confirmation.buttons["Reset and Restart"].exists, "The confirmation should say that Blurt restarts when the reset finishes") - // `.firstMatch` so the query resolves to an element rather than erroring out - // if anything else on screen ever carries the same title. - app.buttons["Cancel"].firstMatch.click() + confirmation.buttons["Cancel"].click() XCTAssertTrue( confirmationTitle.waitForNonExistence(timeout: 5), "Cancel should dismiss the confirmation") From e77ae7175e1befbe39be9ff705cb5f8255437743 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:37:51 +0000 Subject: [PATCH 7/8] Give the dev build its own Keychain item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keychain items are per login keychain, not per app, so the bundle-id split that separates the two builds' TCC rows and defaults did nothing here: both resolved the same `blurt` generic password. A dev build read the shipping app's AssemblyAI key, could overwrite it, and — now that Settings has a Reset — could delete the key someone actually dictates with. HostIdentity.blurtDev is `.blurt` with `keychainService` of "blurt-dev" and nothing else changed: the log subsystem stays shared so the documented `log show` predicates work whichever build is running, and the defaults prefix stays put because defaults are already per-app. BlurtApp.init picks between the two by the running bundle id rather than `#if DEBUG` — the id is what makes these two apps to macOS, and it follows PRODUCT_BUNDLE_IDENTIFIER, so a configuration added later inherits the dev identity instead of silently reaching for the shipping key. reset-install.sh sweeps both services (plus the pre-rename one), and HostIdentityTests pins both strings so a rename fails swift test until the script is updated with it. A dev build will ask for the key once after this lands; it can no longer see the shipping app's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- AGENTS.md | 17 +++++++- App/Blurt/Blurt/App.swift | 29 +++++++++++--- CONTRIBUTING.md | 7 +++- Sources/BlurtEngine/HostIdentity.swift | 39 ++++++++++++++++++- Sources/BlurtEngine/README.md | 2 +- .../BlurtEngineTests/HostIdentityTests.swift | 24 ++++++++++++ scripts/reset-install.sh | 30 +++++++------- 7 files changed, 123 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e100eeb..659a2b5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -793,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 diff --git a/App/Blurt/Blurt/App.swift b/App/Blurt/Blurt/App.swift index 314737f8..85efbf14 100644 --- a/App/Blurt/Blurt/App.swift +++ b/App/Blurt/Blurt/App.swift @@ -1,4 +1,5 @@ import BlurtEngine +import Foundation import SwiftUI @main @@ -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 { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df3c3aa3..12d6ab2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,8 +66,11 @@ it — you can run either, and each has its own Privacy & Security rows, its own settings, and its own Dock icon. Grant "Blurt Dev" microphone and Accessibility once; they stick across rebuilds. -They do share the Keychain item holding your AssemblyAI key, so you won't be -asked for it twice (macOS may ask once whether the other app may read it). +Each keeps its own AssemblyAI key in the Keychain — `blurt` for the release, +`blurt-dev` for the dev build — so a dev build can't read, overwrite or (through +Settings → Advanced → Reset) delete the key you dictate with. You'll enter the +key once in each, and neither one triggers the "another app wants to use your +confidential information" prompt the shared item used to. `scripts/reset-install.sh` wipes both. Settings → Advanced → **Reset** does the same from inside the app, for whichever build you're running — and restarts it, so the diff --git a/Sources/BlurtEngine/HostIdentity.swift b/Sources/BlurtEngine/HostIdentity.swift index a60ef47d..448f8dcf 100644 --- a/Sources/BlurtEngine/HostIdentity.swift +++ b/Sources/BlurtEngine/HostIdentity.swift @@ -45,9 +45,17 @@ public struct HostIdentity: Sendable, Equatable { /// The Keychain service for the API key item. Blurt uses the plain app name, /// so the entry appears as "blurt" in Keychain Access instead of a - /// developer-domain string; it must match `KEYCHAIN_SERVICE` in + /// developer-domain string; it must match one of the `KEYCHAIN_SERVICES` in /// `scripts/reset-install.sh`. /// + /// **This is the one store the debug/release split doesn't separate for + /// free.** Keychain items are per *login keychain*, not per app, so both + /// builds resolved the same `blurt` item however different their bundle ids + /// were: a dev build read the shipping app's key, could overwrite it, and — once + /// Settings grew a Reset — could delete it. `.blurtDev` gives the debug build + /// its own item; `BlurtApp.init` picks between the two by the running bundle + /// id. + /// /// This is the one gap a host could already work around before the identity /// existed — compose against `APIKeyGateway` with your own conformance instead /// of `ProductionAPIKeyStore` and the service never comes up — but overriding @@ -104,6 +112,35 @@ public struct HostIdentity: Sendable, Equatable { releaseURL: URL( staticString: "https://api.github.com/repos/AssemblyAI/blurt/releases/latest")) + /// The identity a **debug build** of Blurt runs under — "Blurt Dev", which + /// macOS already treats as a separate app (its own bundle id, TCC rows and + /// defaults domain). Only `keychainService` differs from `.blurt`, because the + /// Keychain is the only store that isn't separated by the bundle id already + /// (see that property). + /// + /// The log subsystem is deliberately *not* changed: one subsystem across both + /// builds is what keeps the documented `log show` predicates valid whichever + /// one is running (see `subsystem`). Nor is `defaultsPrefix` — defaults are + /// per-app already, and changing the prefix would abandon every dev install's + /// settings for nothing. + /// + /// `scripts/reset-install.sh` hard-codes this service alongside the shipping + /// one (bash can't read this constant); `HostIdentityTests` pins both. + public static let blurtDev = blurt.withKeychainService("blurt-dev") + + /// This identity with a different Keychain service. Private: the two values + /// Blurt ships are the two above, and a host that wants a third builds it with + /// the initializer rather than deriving one from Blurt's. + private func withKeychainService(_ service: String) -> HostIdentity { + HostIdentity( + productName: productName, + subsystem: subsystem, + keychainService: service, + defaultsPrefix: defaultsPrefix, + logDirectoryName: logDirectoryName, + releaseURL: releaseURL) + } + /// The identity every engine component reads. `.blurt` until a host calls /// `configure(_:)`. public static var current: HostIdentity { storage.withLock { $0 } } diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 98e42912..132eac14 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -310,7 +310,7 @@ HostIdentity.configure( | `productName` | `UpdateAlertContent`'s wording, including `appVersionLabel(_:)` | | `releaseURL` | `UpdateChecker`'s default release feed (still overridable per instance) | -Doing nothing inherits `HostIdentity.blurt`, which is exactly the set of constants these used to be — so this changed no behaviour for the app in this repo, whose `BlurtApp.init` configures `.blurt` explicitly because the identity is the host's to state. `HostIdentity.current.logger(_:)` is public too, so your own components can log under the same subsystem. +Doing nothing inherits `HostIdentity.blurt`, which is exactly the set of constants these used to be. The app in this repo configures explicitly in `BlurtApp.init` because the identity is the host's to state — and because it has two: a debug build runs as a separate app (`dev.alex.blurt.dev`) and takes `HostIdentity.blurtDev`, which differs from `.blurt` in the Keychain service alone. That is the field worth thinking about if you ship more than one build of your own app: `UserDefaults` is already per-app and `~/Library/Logs` is per directory name, but Keychain items are per _login keychain_, so two builds naming the same service share one item — including the ability to overwrite and delete it. `HostIdentity.current.logger(_:)` is public too, so your own components can log under the same subsystem. Three things to know. The value is **process-wide**, not injected: its readers are lazily-initialized `static let` loggers, an enum of defaults keys and a Keychain facade, none of which a caller constructs — which is also why the "before you construct any engine type" ordering is a real requirement rather than politeness (a reader that already resolved keeps the identity it resolved with). `defaultsPrefix` is an **on-disk contract** with your shipped users, exactly as a `DefaultsKey` raw value is: changing it later abandons their settings. And `GitHubRelease.dmgAsset` takes the first asset whose name ends in `.dmg`, so it needs nothing from you — but `Update/` is still Blurt's self-update feature living in a dictation engine, which is its own gap below. diff --git a/Tests/BlurtEngineTests/HostIdentityTests.swift b/Tests/BlurtEngineTests/HostIdentityTests.swift index b045cfaf..0b74d5a1 100644 --- a/Tests/BlurtEngineTests/HostIdentityTests.swift +++ b/Tests/BlurtEngineTests/HostIdentityTests.swift @@ -35,6 +35,30 @@ struct HostIdentityTests { #expect(HostIdentity.blurt.keychainService == "blurt") } + @Test("the debug build's Keychain service matches the value scripts hard-code") + func devKeychainServicePinned() { + // Same contract as the shipping service above: `scripts/reset-install.sh` + // lists this string in `KEYCHAIN_SERVICES`, and bash can't read the + // constant. Renaming it here means renaming it there in the same change. + #expect(HostIdentity.blurtDev.keychainService == "blurt-dev") + } + + /// The whole point of the debug identity, stated as an invariant: a dev build + /// must not reach the shipping app's Keychain item, and must not gain a second + /// log subsystem, defaults namespace or log directory in the process — one + /// subsystem across both builds is what keeps the documented `log show` + /// predicates valid, and a different defaults prefix would abandon every dev + /// install's settings for nothing. + @Test("the debug identity differs from the shipping one only in the Keychain service") + func devDiffersOnlyInTheKeychainService() { + #expect(HostIdentity.blurtDev.keychainService != HostIdentity.blurt.keychainService) + #expect(HostIdentity.blurtDev.subsystem == HostIdentity.blurt.subsystem) + #expect(HostIdentity.blurtDev.defaultsPrefix == HostIdentity.blurt.defaultsPrefix) + #expect(HostIdentity.blurtDev.logDirectoryName == HostIdentity.blurt.logDirectoryName) + #expect(HostIdentity.blurtDev.productName == HostIdentity.blurt.productName) + #expect(HostIdentity.blurtDev.releaseURL == HostIdentity.blurt.releaseURL) + } + @Test("an unconfigured engine is Blurt") func defaultsToBlurt() { // The whole compatibility claim of making these host-supplied: a host that diff --git a/scripts/reset-install.sh b/scripts/reset-install.sh index 48e18f2b..4df53558 100755 --- a/scripts/reset-install.sh +++ b/scripts/reset-install.sh @@ -68,20 +68,24 @@ for bundle_id in "${BUNDLE_IDS[@]}"; do defaults delete "$bundle_id" 2>/dev/null || true done -# AssemblyAI API key lives in the login keychain as a generic password. The -# keychain service is `HostIdentity.blurt.keychainService` (used by APIKeyStore, -# Sources/BlurtEngine/Config/APIKeyStore.swift). Must match that constant. -# Installs that predate the service rename may still hold the key under the -# old service (the lowercase bundle id), so a full reset deletes both. -KEYCHAIN_SERVICE="blurt" -# The shipping id, always — the pre-rename service predates the debug/release id -# split, so there was only ever one value to have used. -LEGACY_KEYCHAIN_SERVICE="${BUNDLE_IDS[0]}" +# AssemblyAI API key lives in the login keychain as a generic password. Keychain +# items are per login keychain rather than per app, so the two builds are NOT +# separated by their bundle ids the way their defaults and TCC rows are: each one +# names its own service. These must match `HostIdentity.blurt.keychainService` +# and `HostIdentity.blurtDev.keychainService` (Sources/BlurtEngine/HostIdentity.swift, +# used by APIKeyStore); `HostIdentityTests` pins both, so a rename there fails +# `swift test` until this list is updated with it. +KEYCHAIN_SERVICES=("blurt" "blurt-dev") +# Installs that predate the service rename may still hold the key under the old +# service (the lowercase bundle id) — the shipping id, always, since that rename +# predates the debug/release id split, so there was only ever one value to have +# used. A full reset deletes it too. +KEYCHAIN_SERVICES+=("${BUNDLE_IDS[0]}") KEYCHAIN_ACCOUNT="AssemblyAIAPIKey" -echo "==> Deleting AssemblyAI API key from Keychain ($KEYCHAIN_SERVICE / $KEYCHAIN_ACCOUNT)" -security delete-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" >/dev/null 2>&1 || true -echo "==> Deleting pre-rename AssemblyAI API key from Keychain ($LEGACY_KEYCHAIN_SERVICE / $KEYCHAIN_ACCOUNT)" -security delete-generic-password -s "$LEGACY_KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" >/dev/null 2>&1 || true +for keychain_service in "${KEYCHAIN_SERVICES[@]}"; do + echo "==> Deleting AssemblyAI API key from Keychain ($keychain_service / $KEYCHAIN_ACCOUNT)" + security delete-generic-password -s "$keychain_service" -a "$KEYCHAIN_ACCOUNT" >/dev/null 2>&1 || true +done # Developer mode appends transcript and failure logs here (see DictationLog); a # fresh install has neither, so clear them too. The rmdir below only succeeds From 0920ff97a757edc3f189553e37ac14080b348f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:51:58 +0000 Subject: [PATCH 8/8] Drop SigningIdentity's now-unused Foundation import Process and URL(fileURLWithPath:) were the only Foundation symbols in the file, and they left with the tccutil call that moved to PermissionsReset. swiftlint analyze caught it: "All imported modules should be required to make the file compile". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW --- Sources/BlurtEngine/Permissions/SigningIdentity.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/BlurtEngine/Permissions/SigningIdentity.swift b/Sources/BlurtEngine/Permissions/SigningIdentity.swift index d57fe439..c12e0bf2 100644 --- a/Sources/BlurtEngine/Permissions/SigningIdentity.swift +++ b/Sources/BlurtEngine/Permissions/SigningIdentity.swift @@ -1,4 +1,3 @@ -import Foundation import Security /// The integration adapter for the signing-identity migration: reads the