feat: complete uninstall and purge - #51
Merged
Merged
Conversation
This was referenced Aug 21, 2026
Behnam-RK
force-pushed
the
feat/complete-purge
branch
from
August 22, 2026 07:11
f448e31 to
f75954c
Compare
Behnam-RK
force-pushed
the
feat/ui-explains-itself
branch
from
August 22, 2026 07:11
142f5ef to
80dc5ba
Compare
Behnam-RK
force-pushed
the
feat/complete-purge
branch
from
August 23, 2026 12:29
f75954c to
9c0c4f3
Compare
uninstall.sh removed only root-owned state, so everything belonging to the logged-in user survived it: the preference domain, the login-keychain control token, and the login-item registration. That is also why the setup wizard stopped appearing after a reinstall — FirstRunDecision.offer reads dezhban.firstRunCompleted out of a preference domain no uninstaller ever cleared, so a machine with an empty /etc/dezhban still answered "already done". The missing feature and the reported bug are the same defect from two ends. Root cannot do this work: a login keychain item's ACL is bound to the user's session and a login item is registered per user. So the app owns the per-user half. Settings gains Remove Dezhban…, which clears the keychain token and its capability probe, both login-item registrations, the saved window state, and the preference domains (current and the dead com.dezhban.DezhbanMenu), then opens Terminal on the root uninstaller and quits. Terminal rather than an in-app sheet because uninstall.sh quits Dezhban and deletes its bundle partway through: a progress sheet dies mid-teardown and cannot distinguish a finished uninstall from one that stopped after panic removed the rules. A terminal window outlives the app and shows the rule teardown, which for a kill switch is the step you most want to watch succeed. Preference domains are cleared last, immediately before terminate, because AppKit writes defaults as the app winds down; uninstall.sh repeats the deletion for $SUDO_USER for the same reason, and names the two per-user items it cannot reach rather than failing at them silently. Refs docs/adr/0015-complete-purge-semantics.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behnam-RK
force-pushed
the
feat/complete-purge
branch
from
August 24, 2026 03:55
9c0c4f3 to
f834f8f
Compare
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.
This also fixes the "setup flow didn't auto-start on first run" report — the two turned out to be the same defect seen from opposite ends.
The diagnosis
FirstRunDecision.offeris!isComplete && !vpnKnown, whereisCompletereadsdezhban.firstRunCompletedfrom the app's preference domain. On the machine where this was investigated:The daemon side had been reset; the per-user flag had not, because
packaging/macos/uninstall.shruns as root and only ever removed root-owned state. So a reinstalled machine with no VPN configured still answered "the wizard has been completed" and stayed silent.defaults domainsalso showed a second, dead domain —com.dezhban.DezhbanMenu, from a superseded bundle identifier — that nothing had ever cleaned up.The keychain control token and the login-item registration survived uninstall the same way. A surviving login item makes macOS keep trying to launch a deleted bundle.
Why the app has to do it
Root cannot. A login-keychain item's ACL is bound to the user's session, and a login item is registered per user via
SMAppService.sudo -uinto an account that is not logged in cannot unlock its login keychain. So the work is split by who is actually able to perform it.What changed
Purge.swift(new) removes, in this account's own session: the control token and its capability-probe item (via a newControlToken.purge(), which owns the account names and the-25244ACL reason a plainSecItemDeletewill not work), both login-item registrations (the ADR-0014 agent and any survivingmainAppone), the saved-window-state directories, and the preference domains — current and legacy.Preference domains go last, immediately before
NSApp.terminate: AppKit writes defaults as the app winds down, so a domain cleared early would simply be recreated before the process exits.Settings → Remove Dezhban… is a critical-style alert naming every category it removes, with a "Keep my dezhban configuration in /etc/dezhban" checkbox mapping to the script's existing
KEEP_CONFIG=1. Cancel is the default button and the return key cannot trigger removal — the return key must not be able to uninstall a kill switch.It then opens Terminal.app on
sudo sh /usr/local/share/dezhban/uninstall.shand quits. Not an in-app progress sheet: the script quits Dezhban and deletes its bundle partway through, so a sheet dies mid-teardown and leaves the user unable to tell a finished uninstall from one that stopped afterpanicremoved the rules. A terminal window outlives the app and shows the rule teardown — for a kill switch, the step you most want to watch succeed. If Terminal cannot be opened, the app says dezhban is still installed and still enforcing, prints the command, and does not quit.uninstall.shadditionally deletes both preference domains for$SUDO_USER(belt and braces, for the shutdown-rewrite reason above, and so a CLI-only install is not left behind), and prints the two per-user items it cannot reach with the commands that finish the job. Only$SUDO_USER, never a loop over/Users— a script that deletes other people's data because root ran it is a scope no uninstaller should claim. Its existing ordering invariant is untouched:panicstill runs first.Rationale
ADR-0015 records what purge destroys, what it deliberately does not (other accounts; notification authorization, which macOS owns and offers no API to revoke), and why the three rejected alternatives were rejected — including "just drop
isCompletefrom the gate", which fixes the symptom in one line and leaves every other item behind.Verification
go build ./...,go vet ./...,go test ./...,shellcheck— pass.swift test— 197 tests pass.The purge itself is destructive and one-way, so it is verified on-host rather than in CI. docs/contribute/testing.md gains a "Remove Dezhban (complete purge)" section covering: Cancel as the default; that the keychain item, login item and preference domains are actually gone afterwards; that
panicis visible in the transcript before anything is deleted and the network works throughout;KEEP_CONFIGboth ways; that a reinstall then opens the first-run wizard; that a missing uninstaller degrades honestly; and that the script's own per-user pass touches only the invoking account.docs/usage/troubleshooting.md gains two entries: "I reinstalled, but the setup wizard never appears" (with the one-line
defaults delete, and a pointer to Settings → Run Setup Again… as the route that needs no uninstall) and "Removing dezhban completely".🤖 Generated with Claude Code