Skip to content

feat(setup): two-step wizard - #52

Open
Behnam-RK wants to merge 2 commits into
mainfrom
feat/two-step-wizard
Open

feat(setup): two-step wizard#52
Behnam-RK wants to merge 2 commits into
mainfrom
feat/two-step-wizard

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

Stacked on #51#50#49.

Implements the two-step shape you described: step 1 countries, step 2 an automatic-detection tickbox that reveals the self-hosted options when unticked, with configureVPN dropped.

The shape

dezhban setup --questions now emits exactly:

blockedCountries | group 1 | multiselect | gate —
otherCountries   | group 1 | list        | gate —
autoMode         | group 2 | bool        | gate —
tunnels          | group 2 | multiselect | gate autoMode=false
profileFiles     | group 2 | list        | gate autoMode=false
endpoints        | group 2 | list        | gate autoMode=false

Both wizards read this one set, so dezhban setup changes with the app.

Dropping configureVPN needed two guards

That question was what kept a re-run from touching a working VPN config (Apply returned early when it was false). Removing it means a run always writes the VPN keys, so:

1. autoMode's default is seeded from the config. A config with pinned vpn.tunnelInterfaces starts on manual. Without this, a re-run would default to automatic, and Apply clears TunnelInterfaces under AutoMode on purpose — so pressing Enter through the wizard would have silently unpinned interfaces someone chose deliberately. Pinned by TestAutoModeSeedsFalseWhenInterfacesArePinned on both sides.

2. Input.Endpoints becomes *[]string, nil when the question was never shown. On macOS the endpoint question is gated behind "not automatic", so someone re-running setup to change their blocked-country list — leaving detection on, as recommended — reaches Apply with no endpoint answer at all. Writing that as an empty list would delete their server. Nil rather than empty for exactly the reason the existing AutoDiscover *bool is a pointer, and its doc comment already says so: an empty slice cannot be told apart from "asked, and cleared on purpose". The Swift side already had this property for free via shouldAsk.

One thing worth flagging

Off macOS the endpoint question is ungated. There is no live discovery on Linux or Windows, so an endpoint is required whichever detection mode is chosen — gating it would let a Linux user finish the wizard with a config that cannot enforce. TestEndpointsAreUngatedWhereThereIsNoDiscovery pins it.

The CLI renders step 2 as two prompts

A huh form binds every field before any of them is answered, so a question gated on another question in the same group would be decided by that question's seeded default rather than by what the user just ticked. The app has no such problem — it re-evaluates gates as answers change, which is what makes step 2 a single screen there.

Rather than split the shared question set to suit one renderer, the terminal now asks a group in waves: the ungated questions first, then re-evaluate, then whatever that opened up. Documented in cli.md so the difference is not a surprise.

Verification

  • go build ./..., go vet ./..., go test ./... — pass. internal/setup gains five tests: the two-group shape across all three platforms, the pinned-interface seed both ways, the unasked-endpoint rule, the gating, and the off-macOS ungating.
  • swift test — 199 tests pass. The SetupQuestionsTests JSON fixture is updated to the real emitted shape, and gains theWizardIsTwoSteps, anUnaskedQuestionWritesNoKey, and aSeededManualModeReWritesItsPins.
  • dezhban setup --questions output inspected directly (above).

On-host checks added to docs/contribute/testing.md, the important two being: a re-run on a pinned config keeps its pins, and a re-run under automatic detection keeps configured endpoints. Also fixes a stale defaults delete com.dezhban.menu in the first-run checklist — that domain has been com.behnam-rk.dezhban.app for a while.

🤖 Generated with Claude Code

Behnam-RK and others added 2 commits August 22, 2026 10:39
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>
Countries, then one "Use automatic VPN detection?" tickbox with the manual
fields — tunnel interfaces, self-hosted config files, endpoints — hanging off
it. The opening "Configure your VPN now?" question is gone, and both wizards
read the same question set, so the CLI changes with the app.

Dropping configureVPN removes the thing that stood between a re-run and a
working config, so two guards replace it.

autoMode's default is seeded from the config: a config with pinned
vpn.tunnelInterfaces starts on manual, because Apply clears TunnelInterfaces
under AutoMode on purpose and a wizard defaulting to automatic would silently
unpin them.

Input.Endpoints becomes *[]string, nil when the question was never shown. On
macOS the endpoint question is gated behind "not automatic", so a re-run that
leaves detection on reaches Apply with no answer — writing that as an empty list
would delete the user's server. Same convention, and the same reasoning, as the
existing AutoDiscover *bool. The Swift side already had this property via
shouldAsk. Off macOS the question is ungated instead: there is no live
discovery, so an endpoint is required whichever mode is chosen.

The CLI now asks a group in waves. A huh form binds every field before any is
answered, so a question gated on another question in the same group would be
decided by that question's seeded default. Rather than split the shared question
set to suit one renderer, the terminal asks the ungated questions, re-evaluates,
and asks whatever that opened up — the app shows step 2 as one screen because it
re-evaluates gates as answers change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the feat/complete-purge branch from f448e31 to f75954c Compare August 22, 2026 07:11
@Behnam-RK
Behnam-RK force-pushed the feat/two-step-wizard branch from 76b09e9 to 496be74 Compare August 22, 2026 07:11
@Behnam-RK
Behnam-RK force-pushed the feat/complete-purge branch 2 times, most recently from 9c0c4f3 to f834f8f Compare August 24, 2026 03:55
Base automatically changed from feat/complete-purge to main 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