Skip to content

Add an in-app install reset, and give the dev build its own Keychain item - #160

Merged
alexkroman merged 8 commits into
mainfrom
claude/determined-allen-rntz8j
Aug 24, 2026
Merged

Add an in-app install reset, and give the dev build its own Keychain item#160
alexkroman merged 8 commits into
mainfrom
claude/determined-allen-rntz8j

Conversation

@alexkroman

@alexkroman alexkroman commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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() returns nil when the install came out clean, or the alert naming what survived.
  • PermissionsReset — the tccutil reset adapter, 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 detached sh -c 'sleep 1; open -n <bundle>', since whatever reopens Blurt has to outlive it).

The restart is load-bearing, not a courtesy: macOS prompts for a TCC grant once per process, so only a process started after the sweep gets the prompts back. The fresh copy has no key and no grants, which is what SetupReadiness reads 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 blurt generic password. A dev build read the shipping app's API key, could overwrite it, and (with the Reset above) could delete it.

HostIdentity.blurtDev is .blurt with keychainService: "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 identities 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 reaching for the shipping key.

Nothing changes for the shipping app: it resolves to .blurt exactly as before, and no key is renamed or migrated. A dev build asks for the key once after this lands.

reset-install.sh sweeps both services (plus the pre-rename one), and HostIdentityTests pins both strings, so renaming either fails swift test until the script is updated in the same change.

How it was tested

  • scripts/check.sh --portable passes locally; the Swift half (build, tests, swift-format, swiftlint, periphery, UI tests, leaks) is CI's, on macos-26
  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed
  • Docs updated: AGENTS.md, README, CONTRIBUTING, the engine README, and reset-install.sh's header

Test coverage:

  • InstallResetTests — every step runs, no short-circuit on failure, and a clean sweep reports nothing
  • InstallResetAlertTests — the partial-reset wording names every step that failed
  • PermissionsResetTests — the tccutil service names, and that the sweep covers every service the enum names
  • DictationLogRemovalTests — both files deleted, absent files succeed, the empty directory goes, a directory holding anything else stays
  • HostIdentityTests — both Keychain services pinned against the script, and the dev identity differs from the shipping one in that field alone
  • SettingsUITests.testResetAsksBeforeDoingAnything — the button asks before acting, and dismissing leaves the settings alone

The 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

claude added 7 commits August 24, 2026 20:23
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
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
@alexkroman
alexkroman added this pull request to the merge queue Aug 24, 2026
@alexkroman alexkroman changed the title Add in-app install reset to Settings window Add an in-app install reset, and give the dev build its own Keychain item Aug 24, 2026
Merged via the queue into main with commit a5ead50 Aug 24, 2026
10 checks passed
@alexkroman
alexkroman deleted the claude/determined-allen-rntz8j branch August 24, 2026 22:16
This was referenced Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants