diff --git a/AGENTS.md b/AGENTS.md index b89f5db3..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 @@ -283,8 +294,9 @@ identity recorded in `accessibility.lastSigningTeam`; if it changed and the app `tccutil reset Accessibility` on **the running bundle id** (never `HostIdentity.current.subsystem`, which would have a dev build clearing the release's grant) so the wizard's normal grant flow captures a matching requirement, and records the new identity only when the reset succeeded. -`SigningIdentityMigration` is the pure decision; `SigningIdentity` is the thin `Security`/`tccutil` -adapter. +`SigningIdentityMigration` is the pure decision; `SigningIdentity` reads the requirement (the thin +`Security` adapter) and `PermissionsReset` runs the `tccutil` call — the same adapter the Settings +window's reset button sweeps every grant through. Recording the requirement itself, rather than a proxy for it, is the point: this used to record the **Team ID**, which is stable across exactly the collision above (release and dev builds share team @@ -792,8 +804,10 @@ developer-mode logs go to, the product name update alerts say, and the GitHub re check reads. These were hard constants, which made them Blurt's with no opt-out — a second app embedding the engine wrote into _Blurt's_ Keychain item, log directory and defaults keys. They are one value now; `HostIdentity.current` is what the engine reads, `.blurt` is what an unconfigured host -inherits (so nothing about this app changed), and `BlurtApp.init` calls `HostIdentity.configure(_:)` -with it because the identity belongs to the host, not the engine. It is process-wide rather than +inherits, and `BlurtApp.init` calls `HostIdentity.configure(_:)` because the identity belongs to the +host, not the engine — with `.blurt` or `.blurtDev` depending on the running bundle id, the two +differing only in the Keychain service (see +[Dev builds are a separate app](#regenerating-and-installing-the-app)). It is process-wide rather than injected for the obvious reason: its readers are `static let` loggers, an enum of defaults keys and a Keychain facade, none of which a caller constructs. The derivations are pure functions of the value (`defaultsKey(_:)`, `logURL(_:)`, `queueLabel(_:)`, `logger(_:)`) so the tests exercise them against @@ -894,7 +908,9 @@ sites, so a failure path added later is logged by construction; `.noTarget` and a phase change rather than dropped. A failed append itself goes to `os_log` (never the entry, which would leak transcripts system-wide) — it's the one error that can't be reported through the error log, and it must never throw onto the dictation path. The Settings window's Developer section surfaces the -switch and both paths, and `scripts/reset-install.sh` removes both files. +switch and both paths, and both `scripts/reset-install.sh` and the in-app reset +(`DictationLog.removeStoredLogs()`, via `InstallReset`) remove both files — plus the directory, once +it holds nothing else. API key: stored in the macOS Keychain via **`APIKeyStore`**, a thin static facade over **`MemoizedKeyStore`** (which takes its storage as `read`/`write` closures, so the memo-and-write rules @@ -929,8 +945,18 @@ restating them. time, swapped for a Copy affordance on hover; and a Settings button at the foot). Standard titlebar, always presented at launch. - **Settings** (`SettingsWindowRoot`) — a `TabView` reached via ⌘, / the menu bar, - never at launch. Note macOS titles a preference window after its selected pane, which is why the - UI-test suite aliases the window title to the first tab's label. + never at launch. General holds the everyday setup (key, shortcut, cue, key terms); Advanced holds + the enhanced-transcripts switch, the style profiles, the update check, the developer-mode toggle, + and the **reset** — one destructive button running the engine's `InstallReset` (the same sweep as + `scripts/reset-install.sh`: settings, Keychain key, TCC grants, dictation logs), which confirms + first and then **restarts the app** (a detached `sh -c 'sleep 1; open -n '`, since whatever + reopens Blurt has to outlive it). The restart is load-bearing, not a courtesy: macOS prompts for a + TCC grant once per process, so only a process started after the sweep gets the prompts back — and + the fresh one, having no key and no grants, opens on the setup wizard by the same + `SetupReadiness` gate as a first run. A _partial_ reset skips the restart and reports what + survived instead. Note macOS titles a preference window + after its selected pane, which is why the UI-test suite aliases the window title to the first + tab's label. - **`MenuBarExtra`** (`MenuBar/MenuBarScene.swift`) — live dictation indicator plus a discoverability menu for the otherwise-invisible hotkey. Convenience layered on the Dock icon. - **Overlay** (`Overlay/OverlayWindowController` + `OverlayView`) — the floating pill: meter, phase 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/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/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..9f26ce54 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -1,6 +1,13 @@ +import AppKit import BlurtEngine +import OSLog import SwiftUI +// The Advanced pane's two standalone sections: the developer-mode switch and +// the start-over button. Both are Settings-only (neither gates setup, so +// neither is a wizard step), and they live here rather than in +// `SettingsWindowRoot` because that file is at the repo's file-length limit. + /// The Developer section of the Settings window: an opt-in switch for developer /// mode. While on, every completed dictation is appended to the local JSONL log /// and every failed one to a sibling error log (see `DictationLog` — both gates @@ -32,3 +39,158 @@ struct DeveloperSection: View { } } } + +/// The Reset section of the Settings window: one destructive button that hands +/// the install back to the state a fresh download starts from — no API key, no +/// settings, no dictation logs, and none of the TCC grants — for the user whose +/// permissions have got into a state no toggle in System Settings will fix. The +/// sweep itself is the engine's `InstallReset`, the same set of steps +/// `scripts/reset-install.sh` performs, so someone who can't (or shouldn't have +/// to) run a shell script has the same way out. +/// +/// **Blurt restarts itself when it finishes**, which the confirmation says up +/// front. That isn't politeness: the running process is the one holding the TCC +/// grants the sweep just revoked, and macOS re-prompts per process — so only a +/// process started *after* the reset gets the permission prompts back. The new +/// one comes up with no key and no grants, which is exactly the state +/// `SetupReadiness` reads as "not configured", so it opens on the setup wizard +/// (`MainWindowRoot`) rather than the ready screen. Restarting rather than +/// merely quitting is what makes the button finish the job the user asked for +/// instead of leaving them at a closed app. +struct ResetSection: View { + /// What the section is asking or telling, or nil while it's silent. + /// + /// **One piece of state and one `.alert` modifier**, because two `.alert`s on + /// the same view is the classic SwiftUI conflict where only one of them ever + /// presents — with the report attached second, the confirmation never opened + /// at all, which is how `SettingsUITests` caught it. + private enum Prompt { + /// Asked before anything happens. A reset is irreversible and machine-wide, + /// so the button opens this rather than acting on the click. + case confirm + /// Only shown when part of the sweep survived. A clean reset says nothing: + /// the app restarting into setup is the confirmation. + case failed(InstallReset.AlertContent) + + var title: String { + switch self { + case .confirm: "Reset Blurt?" + case .failed(let content): content.title + } + } + + var message: String { + switch self { + case .confirm: + "This can’t be undone. Your AssemblyAI API key, every setting, the dictation logs, and " + + "Blurt’s microphone, accessibility and input-monitoring permissions are all removed.\n\n" + + "Blurt then restarts and takes you back through setup." + case .failed(let content): content.message + } + } + } + + let coordinator: AppCoordinator + + @State private var prompt: Prompt? + + var body: some View { + Section { + // Ellipsis for the same reason as "Connect…" and "Add Style…": the button + // opens something rather than completing the action. + SettingRow(title: "Reset Blurt", systemImage: "arrow.counterclockwise") { + Button("Reset…", role: .destructive) { prompt = .confirm } + .accessibilityIdentifier(UITestIdentifiers.installReset) + } + } header: { + Text("Reset") + } footer: { + Text( + "Deletes your AssemblyAI API key, clears every setting, removes the dictation logs, and " + + "revokes Blurt’s microphone, accessibility and input-monitoring permissions.") + } + // Alert buttons are addressed by the words on them in the UI suite, like the + // update alert's "OK" — an identifier here wouldn't survive AppKit's alert + // bridging. Cancel stays the default action; the destructive one never takes + // Return. + .alert(prompt?.title ?? "", isPresented: isPrompting, presenting: prompt) { prompt in + switch prompt { + case .confirm: + // Deferred a turn: setting `prompt` straight from an alert action + // re-enters presentation while this alert is still dismissing, and + // SwiftUI swallows it — so the failure report would never appear. + Button("Reset and Restart", role: .destructive) { Task { @MainActor in reset() } } + Button("Cancel", role: .cancel) {} + case .failed: + Button("OK", role: .cancel) {} + } + } message: { prompt in + Text(prompt.message) + } + } + + /// Presentation binding derived from `prompt`, so there's one piece of state + /// rather than a bool that can disagree with it. + private var isPrompting: Binding { + Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } }) + } + + /// Runs the sweep, then restarts — or reports what survived and stays put. + /// + /// The bundle id is the **running** one, never `HostIdentity.current.subsystem`: + /// debug builds ship under `dev.alex.blurt.dev`, and the constant would have a + /// dev build clearing the released Blurt's grants (the same rule + /// `AppDelegate.runAccessibilityGrantMigration` follows). The key is cleared + /// through the model's own storage seam, so a UI-test run sweeps its in-memory + /// store instead of the developer's Keychain item. + private func reset() { + let report = InstallReset( + bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem, + keyStore: coordinator.apiKey.storage + ).run() + // `hasAPIKey` is a mirror of the store, not a read-through, so it has to be + // re-read for the wizard to see the key go — which matters on the failure + // path, where the app stays running. + coordinator.apiKey.refreshStatus() + // Nothing to report means the install is clean, and the fresh copy opening + // on the setup wizard is the whole confirmation — a success alert would only + // be one more click between the user and the setup they came for. + guard let report else { + restart() + return + } + prompt = .failed(report) + } + + /// Replaces this process with a fresh one, so the permission prompts come back + /// (macOS asks once per process, and this one has already been asked). + /// + /// A detached `sh` does the launching because *we* can't: whatever reopens + /// Blurt has to outlive Blurt. It sleeps first so `open` runs against a bundle + /// with no live instance, and takes `-n` so that if the old process is somehow + /// still shutting down, LaunchServices starts a new copy rather than + /// re-activating the dying one and leaving the user with nothing. The overlap + /// that `-n` risks is harmless here: a just-reset copy has no key and no + /// grants, so it installs no event tap and shows no pill — it opens the wizard + /// and waits. + /// + /// The bundle path is passed as an argument rather than interpolated into the + /// script, so a path with spaces (`/Applications/Blurt Dev.app`) can't split + /// into two words. + /// + /// A failed launch is not worth an alert: the sweep itself already succeeded, + /// and the recovery — open Blurt again — is the thing the user was about to do + /// anyway. + private func restart() { + let relauncher = Process() + relauncher.executableURL = URL(fileURLWithPath: "/bin/sh") + relauncher.arguments = ["-c", "sleep 1; /usr/bin/open -n \"$0\"", Bundle.main.bundlePath] + do { + try relauncher.run() + } catch { + HostIdentity.current.logger("reset").error( + "relaunch after reset failed to spawn: \(error.localizedDescription, privacy: .public)") + } + NSApp.terminate(nil) + } +} 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..61e68409 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -126,6 +126,72 @@ final class SettingsUITests: BlurtUITestCase { alert.buttons["OK"].click() } + /// 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 + /// 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() + let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) + + // 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, 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), + "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( + confirmation.buttons["Reset and Restart"].exists, + "The confirmation should say that Blurt restarts when the reset finishes") + + confirmation.buttons["Cancel"].click() + + XCTAssertTrue( + 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. 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..12d6ab2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,10 +66,15 @@ 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). - -`scripts/reset-install.sh` wipes both. +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 +permission prompts come back. ### A note on signing diff --git a/README.md b/README.md index 813b2256..97428882 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,10 @@ 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, 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/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/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/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..c12e0bf2 100644 --- a/Sources/BlurtEngine/Permissions/SigningIdentity.swift +++ b/Sources/BlurtEngine/Permissions/SigningIdentity.swift @@ -1,14 +1,11 @@ -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 +91,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..132eac14 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 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. ## Update checking @@ -308,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/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/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/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..4df53558 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 @@ -61,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