Add an in-app install reset, and give the dev build its own Keychain item - #160
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
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 <bundle>'`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
"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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
alexkroman
enabled auto-merge
August 24, 2026 21:39
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW
This was referenced Aug 24, 2026
Closed
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Two changes, both about install state.
1. A "Reset Blurt" button in Settings → Advanced
A full install reset without a terminal: the AssemblyAI API key, every persisted setting, the developer-mode logs, and the app's TCC grants (Microphone, Accessibility, Input Monitoring) — back to a preinstall state. Same sweep as
scripts/reset-install.sh, for users who shouldn't have to run shell commands.InstallReset— engine type composing four fallible steps (settings, API key, permissions, logs). Every step runs even after one fails, since a half-reset install is exactly the state this exists to escape;run()returnsnilwhen the install came out clean, or the alert naming what survived.PermissionsReset— thetccutil resetadapter, typed over the three services.SigningIdentity's Accessibility-only copy is gone; the launch-time grant migration calls this one, so there's a single adapter.DictationLog.removeStoredLogs()— deletes both log files and their shared directory once it's empty. An absent file is success, not failure — that's the normal case for anyone who never switched developer mode on.ResetSection— confirms first, then sweeps and restarts the app (a detachedsh -c 'sleep 1; open -n <bundle>', since whatever reopens Blurt has to outlive it).The restart is load-bearing, not a courtesy: macOS prompts for a TCC grant once per process, so only a process started after the sweep gets the prompts back. The fresh copy has no key and no grants, which is what
SetupReadinessreads as "not configured", so it opens on the setup wizard. A partial reset skips the restart and reports which steps survived.2. The dev build gets its own Keychain item
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
blurtgeneric password. A dev build read the shipping app's API key, could overwrite it, and (with the Reset above) could delete it.HostIdentity.blurtDevis.blurtwithkeychainService: "blurt-dev"and nothing else changed: the log subsystem stays shared so the documentedlog showpredicates work whichever build is running, and the defaults prefix stays put because defaults are already per-app.BlurtApp.initpicks between the two identities by the running bundle id rather than#if DEBUG— the id is what makes these two apps to macOS, and it followsPRODUCT_BUNDLE_IDENTIFIER, so a configuration added later inherits the dev identity instead of reaching for the shipping key.Nothing changes for the shipping app: it resolves to
.blurtexactly as before, and no key is renamed or migrated. A dev build asks for the key once after this lands.reset-install.shsweeps both services (plus the pre-rename one), andHostIdentityTestspins both strings, so renaming either failsswift testuntil the script is updated in the same change.How it was tested
scripts/check.sh --portablepasses locally; the Swift half (build, tests, swift-format, swiftlint, periphery, UI tests, leaks) is CI's, on macos-26reset-install.sh's headerTest coverage:
InstallResetTests— every step runs, no short-circuit on failure, and a clean sweep reports nothingInstallResetAlertTests— the partial-reset wording names every step that failedPermissionsResetTests— thetccutilservice names, and that the sweep covers every service the enum namesDictationLogRemovalTests— both files deleted, absent files succeed, the empty directory goes, a directory holding anything else staysHostIdentityTests— both Keychain services pinned against the script, and the dev identity differs from the shipping one in that field aloneSettingsUITests.testResetAsksBeforeDoingAnything— the button asks before acting, and dismissing leaves the settings aloneThe confirming path is deliberately not exercised in the UI suite: it revokes the runner's TCC grants and restarts the app. That behaviour is covered over doubles in
InstallResetTests.https://claude.ai/code/session_01PaKGZ9a2NFMxo7Jmpwf3jW