Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
80dc5ba
feat(gui): the UI explains itself
Behnam-RK Aug 21, 2026
b58808d
fix(gui): keep the section anchor the CLI prints, and add the row bes…
Behnam-RK Aug 22, 2026
4e32060
test(help): make the slug pin able to fail, twice over
Behnam-RK Aug 22, 2026
8431cbc
fix(help): the key- prefix is not a collision guarantee, so stop clai…
Behnam-RK Aug 22, 2026
972b81e
fix(gui): drop the hover re-arm rather than patch it a third time
Behnam-RK Aug 22, 2026
016c49b
fix(help): an indented heading is still a heading
Behnam-RK Aug 22, 2026
6a810db
fix(help): tell a repeated key from two keys colliding on one anchor
Behnam-RK Aug 22, 2026
e97e67a
fix(help): only a Field-headed table documents config keys
Behnam-RK Aug 22, 2026
a656399
fix(gui): say what the caption actually rests on, and wrap switchMenu…
Behnam-RK Aug 22, 2026
a31d9b5
fix(gui): rank the caption's inputs instead of erasing one
Behnam-RK Aug 22, 2026
d5398fa
docs: a checklist step that cannot be run is not coverage
Behnam-RK Aug 22, 2026
0668575
fix(gui): bind focus to a control, not to a branch
Behnam-RK Aug 22, 2026
b4387f1
test(config): mirror the renderer's header rule instead of approximat…
Behnam-RK Aug 22, 2026
c36b95b
test(cli): pin the JSON field names the app decodes by name
Behnam-RK Aug 22, 2026
dcda5b7
fix(gui): a re-established tracking area is not the user aiming
Behnam-RK Aug 22, 2026
0c673cc
fix(gui): the explanations were wrong where it mattered most
Behnam-RK Aug 23, 2026
a52bab4
fix(gui): measure movement from when the keyboard took the caption
Behnam-RK Aug 23, 2026
96fb299
test(runner): pin the invariant, not the number of probe ticks
Behnam-RK Aug 23, 2026
739e2ac
fix(runner): a cancelled context ends the loop, it does not buy a rea…
Behnam-RK Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,77 @@ current as you land changes.

## [Unreleased]

### Changed

- **Contextual help lands on the key you asked about.** The **?** beside a
setting used to open one of four section anchors shared by every key in that
section; it now scrolls to that key's own row in the configuration reference.
The help renderer gives each documented key a row anchor, and each key's
anchor is derived from the key rather than hand-written, so the two cannot
drift — a key that loses its documentation row now fails the build by name.
The **?** button now says what it does on hover ("Open the documentation for
…"), which is the affordance a pointer user was missing — the key's own one-line
help is already on the control beside it, and visible under the toggles that
carry a caption.
- **Shorter action buttons, with one caption line under the row.** The Overview's
controls are now Block / Unblock / Switch VPN… / Pause / Guard down / Panic…,
with the window controls shortened to match — **Resume (m:ss left)** rather than
"Resume now", and one **Cancel** in place of "Cancel redial window" and "Cancel
VPN switch" (which window it closes is in the caption). The sentence each title
used to carry inline moved to a caption line beneath the row that follows the
pointer and the keyboard focus, and reads "Point at a button to see what it
does." when neither is on the row. Whichever of the two you used last wins, so
tabbing takes the caption from a resting pointer and moving the pointer takes it
back. The same sentence remains the tooltip and is also announced by VoiceOver as
the control's hint, and the caption wraps rather than truncating — its tail is
where the password expectation is stated, and that has to survive a narrow window.
The menubar's items are unchanged: a menu has no caption line to delegate to, and
a guided empty state's panic button keeps its full title for the same reason.
- **The advanced tunables table names its keys in full** (`vpn.advanced.redialBudget`
rather than `redialBudget`), matching every other table in the reference.

### Fixed

- **Shutting the daemon down no longer buys one last lift-and-probe.** When
FULL BLOCK is held and the tunnel-scoped provider pass cannot be built (no
provider addresses resolved), the recovery probe lifts the guard, looks up
the exit country, and re-cuts. The run loop could take one of those on the
way out: with a cancelled context and a pending geo tick both ready, the
select chose between them at random. Losing that toss meant a stop briefly
opened egress through the forbidden-country exit — to observe a country
nothing was left to act on, moments before teardown removed the rules
anyway. A cancelled context now ends the loop instead, the same as any
other path out.
- **A contextual help link stays on the row it landed on.** The Help pane spends
a deep link's anchor as soon as WebKit has scrolled to it, and the view update
that followed asked for the same page without the fragment — which was loaded
again, putting the reader back at the top of the page a fraction of a second
after arriving. The pane now treats a target that differs only by a dropped
fragment as the page already on screen. Links *between* anchors on one page
still navigate, and clicking the same search hit again after scrolling away
still scrolls back to it.
- **The caption no longer follows a button that moved under a resting pointer.**
When a switch window opens, Cancel replaces Pause beneath wherever the mouse
happens to be; that counted as the pointer aiming at something and took the
caption away from the keyboard's own focused button. It now asks whether the
mouse actually moved, in screen coordinates, so only a hand that went somewhere
counts. A control that arrives already holding keyboard focus also writes its
own sentence now, rather than leaving the outgoing button's on screen.
- **Unblock says what it will actually release.** The button is offered both
while egress is cut by a standing block and while the guard is holding a downed
tunnel, and it described both as releasing a manual block and resuming
monitoring. Whenever the tunnel is down — under the guard *or* under a full
block — it now warns that enforcement stops and traffic uses your real IP until
the VPN reconnects, which is what `vpn.autoArm` does; with the tunnel up it
says the full block is lifted and the guard re-blocks the exit if it is still
forbidden.
- **Panic explains itself to VoiceOver and to a tooltip.** Shortening its title
to "Panic…" left the pane's one destructive control announcing nothing but its
name, with its explanation in an adjacent label no pointer or VoiceOver user
reached from the button. It now carries that sentence as both tooltip and
accessibility hint, with the visible copy hidden from VoiceOver so it is not
read twice — the same arrangement the action row already used.

- **"Open minimized" now actually decides whether the window opens.** The app
used to infer a login launch from `NSApplication.launchIsDefaultUserInfoKey`,
which reported wrong in both directions — the window appeared at login with
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ Gatekeeper friction** and asks for your password exactly once. Details, the
Open the app and everything else happens by clicking:

- **Menubar dropdown** — the safety core. One glance tells you the posture
(e.g. "Guard — NL via Mullvad"); **Block now** / **Unblock**, the VPN switch
window with a live countdown, and **Panic**. These never require the main
window to be open.
(e.g. "Guard — NL via Mullvad"); the VPN switch window with a live countdown,
**Pause**, and **Panic**. These never require the main window to be open.
Manual **Block** / **Unblock** are not here — they live in Overview, since
anyone who wants to cut their own internet can turn off Wi-Fi.
- **Overview** — live status, the daily controls, and guided recovery: if the
service isn't installed or is stopped, there's an inline button for exactly
that, not an error message.
Expand Down
122 changes: 122 additions & 0 deletions cmd/dezhban/schemawire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package main

import (
"encoding/json"
"strings"
"testing"

"github.com/behnam-rk/dezhban/internal/config"
)

// TestSchemaWireNamesTheAppDecodes pins the JSON field names `config schema --json`
// emits, because the macOS app decodes them by name and fails silently if one moves.
//
// `ConfigTunable`'s CodingKeys name `docAnchor` and `docKeyAnchor`, and
// `docKeyAnchor` is decoded with `decodeIfPresent` so an older CLI degrades to the
// section anchor. That tolerance is what would make a rename invisible: rename the Go
// tag and every key silently loses its row anchor, the `?` goes back to landing on
// section headings, and nothing on either side fails — the Swift test builds its
// fixture by hand, so it would keep passing too.
//
// Asserted against `schemaEntry`, not `config.Tunable`: the wire shape is the CLI's
// wrapper, which embeds the Tunable and adds `preset`. Marshalling the Tunable alone
// omits that field, so a test written against it both misses a name the app decodes
// and fails for a reason that is not a defect. This is where the tag would be
// changed, so this is where the assertion belongs.
func TestSchemaWireNamesTheAppDecodes(t *testing.T) {
tunables := config.Tunables()
if len(tunables) == 0 {
t.Fatal("no tunables — this test is pinning nothing")
}

entries := make([]schemaEntry, len(tunables))
written := presetWritten()
for i, tun := range tunables {
entries[i] = schemaEntry{Tunable: tun, Preset: written[tun.Key]}
}

raw, err := json.Marshal(entries)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded []map[string]json.RawMessage
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}

// The names every tunable carries unconditionally.
required := []string{
"key", "label", "kind", "default", "disablable", "advanced", "preset",
"help", "docAnchor",
}
for i, obj := range decoded {
for _, name := range required {
if _, ok := obj[name]; !ok {
t.Errorf("%s: no %q field on the wire", tunables[i].Key, name)
}
}
}

// And the `omitempty` ones, asserted per tunable that actually has a value.
//
// Listing only the unconditional names and calling that "every CodingKey" was
// the first shape of this test, and it left the most consequential field
// unpinned: rename `restartReason` and `ConfigTunable.appliesLive` — which is
// `(restartReason ?? "").isEmpty` — returns true for every key, so Settings
// tells the user a restart-required key applies live. Nothing would have failed.
optional := map[string]func(config.Tunable) string{
"capKey": func(t config.Tunable) string { return t.CapKey },
"unit": func(t config.Tunable) string { return t.Unit },
"restartReason": func(t config.Tunable) string { return t.RestartReason },
}
covered := map[string]int{}
for i, obj := range decoded {
for name, value := range optional {
has := value(tunables[i]) != ""
_, onWire := obj[name]
switch {
case has && !onWire:
t.Errorf("%s: %q has a value but is missing from the wire", tunables[i].Key, name)
case !has && onWire:
t.Errorf("%s: %q is empty but present on the wire", tunables[i].Key, name)
case has:
covered[name]++
}
}
}
for name := range optional {
if covered[name] == 0 {
t.Errorf("no tunable carries %q, so its wire name is not being pinned", name)
}
}

// docKeyAnchor is omitempty, so it is absent exactly for the keys documented in
// prose — and present, naming a row on the reference page, for the rest.
withRow := 0
for i, obj := range decoded {
key := tunables[i].Key
frag, present := obj["docKeyAnchor"]
if tunables[i].DocKeyAnchor == "" {
if present {
t.Errorf("%s: docKeyAnchor should be omitted when empty, got %s", key, frag)
}
continue
}
if !present {
t.Errorf("%s: docKeyAnchor missing from the wire", key)
continue
}
var s string
if err := json.Unmarshal(frag, &s); err != nil {
t.Errorf("%s: docKeyAnchor is not a string: %v", key, err)
continue
}
if !strings.Contains(s, "#key-") {
t.Errorf("%s: docKeyAnchor is %q, which does not name a key row", key, s)
}
withRow++
}
if withRow == 0 {
t.Fatal("no tunable carried a row anchor — the app would have nothing to deep-link to")
}
}
Loading