Skip to content

feat: macOS menubar GUI (Phase 8) + config-management UX, docs restructure, sudo auto-elevation#15

Merged
Behnam-RK merged 9 commits into
mainfrom
feat/macos-menubar-gui
Jul 2, 2026
Merged

feat: macOS menubar GUI (Phase 8) + config-management UX, docs restructure, sudo auto-elevation#15
Behnam-RK merged 9 commits into
mainfrom
feat/macos-menubar-gui

Conversation

@Behnam-RK

@Behnam-RK Behnam-RK commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Scope note: this branch started as Phase 8 (macOS menubar GUI) and grew to
include a docs restructure, a config-management UX overhaul, and sudo
auto-elevation. Each is an independent, separately-committed change; see the
commit list. Original Phase 8 write-up is preserved below, followed by the
follow-on work.


Part 1 — macOS menubar GUI + daemon state export (Phase 8)

Adds a macOS-only menubar app (Dezhban.app) for at-a-glance status and
click-to-control of the kill switch, plus the daemon-side state export it
reads. Implements docs/plans/phase-8-macos-gui.md.

Daemon: live state file (Go, cross-platform, stdlib-only)

  • internal/stateSnapshot type + atomic writer (temp-file + rename,
    0644 world-readable) and reader. Path: /var/db/dezhban/state.json (unix) /
    %ProgramData%\dezhban\state.json (Windows).
  • internal/runner — injected Publish func(state.Snapshot) callback
    (nil = no-op). Publishes on every poll, verdict transition, tunnel edge,
    endpoint refresh, and at startup. Best-effort: a write failure never affects
    enforcement.
  • cmd/dezhbandefaultStatePath(), wires Publish into the real run
    path only, adds status --json merging snapshot + service/config status.

Swift menubar app (macos-gui/, AppKit + ServiceManagement only)

  • NSStatusItem SF-Symbol icon (🟢 allow/guard, 🔴 block/full-block, ⚪
    stopped/stale-after-90s), 1s state-file poll, snapshot-driven menu.
  • Start/Stop, Block/Unblock via the native osascript admin prompt; Launch at
    login
    via SMAppService; VPN-mode indicator + Open config.
  • SwiftPM + build-app.shdist/Dezhban.app; make gui-macos.

Deviations from plan: SwiftPM + bundling script instead of an Xcode project
(build box has only Command Line Tools); VPN-mode live toggle deferred (a blind
vpn.enabled flip fails config.Validate()) — menu routes to Open config….


Part 2 — Docs split + VPN-primary repositioning (26c9ca2)

  • Repositioning (docs only — no code/behavior/CLI/JSON changes): the always-on
    VPN interface guard is now framed as the primary/recommended mode and
    country-blocklisting as the fallback. vpn.enabled still defaults to false
    as a documented safety opt-in.
  • Split the oversized README.md (346 → ~80 lines) and CLAUDE.md (205 → ~75)
    into focused docs under docs/ (modes, architecture, usage,
    development, safety, plus an index). Renamed existing docs to lower
    kebab-case; repointed every reference; removed the generic MCP tooling policy
    that duplicated the user's global config.

Part 3 — Config-management UX (a8c8be3)

  • --config is now optional — resolved from the flag → $DEZHBAN_CONFIG
    system path (/etc/dezhban/dezhban.json) → built-in defaults. dezhban config path prints the winner.
  • internal/config gains a writer (Marshal/Save; durations round-trip as
    "30s"; round-trip tested).
  • New commands: dezhban setup (interactive charmbracelet/huh wizard —
    reuses tunnel detection, the endpoint lockout check, and a print-rules
    preview before saving; guarded against non-TTY); dezhban config path/show/get/set/edit; dezhban completion bash|zsh|fish.
  • Dependency: adds charmbracelet/huh as a second direct dependency (setup
    wizard only; daemon/enforcement path stays stdlib). CLAUDE.md's dependency
    convention updated to record it.

Part 4 — sudo auto-elevation (1d9deaa)

  • Privileged commands auto-re-exec under sudo when not root (unix +
    interactive TTY), preserving $DEZHBAN_CONFIG. setup and config set/edit
    elevate just their config write. Opt out with --no-sudo or
    DEZHBAN_NO_SUDO=1. Windows and non-TTY contexts keep the clear "must run as
    root" error (terminal detected via IsTerminal, not ModeCharDevice).

Verification

  • go build / go vet clean; go test ./...80 pass (adds internal/config
    Save↔Load round-trip + validation tests); host and Windows cross-builds;
    make build-all cross-compiles all 5 targets (with huh); gofmt clean;
    pre-commit hooks green.
  • Config UX exercised: path discovery ($DEZHBAN_CONFIG + system path), config set/get/show/path, completion, non-TTY setup guard.
  • sudo auto-elevation confirmed interactively — password prompt + successful
    unblock; --no-sudo falls back to the plain root error.
  • Still needs a root + logged-in GUI session (Part 1): live daemon →
    state-file → icon flip, admin-prompt actions, login-item registration. Steps in
    the phase doc.

To try locally: make gui-macos && open dist/Dezhban.app, then
sudo ./dezhban run --config configs/dezhban.dev.json --simulate-country IR to
watch the icon flip.

🤖 Generated with Claude Code

Behnam-RK and others added 6 commits July 1, 2026 07:20
Add a macOS-only menubar app (Dezhban.app) for at-a-glance status and
click-to-control, plus the daemon-side state export it reads.

Daemon (Go, cross-platform, stdlib-only):
- internal/state: Snapshot type + atomic (temp+rename), world-readable
  0644 writer/reader for /var/db/dezhban/state.json
- internal/runner: injected Publish callback (nil = no-op); publishes a
  full snapshot on each poll, transition, tunnel edge, endpoint refresh,
  and at startup, retaining the last-known reading. Best-effort — never
  affects enforcement
- cmd/dezhban: defaultStatePath(), wire Publish into the run path only,
  add `status --json`

App (Swift, macos-gui/, AppKit + ServiceManagement only):
- NSStatusItem icon (green allow/guard, red block, gray stopped/stale),
  1s state-file poll, snapshot-driven menu
- Start/Stop, Block/Unblock via osascript admin prompt; Launch-at-login
  via SMAppService; VPN-mode indicator + Open config (live toggle deferred)
- SwiftPM + build-app.sh -> dist/Dezhban.app; `make gui-macos`

Docs: docs/STATE.md (state-file contract), phase-8 plan, README/CLAUDE.md.

Verified: go build/vet clean, 76 tests pass, gofmt clean, Swift builds
and bundles, `status --json` emits valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- state: populate Tunnel.Name in published snapshots. Add Name to
  netdetect.TunnelState (set from the identified up interface) and a
  tunnelSnapshot helper in the runner that maps it through, falling back to
  the configured tunnel name(s) on a down/unknown edge. The JSON `name`
  field the contract and docs/STATE.md advertise is now meaningful instead
  of always "".

- runner: decouple best-effort publishing from the enforcement loop. Run
  now hands snapshots to a background writer over a buffered (64) FIFO
  channel, flushed on shutdown, so a stalled state-file write can't delay
  the next tunnel/geo event. FIFO (not coalescing) preserves the
  every-transition-published contract the runner test asserts.

- macos-gui: remove dead DezhbanCLI.statusJSON()/run() (the app is entirely
  state-file driven and never called them; the comment claiming "used on
  menu open" was false). Correct the matching statusJSON comment in
  cmd/dezhban to describe it as a tooling contract, not an app fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
liveSample emits `"<iface> up"` (e.g. "utun4 up"), never the bare "UP" the
example showed. Aligns the state-file contract doc with actual daemon output;
the `name` field it advertises is now genuinely populated too (this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… mode

Repositioning (docs/positioning only — no code, config-default, or CLI/JSON
contract changes):
- Present the always-on VPN interface guard as the primary/recommended mode and
  the country-blocklist as a best-effort fallback, matching the design intent
  already in docs/plans. Explain the retained vpn.enabled=false default as a
  safety opt-in, and why no poll interval can guarantee zero leak in the fallback.
- "primary"/"fallback" are prose only; the guard/fullblock/legacy identifiers and
  state-file JSON keys are unchanged.

Doc split:
- Move detail out of the two oversized top-level files into focused docs/ files:
  modes.md, architecture.md, usage.md, development.md, safety.md, plus a docs
  index (readme.md). README/CLAUDE keep only essentials + non-negotiables and link.
- Remove the generic jcodemunch/jdocmunch/serena tooling policy from CLAUDE.md
  (inherited from the user's global config).
- Rename docs to lower kebab-case: CONFIG/STATE/TROUBLESHOOTING/plans/README ->
  config/state/troubleshooting/plans/readme; repoint every reference and fix a
  pre-existing broken link in plans/testing-macos-block.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion, path discovery

Make the config file-free and --config optional.

- Config path discovery: `resolveConfigPath` (flag > $DEZHBAN_CONFIG > system
  path if present > built-in defaults), routed through loadConfig so every
  command gains it; `validate` now reports the resolved path.
- Config writer: internal/config gains Marshal + Save (Config → fileConfig,
  durations round-trip as "30s", 0644, vpn block emitted only when enabled),
  with Save↔Load round-trip tests.
- `dezhban config` — path/show/get/set/edit over a small dotted-key registry;
  writes to /etc print a sudo hint on EACCES.
- `dezhban setup` — interactive huh wizard: reuses netdetect tunnel detection
  (MultiSelect), endpoint auto-discover, the lockout-routing check, and a
  print-rules preview before saving; guarded against non-TTY.
- `dezhban completion bash|zsh|fish` — hand-written scripts (no dep).

Dependencies: add charmbracelet/huh (second direct dep, setup wizard only);
CLAUDE.md's dependency convention amended to record it. docs/usage.md,
docs/config.md, and the README updated for the new commands and discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Privileged commands no longer require the user to prepend sudo by hand.

- requireRoot now re-execs the whole invocation under sudo when not root
  (syscall.Exec, so signals/exit codes pass through), preserving $DEZHBAN_CONFIG.
- setup and config set/edit elevate only their config write (or the editor) so an
  interactive wizard never restarts.
- Gated to unix + an interactive TTY; opt out with --no-sudo or DEZHBAN_NO_SUDO=1.
  Windows keeps the clear "must run as root" error (UAC is a separate mechanism).
- Terminal detection uses charmbracelet/x/term.IsTerminal (already in the tree),
  not ModeCharDevice — /dev/null is a char device but not a TTY, so the old check
  would have wrongly tried to prompt for a sudo password in CI/pipes.

Docs and --help updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Behnam-RK Behnam-RK changed the title feat: macOS menubar GUI (Phase 8) with daemon state-file export feat: macOS menubar GUI (Phase 8) + config-management UX, docs restructure, sudo auto-elevation Jul 1, 2026
Behnam-RK and others added 3 commits July 2, 2026 00:00
…" page)

Commit the self-contained HTML reference (VPN guard primary vs country-blocklist
fallback: state machines, rulesets, decision guide) as a browser-openable page,
wrapped in a proper <!doctype html> document. modes.md links to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness:
- runner: non-blocking drop-oldest state publish + bounded shutdown flush so a
  hung state-file write can never stall enforcement or block Cleanup teardown.
- runner/state: surface firewall-action failures via a new `enforcementErr`
  snapshot field (incl. the VPN probe re-cut), so a failed block no longer
  publishes a healthy-looking "allow"; GUI shows a warning icon + menu line.
- config: `config` subcommands now honor `--config` (was silently ignored).
- elevate: pathWritable probes the nearest existing ancestor dir, so a write
  into a missing user dir no longer needlessly escalates to sudo and creates
  root-owned dirs in $HOME.

macOS GUI:
- set menu.autoenablesItems=false so disabled-item gating actually holds.
- run privileged actions off the main thread and surface failures via NSAlert.
- derive staleness from the daemon's pollIntervalSeconds (max(90s, 3x interval)).

Minor:
- reconcile dependency docs (three third-party modules) in CLAUDE.md + architecture.md.
- fish completion builds its subcommand list from the shared const.
- state.Write fsyncs before rename.

Adds runner tests (enforcement-err on block failure; publish stall doesn't block
enforcement + Cleanup still runs) and config --config tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestWriteIsWorldReadable asserts the state file mode is exactly 0644,
which is a POSIX concept. Windows has no Unix mode bits — os.Chmod only
honors the write bit and os.Stat reports 0666/0444 — so the check fails
on windows-latest (got 666 want 0644) while passing on macOS/Linux.

The 0644 world-readable contract only matters for the root-daemon /
unprivileged-reader split on Unix; Windows governs access via ACLs. Skip
the assertion there. Write() behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Behnam-RK Behnam-RK merged commit ee9cf35 into main Jul 2, 2026
5 checks passed
@Behnam-RK Behnam-RK deleted the feat/macos-menubar-gui branch July 2, 2026 10:11
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