Skip to content

feat: complete uninstall and purge - #51

Merged
Behnam-RK merged 1 commit into
mainfrom
feat/complete-purge
Aug 24, 2026
Merged

feat: complete uninstall and purge#51
Behnam-RK merged 1 commit into
mainfrom
feat/complete-purge

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

Stacked on #50 (which is itself on #49). Review those first; this PR's diff is against #50.

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.offer is !isComplete && !vpnKnown, where isComplete reads dezhban.firstRunCompleted from the app's preference domain. On the machine where this was investigated:

$ defaults read com.behnam-rk.dezhban.app
{ "dezhban.firstRunCompleted" = 1; ... }
$ python3 -c "..." /etc/dezhban/dezhban.json
endpoints: []   profiles: []

The daemon side had been reset; the per-user flag had not, because packaging/macos/uninstall.sh runs 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 domains also 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 -u into 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 new ControlToken.purge(), which owns the account names and the -25244 ACL reason a plain SecItemDelete will not work), both login-item registrations (the ADR-0014 agent and any surviving mainApp one), 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.sh and 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 after panic removed 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.sh additionally 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: panic still 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 isComplete from 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 panic is visible in the transcript before anything is deleted and the network works throughout; KEEP_CONFIG both 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

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
Behnam-RK force-pushed the feat/complete-purge branch from 9c0c4f3 to f834f8f Compare August 24, 2026 03:55
@Behnam-RK
Behnam-RK merged commit f6e4e4b into main Aug 24, 2026
5 checks passed
@Behnam-RK
Behnam-RK deleted the feat/complete-purge branch August 24, 2026 04:17
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.

1 participant