Skip to content

Epic: live reload, recovery speed, CLI–GUI parity, tiered settings #33

Description

@Behnam-RK

Problem Statement

Three bug clusters undermine trust in dezhban day-to-day:

  1. Settings don't take effect. Editing config (CLI or GUI) changes nothing
    until a full daemon restart; declining the GUI's restart prompt leaves the
    saved settings silently inert, and the GUI can show values that don't match
    what the daemon is enforcing.
  2. Posture is slow and opaque. After the VPN redials out of FULL BLOCK, the
    guard takes up to poll-interval × hysteresis (~30s at defaults) to restore,
    with no indication that recovery is even in progress.
  3. The GUI occasionally beachballs with no reproducible pattern.

Beyond bugs, the two surfaces have drifted: pause/resume exist only in the CLI,
status wording is written twice and differs, the app calls the same thing "kill
switch", "protection", and "guard" depending on the pane, and the advanced
config block is unreachable from any tool. Finally, the settings surface serves
neither audience well: beginners face raw keys and durations; power users still
can't reach vpn.advanced.* without editing JSON by hand.

Solution

  • Live reload: a reload control-socket op re-reads the root-owned config
    from inside the run loop; config set and the GUI trigger it, falling back to
    restart only for the few restart-required keys. A keychain-gated token
    (biometry-protected, ADR 0003) authorizes a config-write op so GUI settings
    changes stop requiring sudo, and the sudo path that remains gets a real Touch
    ID retry via SUDO_ASKPASS.
  • Fast, visible recovery: a tunnel-up edge while blocked starts accelerated
    geo probes until the hysteresis streak resolves (hysteresis itself unchanged),
    and the snapshot carries recovery progress ("1/2 good readings") that both
    surfaces display.
  • Beachball hunt: a main-thread hang watchdog logs stalls to the persistent
    log, plus defensive fixes for the plausible suspects (state-file stat moved
    off-main).
  • One voice: a Go renderer turns a Snapshot into headline + detail
    sentences; the CLI prints them, the state file and status --json carry them,
    the GUI displays them. Vocabulary standardizes on the Guard family everywhere.
  • Parity: GUI gains Pause/Resume (with countdown), an explicit Restart,
    a Doctor results pane, and profile visibility/picker.
  • Tiered settings, no lost flexibility: Strict/Balanced/Relaxed presets
    defined once in the config core with drift-to-Custom detection, exposed as
    config preset in the CLI and a picker in the GUI; every vpn.advanced.*
    key becomes settable via config set and a collapsed GUI Advanced pane.

Commits

Each commit leaves the tree building, tests green, and behavior coherent.

Phase A — diagnostics and defensive fixes (bugs first)

  1. Add a main-thread hang watchdog to the GUI. A background heartbeat pings
    the main run loop; if a ping goes unanswered past a threshold, log the stall
    duration and app state to the persistent log. Display-only; no behavior
    change.
  2. Move the 1-second state-file stat/read off the main thread. The timer
    fires a background read; only the decoded result hops to main. Likely-suspect
    fix for the beachballs; identical UI behavior.
  3. Add a table-driven config round-trip test asserting every settable key
    travels file → load → normalize → runner options without being dropped.
    Pins the "a specific key is inert" class of bug and becomes the harness later
    commits extend.

Phase B — live reload (the settings bug)

  1. Extract the config→runner-options derivation into a pure function the
    run loop can call again later. Pure refactor; existing tests keep passing.
  2. Teach the run loop to re-derive options from a freshly loaded config via
    a new channel case, applying live-safe changes and reporting which keys need
    a restart. No caller yet; unit-tested directly against the fake backend.
  3. Add the reload op to the control protocol and server whitelist, wired
    to the new channel. Gate mirrors existing op gating.
  4. config set sends reload after a successful write, printing what
    applied live and what needs restart; silent no-op message when no daemon
    answers.
  5. GUI applies settings via reload instead of batching a restart, keeping
    restart as the fallback for restart-required keys or an unresponsive daemon.
    Footer reports applied-live vs needs-restart honestly.
  6. Fix the GUI stale-values case: re-seed the Settings pane from the config
    file on window open and when the file's mtime changes externally.

Phase C — auth (rest of ADR 0003)

  1. Give the sudo path a Touch ID retry: ship a tiny askpass helper, switch
    the elevation path to sudo -A, drop the null-stdin defect-to-password
    behavior. Lifecycle ops (install/start/stop/panic) benefit immediately.
  2. Introduce the control token: daemon reads a hash at its state dir;
    socket ops can require token auth. No writer yet.
  3. Add the config-write socket op, token-gated, letting an authorized
    client submit validated config changes the daemon writes and reloads
    itself.
  4. GUI stores the token biometry-gated in the keychain and uses
    config-write for settings changes — reading the token IS the Touch ID
    prompt, with native retry and password fallback. Sudo remains only for
    lifecycle ops.
  5. Flip ADR 0003 to implemented in the ADR index; docs for the token flow.

Phase D — recovery speed and visibility

  1. Publish recovery progress in the Snapshot (current streak / needed)
    whenever the daemon is counting toward a posture flip; CLI status prints
    it. Display-only.
  2. Fast-probe on tunnel-up while blocked: the up edge schedules accelerated
    geo probes until the streak resolves, then cadence returns to
    pollInterval. Hysteresis and the hold-on-unknown invariant unchanged;
    runner tests pin edge → accelerated probing → GUARD.

Phase E — one renderer, one vocabulary

  1. Add a Go render package: Snapshot → headline + detail sentences in the
    Guard vocabulary, golden-tested for every posture and window state.
  2. CLI status adopts the renderer for its human summary (structured
    lines stay).
  3. State file and status --json carry the rendered strings.
  4. GUI displays the carried strings, deleting its hand-written sentence
    copies; menubar tooltip and Overview headline come from the renderer.
  5. Vocabulary sweep: Guard-family labels across GUI panes, CLI help, docs,
    and the stale modes HTML page; glossary updated as the authority.

Phase F — GUI parity surfaces

  1. Pause/Resume in the GUI with pauseMax countdown, using the existing
    socket ops — copy makes clear it relaxes the guard and auto-reverts.
  2. Explicit Restart control in the GUI (was only implicit in settings
    apply).
  3. doctor gains machine-readable output; then a Doctor pane in the
    GUI renders the same checks.
  4. Profile visibility/picker: show vpn.profiles and the matched
    activeProfile; open a profile-targeted switch window from the GUI.

Phase G — tiered settings

  1. config set learns every vpn.advanced.* key, extending the Phase A
    round-trip test to cover them.
  2. Preset definitions in the config core (Strict / Balanced / Relaxed as
    key-value sets with cost/benefit descriptions) plus a drift function
    reporting which keys diverge from a preset. Pure logic + tests.
  3. CLI config preset list / show / apply / diff, apply routed through
    the same write+reload path as config set.
  4. GUI preset picker showing cost beside benefit, with drift-to-Custom
    detection and a diff of divergent keys.
  5. GUI Advanced disclosure pane exposing vpn.advanced.* behind the
    "touch only if you know why" warning.

Phase H — Swift test target

  1. Add the first SPM test target covering Snapshot decoding, posture→icon
    mapping, and the settings seed/apply batching logic; wire into CI where the
    runner allows (macOS job).

Docs and CHANGELOG entries land in the same commit as each behavior change, per
repo convention.

Decision Document

  • Reload semantics: reload re-reads the root-owned config file — it
    grants no new write authority, so it rides the existing op-gating model.
    config-write DOES grant write authority and is therefore token-gated
    (keychain, biometry-protected). Two ops, deliberately different trust levels.
  • Live-safe vs restart-required keys: the run loop applies what it can
    (intervals, hysteresis, countries, providers, windows, VPN sets); keys wired
    before the loop starts (control socket path/group, log destination, service
    identity) report restart-required. The reload response enumerates both so no
    surface ever claims a change applied when it didn't.
  • Recovery acceleration changes cadence only, never the rules: hysteresis
    still gates the flip, unknown still holds posture, the accelerated probe uses
    the same tunnel+destination-scoped provider pass. No new window trigger, no
    invariant change, no new ADR needed for it.
  • The renderer is the single voice: Go renders once; every surface displays
    the same strings. Swift never composes posture sentences again. Vocabulary is
    the Guard family: Guard / Full block / Standby / Paused / Window open.
  • Pause in the GUI is trigger 3 unchanged — same socket op, same pauseMax
    cap, same auto-revert; only presentation is new.
  • Presets are a write-time macro, not runtime state: applying a preset
    writes ordinary keys through the ordinary path; the daemon never knows about
    presets. Drift detection compares current config to preset definitions at
    display time. This keeps vpn.profiles (VPN identities) cleanly distinct
    from presets (strictness strategies).
  • No new Go dependencies. The askpass helper is trivial; keychain access is
    Swift/Security-framework; presets and renderer are stdlib.
  • Beachball work is diagnose-then-fix: the watchdog plus off-main file
    reads ship now; any further fix waits for watchdog evidence rather than
    guessing.

Testing Decisions

  • Tests assert external behavior — published snapshots, socket responses,
    rendered strings, written config — never internal call sequences.
  • Go: runner tests with the fake backend/monitor (prior art: the existing
    runner package tests) cover reload application, restart-required reporting,
    and the tunnel-up fast-probe path. Config round-trip table pins every key
    including advanced. Renderer uses golden-string tests per posture. Control
    tests (prior art: the existing control package tests) cover the new ops and
    token gating.
  • Swift: new SPM test target pins Snapshot decoding, posture→icon mapping,
    and settings batching — the seams where GUI bugs actually lived.
  • Privileged on-host checks (CI can't run them) get new entries in the
    standing checklist: live reload against a running daemon, Touch ID retry
    behavior, token enrollment/revocation, pause countdown.
  • Behavior-preservation for refactor commits: build the parent commit in a
    scratch worktree and diff print-rules output (stdout AND stderr) across
    configs × modes.

Out of Scope

  • Any change to enforcement invariants: no fourth window trigger, no shared
    caps, no relaxation of the tunnel+destination-scoped geo pass, no change to
    hold-on-unknown.
  • Linux/Windows GUI work (Go-side changes remain cross-platform; GUI work is
    the macOS app).
  • Trusted-networks/SSID features, notification granularity, config
    history/rollback (backlog items not pulled in here).
  • The upgrade pipeline and its CanActivate gating.
  • Removing any existing setting or capability — this epic only adds reachability
    and layers presets on top.

Further Notes

  • Phases are ordered so the bug fixes (A–D) land before the larger surface work
    (E–G); each phase is independently shippable and PR-able.
  • ADR 0003 is the only ADR this epic ships; flip its status in the same PR as
    the token work.
  • The stale modes HTML page rewrite rides the vocabulary sweep commit.

Addendum — icon truthfulness, first-run, in-app docs, settings UX

Seven follow-up items. Investigation notes that changed their shape:

  • Icon state logic already exists. PostureUI.guardHoldsDownedTunnel already
    maps "guard posture, no tunnel up" to the red blocked icon, and
    PostureUI.dockState already narrows the Dock tile to blocked-vs-default. What
    is wrong is narrower than it looked (below).
  • The guard-holds-downed-tunnel state is never published. On a tunnel-down
    edge the run loop opens the automatic reconnect window in the same loop
    iteration, so the snapshot goes straight from guard to switch-window. The
    red state is therefore unreachable on the automatic path — it only appears when
    the window is suppressed (reconnect disabled, flap guard, standby, full block).
  • A reconnect window does not cut egress. It deliberately relaxes the guard so
    the VPN can redial, so the real IP may be exposed. Red there would invert the
    icon's meaning at the moment it matters most; amber stays correct.
  • Disabling the windows already works ("0" / config.Disabled on
    switchWindow, reconnectWindow, pauseMax). The gap is purely that the GUI
    offers no visible "Off", and pauseMax has no GUI control at all.

Phase I — icon truthfulness and brand assets

  1. Rename gui/assets/ to gui/artifacts/. Mechanical: update the build
    script's asset root, the README banner, docs image paths, and the source
    comments that name the directory. Historical CHANGELOG entries keep their old
    paths — they describe shipped releases and must not be rewritten. Drop the
    stray .DS_Store and add an ignore rule.
  2. Dock icon shows the brand icon unless egress is cut. Today the
    not-blocked Dock tile is staged from the icon-on state tile; it should be
    the brand app icon, with icon-blocked only when actually blocked. A
    build-script mapping change plus the matching comment; the coarsening logic in
    dockState is already right.
  3. Publish the cut moment before opening the automatic window. The
    tunnel-down edge writes a snapshot showing the guard holding a downed tunnel,
    then opens the reconnect window. Makes an already-correct state observable
    and lets both surfaces report the drop honestly instead of jumping straight to
    "window open".
  4. "Hold the line" — a deliberate-disconnect affordance. dezhban cannot tell
    an intentional disconnect from an accidental drop, which is the real cause of
    the reported confusion: disconnecting by hand opens a reconnect window the user
    never wanted. Add an explicit operator action that suppresses the automatic
    window for the next drop, so a deliberate disconnect stays cut and red. This is
    strictly more restrictive — it adds no relaxation trigger and needs no ADR.
    Exposed on both surfaces alongside pause (pause = "let me use my real IP";
    hold the line = "keep me cut").
  5. Sharpen the open-window copy so a relaxed guard is unmistakable —
    the exposure and the remaining time lead the sentence. Rides the Phase E
    renderer, so both surfaces change together.

Phase J — first-run setup in the GUI

  1. Extract the setup wizard's decisions and defaults into a Go core, leaving
    the interactive prompt library as CLI-only presentation. Same questions, same
    derived config, one place.
  2. Native first-run wizard in the GUI, driven by that core and writing
    through the same validated write-and-reload path as every other settings
    change. Runs on first launch and is re-openable later.

Phase K — in-app wiki and contextual help

  1. Bundle documentation into the app at build time. The build script copies
    the selected markdown into app Resources so it ships with the version it
    documents and works offline — which is exactly when the kill switch has cut
    traffic. A CI check fails the build if a referenced doc is missing or renamed.
  2. A documentation browser pane rendering that bundled markdown natively,
    with search and a tutorial track for first-time users.
  3. Contextual deep links from individual settings controls into the relevant
    documentation section, so help is reachable where the question arises.

Phase L — settings UX and window controls

  1. Purpose-built duration controls. Replace raw duration text entry with a
    preset menu plus a slider bounded by each field's real cap, a custom entry for
    exact values, and an explicit Off choice wherever the disabled sentinel
    applies — each stating its consequence in plain words. Expose vpn.pauseMax,
    which currently has no GUI control.
  2. Content and layout pass across the GUI: ordering, grouping, headings,
    field descriptions, empty and error states, and the shared CLI help text for
    the same concepts. Follows the Phase E vocabulary so every surface reads the
    same way.

Addendum decisions

  • Red means egress is actually cut — no exceptions. An open switch, reconnect,
    or pause window stays amber because traffic is flowing and the real IP may be
    exposed. The fix for a deliberate disconnect is to suppress the window (item 35)
    or disable it outright, not to recolor a relaxed state.
  • "Hold the line" suppresses; it never relaxes. It removes an automatic
    relaxation for one drop. The three sanctioned relaxation triggers are unchanged
    and no fourth is introduced.
  • Documentation stays single-source. The GUI renders the repo's markdown; it
    never restates it. A doc referenced by the bundle is load-bearing, same rule as
    doc paths cited from source.
  • The setup core is shared, the presentation is not. The interactive prompt
    dependency stays off the GUI and off the enforcement path.

Addendum testing

  • Go: the snapshot published on a tunnel-down edge, window suppression after
    "hold the line", and the setup core's question-to-config derivation.
  • Swift: posture-to-icon and posture-to-Dock-tile mapping in the new test target,
    including the guard-holds-downed-tunnel case that has no coverage today.
  • Build: the docs bundling check, and a manual on-host pass confirming the Dock
    tile swaps only when egress is cut.

Addendum 2 — defaults source of truth, menubar scope, pause presets

Phase M — one source of truth for defaults

Default values are currently restated in at least four places: the Go constants
in the config package, the GUI's placeholder hints, the documentation tables, and
the example config files. They have already drifted. The GUI hints advertise a
30s endpoint refresh and a 5s tunnel watch; the shipped defaults are 1m and 1s.
Anyone reading the settings pane is being told the wrong thing today.

  1. Promote defaults to declared metadata. Each tunable becomes a described
    entry in the config package — key, default, cap, unit, whether the disabled
    sentinel applies, and a one-line explanation — with the existing constants as
    the values behind it. Pure addition; Normalize keeps its current behavior and
    its tests keep passing.
  2. Every surface reads that metadata instead of restating it. The GUI derives
    hints, slider bounds, and Off-availability from it; CLI help and validate
    output do the same. The drifted hints disappear because nothing hardcodes them
    anymore.
  3. A test fails the build when documentation or example configs disagree
    with the declared defaults, so the config reference and the shipped examples
    cannot silently rot again.

Phase N — menubar scope

The menubar becomes a glance-and-go surface: routine actions and read-only
information only. Everything else moves into the main window, which is where
detail belongs.

  1. Narrow the menubar to routine actions. Start and Stop the kill switch as
    the primary pair, opening and cancelling a switch window, and Pause. Manual
    block/unblock, profile and doctor detail, and configuration all move to the
    window. Note that lifecycle actions require root every time — a daemon cannot
    manage its own lifecycle — so the Touch ID retry work in Phase C is a
    prerequisite for this to feel routine rather than punishing.
  2. Keep the read-only information that is actually worth a glance: current
    posture in the shared vocabulary, tunnel and exit country, and any live window
    countdown. Sourced from the Phase E renderer, so it matches the CLI word for
    word.
  3. Demote Panic behind the Option key — the standard macOS treatment for a
    destructive item. It stays reachable from the menubar on purpose: it is the
    lockout escape hatch, and the situation where it is needed is exactly the
    situation where the main window may not open.

Phase O — pause presets

  1. Pause offers predefined durations rather than a single fixed length or a
    free-text duration: a short list covering the realistic cases, defined once in
    the config core and consumed by both the CLI and the GUI so the two offer the
    same choices. Each option states when protection returns. Options above the
    configured cap are shown as unavailable with the cap as the reason, rather than
    silently clamping to something the user did not pick. The existing cap,
    disable-gate, and auto-revert behavior are unchanged.

Addendum 2 decisions

  • Defaults are data, not prose. A default stated in more than one place is a
    default that will drift; the metadata table is the only place a value is
    written down, and every other surface derives from it.
  • The menubar is for glancing and routine acts; the window is for detail.
    The split is deliberate, and Panic is the one exception because its whole
    purpose is working when everything else does not.
  • Pause presets clamp nothing silently. A duration beyond the cap is refused
    and explained rather than quietly shortened.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions