From 638c82eb44afa58cdfcb7cc6b8a86c348be9d5f3 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 18:49:03 +0330 Subject: [PATCH 01/27] feat(config): declare every tunable as data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/config/schema.go: a Tunable per settable key carrying the label, kind, cap, unit, off-availability, help line, and doc anchor a surface needs to render a control for it without hardcoding anything of its own. Tunables(), TunableByKey(), TunableKeys(). The reason this exists is drift that had already happened. Every default was written down four times — the const block in config.go, the macOS app's placeholder hints, the tables in docs/usage/config.md, and the example configs — and they had come apart: the app advertised a 30s endpoint refresh and a 5s tunnel watch while the shipped defaults were 1m and 1s. Anyone reading the settings pane was being told the wrong thing. So a Tunable deliberately does NOT carry a hand-written default. Defaults() derives them from KeyValues() of a normalized Default() config, which IS the definition of "what you get if you set nothing". A metadata table that restated the numbers would just be a fifth copy. RestartReason is derived the same way, from restartReasonFor, so a key cannot read as live-appliable in the settings pane and restart-required in the reload report. Three defaults were still magic numbers inside Normalize — the endpoint refresh, the tunnel watch, and the starting blocklist — and those are exactly the two the app got wrong. Promoted to defaultEndpointRefresh, defaultTunnelWatch, and defaultBlockedCountries() so the const block is now genuinely the only place a default number is written down. Pure refactor; same values. CapKey names the tunable that bounds another rather than a number, because the ceiling is itself settable: a slider's top has to be read from the live config. A test asserts caps are non-transitive, since the three windows have separate caps precisely so widening one cannot widen another. Tests pin the table against the code rather than against itself: the Tunable set and KeyValues must be exactly equal in both directions (the same shape as the existing every-key-in-exactly-one-map test), the declared defaults must equal a normalized Default(), and every key marked Disablable must really survive Normalize as the negative sentinel — the promise that lets a surface offer an explicit "Off" is only honest if the setting is not silently discarded. No surface reads this yet; that is the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- internal/config/config.go | 20 +- internal/config/schema.go | 451 +++++++++++++++++++++++++++++++++ internal/config/schema_test.go | 214 ++++++++++++++++ 3 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 internal/config/schema.go create mode 100644 internal/config/schema_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 983874e..6333ed1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -932,7 +932,7 @@ func Normalize(cfg *Config) { // defaults review). An explicit "blockedCountries": [] is a deliberate // choice to block nothing and must stay that way — see the len>0 branch // below, which never fires for a genuinely empty, non-nil slice. - cfg.BlockedCountries = []string{"IR", "RU", "KP"} + cfg.BlockedCountries = defaultBlockedCountries() case len(cfg.BlockedCountries) > 0: seen := make(map[string]bool, len(cfg.BlockedCountries)) out := make([]string, 0, len(cfg.BlockedCountries)) @@ -963,13 +963,13 @@ func Normalize(cfg *Config) { // VPN guard cadence defaults. Set unconditionally — these are only read in // VPN mode, but defaulting here keeps Validate and the runner simple. if cfg.VPN.EndpointRefresh <= 0 { - cfg.VPN.EndpointRefresh = time.Minute + cfg.VPN.EndpointRefresh = defaultEndpointRefresh } if cfg.VPN.EndpointGrace <= 0 { cfg.VPN.EndpointGrace = defaultEndpointGrace } if cfg.VPN.TunnelWatch <= 0 { - cfg.VPN.TunnelWatch = 1 * time.Second + cfg.VPN.TunnelWatch = defaultTunnelWatch } if cfg.VPN.SwitchWindow == 0 { cfg.VPN.SwitchWindow = defaultSwitchWindow @@ -1024,8 +1024,19 @@ func normalizeAdvanced(a *Advanced) { } } +// defaultBlockedCountries is the recommended starting blocklist, returned fresh +// each call because Normalize hands it straight to the caller's config, which is +// then free to sort, extend, or de-duplicate it in place. +func defaultBlockedCountries() []string { return []string{"IR", "RU", "KP"} } + // Switch-window / learning defaults. These are the recommended values; the // vpn.advanced config block overrides any of them. +// +// This block is the ONLY place a default number is written down. Every surface +// that shows a default — the macOS app's hints and slider bounds, `dezhban config +// schema`, the tables in docs/usage/config.md — derives it from here via +// config.Defaults(), and a test fails the build when the docs or the example +// configs disagree. See schema.go for why. const ( defaultSwitchWindow = 5 * time.Second // 2026-07-22 defaults review: windows exist to be closed fast defaultSwitchWindowMax = 3 * time.Minute @@ -1037,6 +1048,9 @@ const ( defaultPromoteAfterRefreshes = 3 defaultEndpointWarnThreshold = 256 + defaultEndpointRefresh = 1 * time.Minute + defaultTunnelWatch = 1 * time.Second // how fast a tunnel drop is noticed + defaultRedialWindow = 30 * time.Second defaultRedialWindowMax = 10 * time.Minute defaultRedialMinUptime = 15 * time.Second diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..14f675e --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,451 @@ +package config + +import "sort" + +// This file exists because a default stated in more than one place is a default +// that will drift — and this one already had. Before it, every tunable's default +// was written down four times: the constants below, the macOS app's placeholder +// hints, the tables in docs/usage/config.md, and the example configs. The app was +// advertising a 30s endpoint refresh and a 5s tunnel watch while the shipped +// defaults were 1m and 1s, so anyone reading the settings pane was being told the +// wrong thing. +// +// The fix is to make defaults data. Every surface — the GUI's hints and slider +// bounds, `dezhban config schema`, the CLI's help — reads the table below instead +// of restating it, and a test fails the build when the docs or the example configs +// disagree with it. +// +// Crucially, a Tunable does NOT carry a hand-written default value. Defaults are +// derived from a normalized Default() config (see Defaults), so the only place a +// default number is written down stays the const block in config.go. A metadata +// table that restated them would just be a fifth copy. + +// Kind classifies a tunable's value shape, so a surface can pick a control for it +// without knowing the key. It describes the *shape*, not the meaning: two +// durations may mean very different things but both get a duration control. +type Kind string + +const ( + KindDuration Kind = "duration" + KindBool Kind = "bool" + KindInt Kind = "int" + KindList Kind = "list" // comma-separated, order-significant + KindString Kind = "string" // free text with no list semantics +) + +// A Tunable describes one settable config key well enough that a surface can +// render a control for it, bound it, explain it, and link to its documentation +// without hardcoding anything of its own. +type Tunable struct { + // Key is the dotted name `dezhban config set` accepts. It is the identity: + // every other table in the codebase is keyed by the same string. + Key string `json:"key"` + + // Label is the human name for the concept, in the vocabulary of + // docs/concepts/glossary.md. It never contains the config key — "Serialised + // forms are not a UI", and neither are JSON keys in a label. + Label string `json:"label"` + + Kind Kind `json:"kind"` + + // Default is the value this key takes when the config does not set it, + // rendered exactly as KeyValues renders it. Filled by Tunables from a + // normalized Default() — never written by hand. + Default string `json:"default"` + + // CapKey names the tunable that bounds this one, or "" when nothing does. + // It is a key rather than a number because the ceiling is itself settable: + // a slider's top must be read from the live config, not from a constant. + // + // Deliberately NOT transitive and deliberately not shared — the three windows + // have their own caps precisely so that widening one cannot widen another + // (see CLAUDE.md's window-trigger invariant). + CapKey string `json:"capKey,omitempty"` + + // Unit names what an int counts, for surfaces that want to write "16 + // endpoints" rather than "16". Empty for every other kind: a duration's unit + // is carried by its own Go duration syntax, and bools and lists count nothing. + Unit string `json:"unit,omitempty"` + + // Disablable reports whether "0" is an explicit, persisted opt-out for this + // key rather than "reset me to the default". Only the keys whose setters + // install the negative Disabled sentinel may set this — for every other + // duration, Normalize coerces a plain 0 back to the default, so offering an + // "Off" choice would silently discard the user's decision. + Disablable bool `json:"disablable"` + + // Advanced marks the `vpn.advanced.*` knobs, which surfaces put behind a + // disclosure with the "touch only if you know why" warning. + Advanced bool `json:"advanced"` + + // Help is one line explaining what the key does and what it costs. + Help string `json:"help"` + + // DocAnchor points at the section of the bundled documentation that covers + // this key, as "#". Section-level rather than per-key because + // that is the granularity docs/usage/config.md actually has; a test asserts + // every anchor resolves in the generated help bundle. + DocAnchor string `json:"docAnchor"` + + // RestartReason is why a running daemon cannot adopt this key in place, or "" + // when it can. Derived from restartReasonFor — never restated here, so a key + // cannot claim to be live in one table and restart-required in another. + RestartReason string `json:"restartReason,omitempty"` +} + +// LiveAppliable reports whether a running daemon adopts this key in place. +func (t Tunable) LiveAppliable() bool { return t.RestartReason == "" } + +// Doc anchors. Keys are documented by section, so these are the four sections of +// docs/usage/config.md that between them cover every key. +const ( + anchorFields = "usage/config.md#fields" + anchorControl = "usage/config.md#control-block" + anchorVPN = "usage/config.md#vpn-block" + anchorAdvanced = "usage/config.md#advanced-tunables-vpnadvanced" +) + +// tunables is the declared table, in the order surfaces should present it: +// what the guard blocks, then which tunnel it trusts, then how it behaves when +// that tunnel comes and goes, then the control socket, then the advanced knobs. +// +// Default is left empty here on purpose — Tunables fills it. See the file comment. +var tunables = []Tunable{ + { + Key: "blockedCountries", + Label: "Blocked countries", + Kind: KindList, + Help: "Exit countries the guard refuses. If your VPN surfaces in one of these, everything is cut until it moves.", + DocAnchor: anchorFields, + }, + { + Key: "pollInterval", + Label: "Exit country check interval", + Kind: KindDuration, + Help: "How often the current VPN exit's country is checked.", + DocAnchor: anchorFields, + }, + { + Key: "hysteresis", + Label: "Agreeing readings before a flip", + Kind: KindInt, + Unit: "readings", + Help: "How many consecutive agreeing readings it takes to change posture. Higher is slower to react but harder to fool with one bad lookup.", + DocAnchor: anchorFields, + }, + { + Key: "providers", + Label: "Exit country lookup services", + Kind: KindList, + Help: "Tried in order, so the first reachable one absorbs nearly all the traffic.", + DocAnchor: anchorFields, + }, + { + Key: "providerQuorum", + Label: "Require two services to agree", + Kind: KindBool, + Help: "Confirms each reading against a second service before it counts. Safer against one wrong answer; twice the lookups.", + DocAnchor: anchorFields, + }, + { + Key: "logLevel", + Label: "Log level", + Kind: KindString, + Help: "How much the daemon writes to its log: debug, info, warn, or error.", + DocAnchor: anchorFields, + }, + + { + Key: "vpn.tunnelInterfaces", + Label: "Your VPN tunnel", + Kind: KindList, + Help: "Tunnels named here are pinned: the guard trusts them and never prunes them automatically. Leave empty to rely on autodetection.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpoints", + Label: "VPN server addresses", + Kind: KindList, + Help: "The addresses your VPN client dials. The guard keeps these reachable on the physical link so a dropped tunnel can redial without any relaxation.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoDetect", + Label: "Find my VPN tunnel automatically", + Kind: KindBool, + Help: "Watches for tunnel interfaces appearing and disappearing instead of relying only on the pinned list.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoDiscoverEndpoints", + Label: "Find the VPN server address automatically", + Kind: KindBool, + Help: "Learns the address your VPN client is actually talking to, so a redial works without you typing it in.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoArm", + Label: "Arm the guard when a VPN connects", + Kind: KindBool, + Help: "With no VPN connected dezhban idles in standby and arms the moment a tunnel appears. It never disarms on a drop — that is the kill switch.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.armAtBoot", + Label: "Arm the guard at boot", + Kind: KindBool, + Help: "On a host where a tunnel has been up at least once, a reboot stays cut until the VPN redials rather than opening for however long that takes.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.allowLocalNetwork", + Label: "Keep local devices reachable", + Kind: KindBool, + Help: "Printers, NAS, your router's admin page, AirPlay and local dev servers keep working. Local destinations only, so nothing on the internet is opened — but on untrusted Wi-Fi it also lets that network reach you.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.allowPhysicalDNS", + Label: "Allow DNS on the physical link", + Kind: KindBool, + Help: "Lets name lookups use your ISP's resolver while the tunnel is down. Convenient, but your ISP sees what you look up.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.switchWindow", + Label: "Switch window", + Kind: KindDuration, + CapKey: "vpn.advanced.switchWindowMax", + Disablable: true, + Help: "How long the guard relaxes when you ask to switch VPN by hand. Off means nothing you type can relax it.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.redialWindow", + Label: "Redial window", + Kind: KindDuration, + CapKey: "vpn.advanced.redialWindowMax", + Disablable: true, + Help: "How long the guard relaxes by itself when a healthy tunnel drops, so your VPN can redial. Your real IP may be exposed for that long. Off is the strict, zero-leak choice.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.pauseMax", + Label: "Longest pause", + Kind: KindDuration, + Disablable: true, + Help: "The most you can pause the guard for in one go, when you deliberately want your real IP. Off removes pausing entirely.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpointRefresh", + Label: "VPN server address refresh", + Kind: KindDuration, + Help: "How often a VPN server hostname is re-resolved, so a rotated address is noticed.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpointGrace", + Label: "VPN server address grace", + Kind: KindDuration, + Help: "How long a discovered VPN server stays reachable after its connection disappears, so a dropped VPN can redial the same server.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.tunnelWatch", + Label: "Tunnel check interval", + Kind: KindDuration, + Help: "How often the tunnel is checked for coming up or going down. This is how fast a drop is noticed.", + DocAnchor: anchorVPN, + }, + + { + Key: "control.enabled", + Label: "Control socket", + Kind: KindBool, + Help: "Lets an authorised local client ask the running daemon to act, instead of every command needing root.", + DocAnchor: anchorControl, + }, + { + Key: "control.socket", + Label: "Control socket path", + Kind: KindString, + Help: "Where the control socket is bound. Empty means the daemon picks the path under its own state directory.", + DocAnchor: anchorControl, + }, + { + Key: "control.group", + Label: "Control socket group", + Kind: KindString, + Help: "The group allowed to talk to the socket. Membership in it is the authorisation.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowSwitchOps", + Label: "Allow switch windows over the socket", + Kind: KindBool, + Help: "Lets opening and cancelling a switch window go through the daemon. Off makes those root-only again.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowPauseOps", + Label: "Allow pause over the socket", + Kind: KindBool, + Help: "Lets pause and resume go through the daemon. Independent of switch windows: turning one off leaves the other alone.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowConfigOps", + Label: "Allow settings changes over the socket", + Kind: KindBool, + Help: "Lets an enrolled, token-holding client change settings without root. The token gate still applies.", + DocAnchor: anchorControl, + }, + + { + Key: "vpn.advanced.switchWindowMax", + Label: "Switch window cap", + Kind: KindDuration, + Advanced: true, + Help: "The ceiling on a manual switch window. Its own cap on purpose — raising it must never widen the redial window or a pause.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.redialWindowMax", + Label: "Redial window cap", + Kind: KindDuration, + Advanced: true, + Help: "The ceiling on an automatic redial window, and so the most exposure one dropped tunnel can cause.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.redialMinUptime", + Label: "Redial anti-flap uptime", + Kind: KindDuration, + Advanced: true, + Disablable: true, + Help: "A tunnel that was up for less than this gets no automatic window, so a flapping VPN cannot chain windows into standing exposure. Off removes the gate.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.commandFreshness", + Label: "Command freshness", + Kind: KindDuration, + Advanced: true, + Help: "How recent a control command must be to be acted on — the replay guard.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowDiscoveryInterval", + Label: "Window discovery interval", + Kind: KindDuration, + Advanced: true, + Help: "How often a new VPN server is looked for while a window is open.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.tunnelPruneAfter", + Label: "Tunnel prune delay", + Kind: KindDuration, + Advanced: true, + Help: "How long a detected tunnel must be gone before it is dropped from the trusted set. Pinned tunnels are never pruned.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.learnedEndpointTTL", + Label: "Learned address lifetime", + Kind: KindDuration, + Advanced: true, + Help: "How long an unused learned VPN server address is kept.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.learnedMaxPerProfile", + Label: "Learned addresses per profile", + Kind: KindInt, + Unit: "addresses", + Advanced: true, + Help: "How many learned VPN server addresses are kept for each profile before the oldest is forgotten.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.promoteAfterRefreshes", + Label: "Sightings before an address is learned", + Kind: KindInt, + Unit: "sightings", + Advanced: true, + Help: "Consecutive sightings before a discovered VPN server address is learned under a normal guard.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.endpointWarnThreshold", + Label: "Address-bloat warning threshold", + Kind: KindInt, + Unit: "addresses", + Advanced: true, + Help: "How many resolved VPN server addresses it takes before dezhban warns that the set has grown unreasonably.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowProtocols", + Label: "Window protocols", + Kind: KindList, + Advanced: true, + Help: "Restricts an open window to these protocols instead of allowing all outbound traffic.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowPorts", + Label: "Window ports", + Kind: KindList, + Advanced: true, + Help: "Restricts an open window to these ports instead of allowing all outbound traffic.", + DocAnchor: anchorAdvanced, + }, +} + +// Defaults renders every key's default exactly as KeyValues renders a live value, +// so the two are directly comparable. It is derived rather than declared: a +// normalized Default() IS the definition of "what you get if you set nothing", +// and asking it beats maintaining a second copy that can disagree with it. +func Defaults() map[string]string { + c := Default() + Normalize(&c) + return KeyValues(&c) +} + +// Tunables returns the declared table with Default and RestartReason filled in +// from the code that actually decides them. Callers get a fresh slice, so a +// surface cannot mutate the table for everyone else. +func Tunables() []Tunable { + defaults := Defaults() + out := make([]Tunable, len(tunables)) + for i, t := range tunables { + t.Default = defaults[t.Key] + t.RestartReason = restartReasonFor(t.Key) + out[i] = t + } + return out +} + +// TunableByKey looks one key up. The bool is false for a key that is not +// settable, which callers should treat as "unknown key", not "no metadata". +func TunableByKey(key string) (Tunable, bool) { + for _, t := range Tunables() { + if t.Key == key { + return t, true + } + } + return Tunable{}, false +} + +// TunableKeys lists every settable key, sorted. Handy for tests and for CLI help +// that wants a stable order rather than the presentation order. +func TunableKeys() []string { + out := make([]string, 0, len(tunables)) + for _, t := range tunables { + out = append(out, t.Key) + } + sort.Strings(out) + return out +} diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go new file mode 100644 index 0000000..e47bfbc --- /dev/null +++ b/internal/config/schema_test.go @@ -0,0 +1,214 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// The point of these tests is that the declared table cannot quietly fall out of +// step with the code. A metadata table nobody checks is just a fifth copy of the +// defaults with extra steps. + +// TestTunablesCoverEverySettableKey is the load-bearing one: the declared table +// and the settable-key set must be exactly equal, in both directions. A new key +// added to KeyValues without a Tunable would reach the config file with no label, +// no help, and no default for any surface to show; a Tunable for a key KeyValues +// does not carry would describe something nobody can set. +func TestTunablesCoverEverySettableKey(t *testing.T) { + c := Default() + Normalize(&c) + settable := KeyValues(&c) + + declared := map[string]bool{} + for _, tun := range Tunables() { + if declared[tun.Key] { + t.Errorf("tunable %q is declared twice", tun.Key) + } + declared[tun.Key] = true + } + + for key := range settable { + if !declared[key] { + t.Errorf("settable key %q has no Tunable — add one to schema.go", key) + } + } + for key := range declared { + if _, ok := settable[key]; !ok { + t.Errorf("Tunable %q is not a settable key — remove it or add it to KeyValues", key) + } + } +} + +// TestTunableDefaultsMatchANormalizedDefaultConfig pins the derivation itself. +// If Tunables ever grew a hand-written Default, this is what would catch it +// disagreeing with what you actually get by setting nothing. +func TestTunableDefaultsMatchANormalizedDefaultConfig(t *testing.T) { + c := Default() + Normalize(&c) + want := KeyValues(&c) + + for _, tun := range Tunables() { + if got := tun.Default; got != want[tun.Key] { + t.Errorf("%s: declared default %q, but a normalized Default() yields %q", + tun.Key, got, want[tun.Key]) + } + } +} + +// TestTunableRestartClassificationMatchesReload keeps one classification, not two. +// Telling a user in the settings pane that a key applies live while the reload +// path reports it as restart-required is the same failure as claiming a change +// applied when the old value is still being enforced. +func TestTunableRestartClassificationMatchesReload(t *testing.T) { + for _, tun := range Tunables() { + want := restartReasonFor(tun.Key) + if tun.RestartReason != want { + t.Errorf("%s: RestartReason %q, want %q", tun.Key, tun.RestartReason, want) + } + if tun.LiveAppliable() != (want == "") { + t.Errorf("%s: LiveAppliable() disagrees with its own RestartReason", tun.Key) + } + } +} + +// TestTunableCapKeysResolve — a cap is a key rather than a number precisely so a +// surface can read the live ceiling, which only works if the key exists. +func TestTunableCapKeysResolve(t *testing.T) { + for _, tun := range Tunables() { + if tun.CapKey == "" { + continue + } + ceiling, ok := TunableByKey(tun.CapKey) + if !ok { + t.Errorf("%s: CapKey %q is not a settable key", tun.Key, tun.CapKey) + continue + } + if ceiling.Kind != tun.Kind { + t.Errorf("%s is %s but its cap %s is %s — a cap must be comparable to what it bounds", + tun.Key, tun.Kind, ceiling.Key, ceiling.Kind) + } + if ceiling.CapKey != "" { + t.Errorf("%s: cap %s is itself capped by %s; caps are deliberately not transitive", + tun.Key, ceiling.Key, ceiling.CapKey) + } + } +} + +// TestDisablableKeysSurviveNormalize is the one that protects the worst bug this +// tool can have. Disablable promises a surface it may offer an explicit "Off"; +// that promise is only honest if the negative sentinel actually survives +// Normalize instead of being coerced back to the default. +func TestDisablableKeysSurviveNormalize(t *testing.T) { + // Field accessors kept test-local: the production key→field table lives in + // cmd/dezhban's configFields, and internal/config must not depend on it. + fields := map[string]func(*Config) *time.Duration{ + "vpn.switchWindow": func(c *Config) *time.Duration { return &c.VPN.SwitchWindow }, + "vpn.redialWindow": func(c *Config) *time.Duration { return &c.VPN.RedialWindow }, + "vpn.pauseMax": func(c *Config) *time.Duration { return &c.VPN.PauseMax }, + "vpn.advanced.redialMinUptime": func(c *Config) *time.Duration { return &c.VPN.Advanced.RedialMinUptime }, + } + + var disablable []string + for _, tun := range Tunables() { + if tun.Disablable { + disablable = append(disablable, tun.Key) + } + } + if len(disablable) != len(fields) { + t.Fatalf("Disablable keys are %v, but this test knows how to set %d of them — "+ + "a new disablable key needs an entry here", disablable, len(fields)) + } + + for _, key := range disablable { + get, ok := fields[key] + if !ok { + t.Errorf("%s is marked Disablable but this test cannot reach its field", key) + continue + } + c := Default() + *get(&c) = Disabled + Normalize(&c) + if got := *get(&c); got != Disabled { + t.Errorf("%s: Normalize turned the explicit off-sentinel into %v — "+ + "a security setting was accepted and silently discarded", key, got) + } + if got := KeyValues(&c)[key]; got != "off" { + t.Errorf("%s: reads as %q once disabled, want \"off\"", key, got) + } + } +} + +// TestNonDisablableDurationsCoerceZeroToTheirDefault is the mirror image: a key +// NOT marked Disablable must be one where Normalize really does restore the +// default, so no surface offers an "Off" that would silently do nothing. +func TestNonDisablableDurationsCoerceZeroToTheirDefault(t *testing.T) { + for _, tun := range Tunables() { + if tun.Kind != KindDuration || tun.Disablable { + continue + } + if tun.Default == "off" { + t.Errorf("%s is not Disablable yet defaults to off", tun.Key) + } + } +} + +// TestTunableMetadataIsComplete — every field a surface relies on is populated, +// and the ones that are meaningful for only one Kind stay empty elsewhere. +func TestTunableMetadataIsComplete(t *testing.T) { + for _, tun := range Tunables() { + if tun.Label == "" { + t.Errorf("%s: no Label", tun.Key) + } + if tun.Help == "" { + t.Errorf("%s: no Help", tun.Key) + } + if tun.Kind == "" { + t.Errorf("%s: no Kind", tun.Key) + } + if tun.DocAnchor == "" { + t.Errorf("%s: no DocAnchor", tun.Key) + } else if !strings.Contains(tun.DocAnchor, "#") { + t.Errorf("%s: DocAnchor %q is not of the form page#anchor", tun.Key, tun.DocAnchor) + } + if tun.Unit != "" && tun.Kind != KindInt { + t.Errorf("%s: Unit %q on a %s — units name what an int counts", tun.Key, tun.Unit, tun.Kind) + } + if tun.Disablable && tun.Kind != KindDuration { + t.Errorf("%s: only durations carry the off-sentinel", tun.Key) + } + // The label is what a user reads; the key is the machine's name for it. + // Mixing them is the "Serialised forms are not a UI" entry in the glossary. + if strings.Contains(tun.Label, tun.Key) { + t.Errorf("%s: Label %q restates the config key", tun.Key, tun.Label) + } + if wantAdvanced := strings.HasPrefix(tun.Key, "vpn.advanced."); tun.Advanced != wantAdvanced { + t.Errorf("%s: Advanced=%v, want %v (it is set by the key prefix)", + tun.Key, tun.Advanced, wantAdvanced) + } + } +} + +// TestTunableKeysAreSorted — TunableKeys promises a stable order to CLI help and +// tests, which is only useful if it is actually stable. +func TestTunableKeysAreSorted(t *testing.T) { + keys := TunableKeys() + if len(keys) != len(tunables) { + t.Fatalf("TunableKeys returned %d keys, want %d", len(keys), len(tunables)) + } + for i := 1; i < len(keys); i++ { + if keys[i-1] >= keys[i] { + t.Fatalf("TunableKeys is not sorted: %q before %q", keys[i-1], keys[i]) + } + } +} + +// TestTunablesReturnsACopy — a surface that mutates what it is handed must not +// change what the next caller sees. +func TestTunablesReturnsACopy(t *testing.T) { + first := Tunables() + first[0].Label = "mutated" + if Tunables()[0].Label == "mutated" { + t.Fatal("Tunables handed out the shared table; callers can corrupt it for everyone") + } +} From 6de867301b514956fa22de4a0ddf2210dd54de1a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 18:57:27 +0330 Subject: [PATCH 02/27] feat(cli): config schema, so a surface can stop hardcoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read-only `dezhban config schema [--json]`: for every settable key, its label, default, cap, unit, whether "0" turns it off, whether a preset writes it, whether the daemon adopts it live, and which section of docs/usage/config.md covers it. It deliberately loads no config file. The schema is what the keys ARE, not what this host has set, so it answers identically on a machine that has never been configured — which is exactly when a first-run wizard needs to ask what a key means. The JSON shape embeds config.Tunable rather than restating its eleven fields in a parallel struct. That is the opposite of the convention changeJSON follows, and on purpose: config.Change has no json tags, so it would serialize capitalized, whereas Tunable was designed as the wire shape for surfaces and already carries the lowerCamelCase tags the app decodes. A second struct here would be precisely the extra copy this phase exists to delete. A test pins that the embedding flattens. The "Keys" section of `config --help` was itself a hand-maintained copy of the settable-key set. It is now generated from config.TunableKeys(), so a new key appears in the help by existing rather than by someone remembering. Tests assert the two key tables cannot diverge: configFields (what `config set` accepts) and Tunables (what surfaces advertise) must be exactly equal in both directions — a key in one and not the other is either a setting nobody can discover or one the app offers and the CLI rejects. Another cross-checks the two renderings of a value, since `config get` uses configFields.get while the schema's defaults come from KeyValues; on a default config they must agree exactly, or the app would show one default as a hint while `config get` reported another. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 18 ++++ CLAUDE.md | 2 +- cmd/dezhban/config_cmd.go | 145 +++++++++++++++++++++++++--- cmd/dezhban/config_schema_test.go | 152 ++++++++++++++++++++++++++++++ docs/usage/cli.md | 22 ++++- 5 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 cmd/dezhban/config_schema_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6473d54..35d7484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ current as you land changes. ## [Unreleased] +### Added + +- **`dezhban config schema` describes every setting**, so you can ask what a key + is instead of reading source. For each one it prints the label, its default, + what bounds it, whether `"0"` turns it off, whether a strictness preset writes + it, whether a running daemon adopts it without a restart, and where it is + documented. `--json` for tools; read-only, no root, and it reads no config file + — the schema is what the keys *are*, not what this host has set, so it answers + the same on a machine that has never been configured. + + Behind it, defaults are now data rather than prose. They used to be written + down in four places — the Go constants, the macOS app's placeholder hints, the + documentation tables, and the example configs — and they had already come + apart: the app advertised a 30s endpoint refresh and a 5s tunnel watch while + the shipped defaults were 1m and 1s. Every surface now derives them from one + table, which itself derives the values from the shipped defaults, so the same + drift cannot recur. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/CLAUDE.md b/CLAUDE.md index 3a49a9e..e14b8c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ over its control socket first (gated by `control.allowSwitchOps`/ `control.allowPauseOps` respectively) and only fall back to the root-owned command file when no daemon answers. Everything else — `status`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, `vpn list`/`show`, -`config show`/`path`/`preset list`/`preset show`/`preset diff`, `token status`, +`config show`/`path`/`schema`/`preset list`/`preset show`/`preset diff`, `token status`, `completion`, `upgrade check`, `version`, `help` — is read-only: no root, no firewall effects. Full reference: [docs/usage/cli.md](docs/usage/cli.md); the upgrade design in full: diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index 3fa3f35..865f1c2 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -19,7 +19,14 @@ import ( "github.com/behnam-rk/dezhban/internal/control" ) -const configUsage = `usage: dezhban config +// configUsage is assembled rather than written out, because its "Keys" section +// used to be a hand-maintained copy of the settable-key set — one more list to +// forget. It is now generated from config.Tunables(), the same table `config +// schema` prints and the app reads, so a new key appears in the help by existing. +var configUsage = configUsageProse + wrapKeys(config.TunableKeys()) + ` + (VPN profiles are managed with 'dezhban vpn add/remove', not 'config set')` + +const configUsageProse = `usage: dezhban config Subcommands: path Print the resolved config path @@ -39,6 +46,8 @@ Subcommands: preset apply Write a preset's values, validate, and save — same path as 'set', so it applies live where it can and reports what needs a restart + schema Describe every settable key: its default, cap, unit, whether + it can be turned off, and whether it applies live edit Open the config in $EDITOR (created from defaults if missing) Flags: @@ -47,22 +56,126 @@ Flags: Falls back to a privileged write if no daemon answers; a daemon that REFUSES is reported, never routed around. See 'dezhban token'. - --json ('preset list'/'preset show'/'preset diff' only) print - machine-readable JSON instead of prose + --json ('preset list'/'preset show'/'preset diff'/'schema' only) + print machine-readable JSON instead of prose Keys (dotted; list values are comma-separated): - pollInterval blockedCountries hysteresis providers providerQuorum logLevel - vpn.tunnelInterfaces vpn.endpoints vpn.autoDetect - vpn.autoDiscoverEndpoints vpn.allowPhysicalDNS vpn.allowLocalNetwork - vpn.autoArm vpn.armAtBoot vpn.switchWindow - vpn.redialWindow vpn.pauseMax vpn.endpointRefresh vpn.endpointGrace vpn.tunnelWatch - control.enabled control.socket control.group control.allowSwitchOps - control.allowPauseOps control.allowConfigOps - vpn.advanced.switchWindowMax vpn.advanced.redialWindowMax vpn.advanced.redialMinUptime - vpn.advanced.commandFreshness vpn.advanced.windowDiscoveryInterval vpn.advanced.tunnelPruneAfter - vpn.advanced.learnedEndpointTTL vpn.advanced.learnedMaxPerProfile vpn.advanced.promoteAfterRefreshes - vpn.advanced.endpointWarnThreshold vpn.advanced.windowProtocols vpn.advanced.windowPorts - (VPN profiles are managed with 'dezhban vpn add/remove', not 'config set')` +` + +// wrapKeys renders the settable-key list for the usage text: two-space indent, +// space-separated, wrapped near 78 columns so it reads in a standard terminal. +func wrapKeys(keys []string) string { + const width = 78 + var b strings.Builder + line := " " + for _, k := range keys { + if len(line)+1+len(k) > width { + b.WriteString(line) + b.WriteString("\n") + line = " " + } + line += " " + k + } + b.WriteString(line) + return b.String() +} + +// schemaEntry is what `config schema --json` emits: the declared Tunable +// verbatim (it already carries the lowerCamelCase tags every surface decodes) +// plus whether the key is one a strictness preset writes. +// +// Embedding rather than restating the eleven fields is deliberate — a parallel +// struct here would be exactly the kind of second copy this whole phase exists +// to delete. +type schemaEntry struct { + config.Tunable + // Preset reports that applying a strictness preset overwrites this key, so a + // surface can warn that changing it by hand drifts the config to Custom. + Preset bool `json:"preset"` +} + +// presetWritten reports which keys a strictness preset sets. Taken from the +// preset definitions themselves rather than a list here, so the two cannot +// disagree; every preset writes the same key set, so the first one answers for all. +func presetWritten() map[string]bool { + out := map[string]bool{} + for _, p := range config.Presets() { + for k := range p.Values { + out[k] = true + } + } + return out +} + +// configSchema describes every settable key. It deliberately reads no config +// file: the schema is what the keys ARE, not what this host has set, so it +// answers identically on a machine with no config yet — which is exactly when a +// first-run wizard needs it. +func configSchema(args []string) int { + args, jsonOut := stripJSONFlag(args) + if len(args) != 0 { + fmt.Fprintf(os.Stderr, "config schema takes no arguments (got %q)\n", strings.Join(args, " ")) + return 2 + } + + written := presetWritten() + tunables := config.Tunables() + + if jsonOut { + out := make([]schemaEntry, 0, len(tunables)) + for _, t := range tunables { + out = append(out, schemaEntry{Tunable: t, Preset: written[t.Key]}) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "config schema json:", err) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + for _, t := range tunables { + fmt.Printf("%s\n", t.Key) + fmt.Printf(" %s (%s)\n", t.Label, t.Kind) + fmt.Printf(" %s\n", t.Help) + + facts := []string{"default " + quoteEmpty(t.Default)} + if t.Unit != "" { + facts = append(facts, "in "+t.Unit) + } + if t.CapKey != "" { + facts = append(facts, "capped by "+t.CapKey) + } + if t.Disablable { + facts = append(facts, `"0" turns it off`) + } + if t.Advanced { + facts = append(facts, "advanced") + } + if written[t.Key] { + facts = append(facts, "set by presets") + } + fmt.Printf(" %s\n", strings.Join(facts, "; ")) + + if t.RestartReason != "" { + fmt.Printf(" needs a restart: %s\n", t.RestartReason) + } else { + fmt.Printf(" applies live\n") + } + fmt.Printf(" docs: %s\n\n", t.DocAnchor) + } + return 0 +} + +// quoteEmpty renders an empty default as something visible, so "default " isn't +// mistaken for a truncated line. control.socket legitimately defaults to empty. +func quoteEmpty(v string) string { + if v == "" { + return `"" (unset)` + } + return v +} // configField is a get/set pair for one dotted config key. type configField struct { @@ -378,6 +491,8 @@ func cmdConfig(args []string) int { return configReset(cfgPath, rest, useToken) case "preset": return configPreset(cfgPath, rest, useToken) + case "schema": + return configSchema(rest) case "edit": return configEdit(cfgPath) case "-h", "--help", "help": diff --git a/cmd/dezhban/config_schema_test.go b/cmd/dezhban/config_schema_test.go new file mode 100644 index 0000000..208f24b --- /dev/null +++ b/cmd/dezhban/config_schema_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// TestConfigFieldsMatchTheDeclaredSchema is the reason `config schema` can be +// trusted. configFields is what `config set` will actually accept; Tunables is +// what every surface tells the user exists. A key in one and not the other is +// either a setting nobody can discover or a setting the app offers and the CLI +// rejects. +func TestConfigFieldsMatchTheDeclaredSchema(t *testing.T) { + declared := map[string]bool{} + for _, k := range config.TunableKeys() { + declared[k] = true + } + + for key := range configFields { + if !declared[key] { + t.Errorf("config set accepts %q but it has no Tunable — add one to internal/config/schema.go", key) + } + } + for key := range declared { + if _, ok := configFields[key]; !ok { + t.Errorf("%q is declared in the schema but config set cannot write it", key) + } + } +} + +// TestUsageListsEveryKey pins the generated help. The key list used to be typed +// out by hand and is now built from the schema; this catches a regression back +// to a hand-maintained copy. +func TestUsageListsEveryKey(t *testing.T) { + for _, key := range config.TunableKeys() { + if !strings.Contains(configUsage, " "+key) { + t.Errorf("config usage text omits %q", key) + } + } +} + +// TestSchemaDefaultsAgreeWithConfigGet cross-checks the two renderings of a +// value: KeyValues (which the schema's defaults come from) and configFields.get +// (which `config get` prints). On a default config nothing is disabled, so the +// two must agree exactly — a divergence would mean the app shows one default as +// a hint and `config get` reports another. +func TestSchemaDefaultsAgreeWithConfigGet(t *testing.T) { + cfg := config.Default() + config.Normalize(&cfg) + + for _, tun := range config.Tunables() { + field, ok := configFields[tun.Key] + if !ok { + continue // reported by TestConfigFieldsMatchTheDeclaredSchema + } + if got := field.get(&cfg); got != tun.Default { + t.Errorf("%s: config get renders %q but the schema's default is %q", + tun.Key, got, tun.Default) + } + } +} + +// TestConfigSchemaJSONCoversEveryKey exercises the command end to end, since the +// macOS app decodes exactly these bytes. +func TestConfigSchemaJSONCoversEveryKey(t *testing.T) { + var code int + out := captureStdout(t, func() { code = configSchema([]string{"--json"}) }) + if code != 0 { + t.Fatalf("config schema --json exited %d", code) + } + + var entries []struct { + Key string `json:"key"` + Label string `json:"label"` + Kind string `json:"kind"` + Default string `json:"default"` + CapKey string `json:"capKey"` + Disablable bool `json:"disablable"` + Advanced bool `json:"advanced"` + Preset bool `json:"preset"` + Help string `json:"help"` + DocAnchor string `json:"docAnchor"` + RestartReason string `json:"restartReason"` + } + if err := json.Unmarshal([]byte(out), &entries); err != nil { + t.Fatalf("config schema --json emitted undecodable JSON: %v", err) + } + + seen := map[string]bool{} + for _, e := range entries { + seen[e.Key] = true + if e.Label == "" || e.Kind == "" || e.Help == "" || e.DocAnchor == "" { + t.Errorf("%s: incomplete entry in JSON output", e.Key) + } + } + for _, key := range config.TunableKeys() { + if !seen[key] { + t.Errorf("config schema --json omits %q", key) + } + } + + // The embedded Tunable must flatten, not nest — the app decodes these fields + // at the top level of each entry. + if strings.Contains(out, `"Tunable"`) { + t.Error("config schema --json nested the embedded Tunable instead of flattening it") + } +} + +// TestConfigSchemaMarksPresetKeys — a surface warns that editing one of these by +// hand drifts the config off its preset, so the flag has to be right. +func TestConfigSchemaMarksPresetKeys(t *testing.T) { + written := presetWritten() + if len(written) == 0 { + t.Fatal("no preset writes any key") + } + for key := range written { + if _, ok := configFields[key]; !ok { + t.Errorf("preset writes %q, which config set cannot write", key) + } + } + // Spot-check a key presets deliberately leave alone: presets are strictness + // strategies, never identity. + if written["vpn.endpoints"] { + t.Error("a preset writes vpn.endpoints — presets must not touch identity data") + } +} + +// TestConfigSchemaRejectsArguments — it takes none, and a typo'd subcommand must +// not be silently ignored. +func TestConfigSchemaRejectsArguments(t *testing.T) { + if code := configSchema([]string{"pollInterval"}); code != 2 { + t.Errorf("config schema with an argument exited %d, want 2", code) + } +} + +// TestConfigSchemaNeedsNoConfigFile is the property a first-run wizard depends +// on: the schema describes what the keys ARE, so it must answer on a host that +// has no config yet. +func TestConfigSchemaNeedsNoConfigFile(t *testing.T) { + t.Setenv("DEZHBAN_CONFIG", "/nonexistent/dezhban/config.json") + var code int + out := captureStdout(t, func() { code = configSchema([]string{"--json"}) }) + if code != 0 { + t.Fatalf("config schema --json exited %d with no config file", code) + } + if !strings.Contains(out, `"key": "pollInterval"`) { + t.Error("config schema --json produced nothing useful without a config file") + } +} diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7635af1..8aed9c8 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -54,7 +54,7 @@ daemon** over its control socket and need no password at all: | `panic` | Yes — deliberately independent of the daemon, so the lockout escape hatch works when nothing else does. | | `run` | Yes — it *is* the daemon. | | `setup`, `config set`/`edit`, `config preset apply` | Yes, but only for the config write itself. `preset apply` is a write like any other — see [Presets](#presets). | -| `config show`/`path`, `config preset list`/`show`/`diff` | **No** — read-only; they report the config, they don't change it. | +| `config show`/`path`/`schema`, `config preset list`/`show`/`diff` | **No** — read-only; they report the config, they don't change it. | | `token status` | **No** — reports whether a control token is enrolled; the answer is not itself a secret. | | `token enroll`, `token forget` | Yes — the token's hash lives in the daemon's root-owned state dir, because anything that could rewrite it could nominate its own token. Once, at setup. | | `upgrade check` | **No** — read-only, no root. | @@ -163,6 +163,7 @@ sudo dezhban setup # interactive wizard — builds/updates the c # detects tunnels, previews the ruleset, then writes it dezhban config path # print the resolved config path dezhban config show # print the effective config as JSON +dezhban config schema # describe every settable key (add --json for machine output) dezhban config get blockedCountries sudo dezhban config set blockedCountries IR,RU # set, validate, save sudo dezhban config reset vpn.switchWindow # restore a shipped default (--all: every tunable) @@ -199,6 +200,25 @@ succeeds and says so; the new values are read the next time it starts. validation, and ruleset preview as `detect-vpn`/`validate`/`print-rules`. Writes to the system path need root (hence `sudo`); a permission error prints a `sudo` hint. +### Asking what a key is + +`config schema` describes the keys themselves rather than your values: for each +one, its default, what bounds it, whether `"0"` turns it off, whether a preset +writes it, whether the running daemon can adopt it without a restart, and which +part of [config.md](config.md) documents it. + +```sh +dezhban config schema # every key, explained +dezhban config schema --json # the same, for tools +``` + +It reads no config file — the schema is what the keys *are*, not what this host +has set — so it answers the same on a machine that has never been configured. +That is what lets the macOS app label its settings, bound its sliders, and know +where an explicit **Off** is a real choice instead of hardcoding any of it. The +defaults it prints are derived from the shipped defaults themselves, so this +output cannot drift from what you actually get by setting nothing. + ### Presets A quick way to answer "how strict am I", without knowing which eight keys that From 79208907a0684181cec062a92228d2c7993dc2a6 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 19:13:50 +0330 Subject: [PATCH 03/27] test(config): fail the build when the docs drift from the defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults are data now, but documentation is prose, and prose is where the drift lived. Two tests make the config reference and the shipped examples answerable to the code. TestDocumentedDefaultsMatchTheCode parses every table in docs/usage/config.md and compares its Default column to the declared default, and also fails when a settable key has no row at all. Columns are found by header name rather than position because the tables really do differ (Field/Type/Default/Notes versus the advanced block's Field/Default/What it controls), and a heading sets the key prefix so the advanced table's bare `switchWindowMax` resolves. Durations are compared as durations, not strings: the tables read "30m" while Go's Duration.String renders "30m0s". Forcing the prose to say "30m0s" would make the documentation worse to serve the test, which is the wrong way round. Three keys genuinely cannot state a literal — the eight provider URLs do not fit a cell, control.socket documents the resolved path while the code default is "resolve it yourself", and control.group is platform-dependent. Those are exempt by name WITH a reason, and a stale exemption is itself a failure: if the cell later states the value exactly, the test says to remove it. A new prose default fails until somebody justifies it, rather than quietly opting out. TestExampleConfigsUseOnlyKnownKeys checks the examples are real: every key must still take effect, and the file must load and validate. It deliberately does NOT require values to equal defaults — an example exists to show non-default values. What must never happen is an example naming a key that silently does nothing, which is what a rename like reconnect→redial would otherwise leave behind. Two findings, both fixed here: windowProtocols and windowPorts shared one table row, so neither was individually checkable; they are now a row each, stating the literal `[]` default alongside the explanation of what empty means. Everything else already agreed — the drift was in the app's hints, which the next commit removes. Verified by injecting a wrong default and watching the test fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- docs/usage/config.md | 3 +- internal/config/docdrift_test.go | 292 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 internal/config/docdrift_test.go diff --git a/docs/usage/config.md b/docs/usage/config.md index 6cd4bfe..e78298b 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -329,7 +329,8 @@ the two differ, on both write paths (elevated and `--token-stdin`). | `promoteAfterRefreshes` | `3` | Consecutive sightings before a discovered endpoint is learned under normal guard. | | `redialMinUptime` | `15s` | Anti-flap gate on the automatic redial window: an auto-window opens only if the tunnel had been up at least this long (or a good exit was confirmed during that uptime). The first drop after startup is exempt — uptime before the daemon started is unknowable. `"0"` disables the gate. | | `endpointWarnThreshold` | `256` | Union size at which `doctor` warns about rule-list bloat. | -| `windowProtocols` / `windowPorts` | (empty = allow all) | Restrict the switch window to these protocols/ports instead of all outbound — only useful when every VPN you switch to uses a fixed port set (e.g. WireGuard on 51820). | +| `windowProtocols` | `[]` | Restrict a switch window to these protocols (e.g. `["udp"]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed protocol. | +| `windowPorts` | `[]` | Restrict a switch window to these ports (e.g. `[51820]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed port set (e.g. WireGuard on 51820). | ## Presets diff --git a/internal/config/docdrift_test.go b/internal/config/docdrift_test.go new file mode 100644 index 0000000..0d2e578 --- /dev/null +++ b/internal/config/docdrift_test.go @@ -0,0 +1,292 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// Defaults are data (see schema.go), but documentation is prose, and prose is +// where the drift lived: the config reference and the example configs restate +// values that nothing forces to agree with the code. These tests are that force. +// +// They run inside `go test ./...`, so both CI legs catch a stale table — this is +// cheaper and earlier than a CI-only check, and it means editing a default +// without editing its documentation fails on the contributor's own machine. + +const ( + docConfigRef = "../../docs/usage/config.md" + exampleDir = "../../configs" +) + +// docDefaultIsProse names keys whose Default column deliberately says something +// other than the literal value, with the reason. Anything NOT listed here must +// state its default verbatim, so a new prose default fails the build until +// somebody justifies it rather than quietly opting out of the check. +var docDefaultIsProse = map[string]string{ + "providers": "eight URLs would not fit a table cell; the Notes column names them in order instead", + "control.socket": "the code default is empty, meaning 'resolve it against the daemon's state dir'; " + + "the table documents the resolved path, which is what a reader needs", + "control.group": "genuinely platform-dependent (admin on macOS, empty elsewhere), so there is no " + + "single literal to state", +} + +// TestDocumentedDefaultsMatchTheCode is the drift check. A default stated in the +// config reference that disagrees with the shipped default is worse than no +// documentation: a reader acts on it. +func TestDocumentedDefaultsMatchTheCode(t *testing.T) { + documented := parseDocDefaults(t, docConfigRef) + + for _, tun := range Tunables() { + cell, ok := documented[tun.Key] + if !ok { + t.Errorf("%s is settable but has no row in %s — every key a user can set must be documented", + tun.Key, docConfigRef) + continue + } + + got, parsed := normalizeDocDefault(cell) + if reason, prose := docDefaultIsProse[tun.Key]; prose { + // A stale exemption is its own kind of rot: if the cell now states + // the value exactly, the exemption is no longer buying anything. + if parsed && got == tun.Default { + t.Errorf("%s: documented default now matches the code exactly, so its "+ + "docDefaultIsProse exemption (%q) should be removed", tun.Key, reason) + } + continue + } + + if !parsed { + t.Errorf("%s: Default column %q is not a literal value. State the default, "+ + "or add the key to docDefaultIsProse with a reason.", tun.Key, cell) + continue + } + if !sameDefault(tun, got) { + t.Errorf("%s: %s says the default is %q, the code says %q", + tun.Key, docConfigRef, got, tun.Default) + } + } + + for key := range docDefaultIsProse { + if _, ok := TunableByKey(key); !ok { + t.Errorf("docDefaultIsProse exempts %q, which is not a settable key", key) + } + } +} + +// TestExampleConfigsUseOnlyKnownKeys keeps the shipped examples honest. They are +// the first config most people ever see, and a copied-out example that sets a +// renamed or retired key silently does nothing — the exact failure the +// reconnect→redial rename was designed to make loud everywhere else. +// +// The check is "every key is real", not "every value is the default": an example +// exists precisely to show non-default values, so equality would be the wrong +// assertion. What must never happen is an example naming a key that no longer +// takes effect. +func TestExampleConfigsUseOnlyKnownKeys(t *testing.T) { + paths, err := filepath.Glob(filepath.Join(exampleDir, "*.json")) + if err != nil { + t.Fatalf("glob %s: %v", exampleDir, err) + } + if len(paths) == 0 { + t.Fatalf("no example configs found under %s", exampleDir) + } + + for _, path := range paths { + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + for _, u := range unknownKeys(data) { + reason, tookEffect := describeUnknown(u) + if tookEffect { + // A renamed key that still works: worth saying, not failing. + t.Logf("%s: %s", u.Key, reason) + continue + } + t.Errorf("sets %q, which does nothing: %s", u.Key, reason) + } + + // It must also still load and validate — an example that cannot be + // used is not an example. + if _, err := Load(path); err != nil { + t.Errorf("does not load: %v", err) + } + }) + } +} + +// sameDefault compares a documented default to the declared one. Durations are +// compared as durations, not as strings: the tables read "30m" while Go's +// Duration.String renders "30m0s", and those are the same value. Forcing the +// prose to say "30m0s" would make the documentation worse to serve the test, +// which is the wrong way round. +func sameDefault(tun Tunable, documented string) bool { + if tun.Kind != KindDuration { + return documented == tun.Default + } + want, errWant := parseDuration(tun.Default) + got, errGot := parseDuration(documented) + if errWant != nil || errGot != nil { + return documented == tun.Default + } + return want == got +} + +// parseDuration accepts the two forms a default can take: a Go duration, or the +// literal "off" KeyValues renders for the Disabled sentinel. +func parseDuration(v string) (time.Duration, error) { + if v == "off" { + return Disabled, nil + } + return time.ParseDuration(v) +} + +// parseDocDefaults reads every markdown table in the reference and returns the +// Default cell for each documented field, keyed by the dotted config key. +// +// Columns are located by header name rather than by position, because the tables +// genuinely differ: the field tables are Field/Type/Default/Notes while the +// advanced table is Field/Default/What it controls. +func parseDocDefaults(t *testing.T, path string) map[string]string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + out := map[string]string{} + fieldCol, defaultCol := -1, -1 + prefix := "" + + for rawLine := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if !strings.HasPrefix(line, "|") { + // A heading sets the key prefix for the tables under it: the + // advanced table lists bare field names (`switchWindowMax`) because + // its heading already says which block they live in. + if strings.HasPrefix(line, "#") { + prefix = "" + if strings.Contains(line, "vpn.advanced") { + prefix = "vpn.advanced." + } + } + // Any non-table line ends the current table, so a Default column + // index can never leak from one table into the next. + fieldCol, defaultCol = -1, -1 + continue + } + cells := splitRow(line) + + // A separator row (|---|---|) means the row above was the header. + if isSeparator(cells) { + continue + } + if fieldCol == -1 { + fieldCol, defaultCol = findColumns(cells) + continue + } + if defaultCol == -1 || fieldCol >= len(cells) || defaultCol >= len(cells) { + continue + } + + key := strings.Trim(strings.TrimSpace(cells[fieldCol]), "`") + if key == "" { + continue + } + // A bare name under a prefixed heading is that block's field; a name + // that is already dotted is used as written. + if _, known := TunableByKey(key); !known && prefix != "" { + key = prefix + key + } + // First table wins: a key documented twice keeps its primary row, and + // the duplicate is reported rather than silently shadowing it. + if prev, dup := out[key]; dup && prev != cells[defaultCol] { + t.Errorf("%s: %s is documented twice with different defaults (%q and %q)", + path, key, prev, cells[defaultCol]) + continue + } + out[key] = cells[defaultCol] + } + return out +} + +// splitRow splits a markdown table row into its cells, dropping the empty +// leading and trailing fields the surrounding pipes produce. +func splitRow(line string) []string { + parts := strings.Split(strings.Trim(line, "|"), "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func isSeparator(cells []string) bool { + for _, c := range cells { + if c == "" || strings.Trim(c, "-: ") != "" { + return false + } + } + return len(cells) > 0 +} + +// findColumns locates the Field and Default columns in a header row, returning +// -1 for a table that has neither (so it is skipped entirely). +func findColumns(header []string) (fieldCol, defaultCol int) { + fieldCol, defaultCol = -1, -1 + for i, h := range header { + switch strings.ToLower(strings.TrimSpace(h)) { + case "field", "key": + fieldCol = i + case "default": + defaultCol = i + } + } + if defaultCol == -1 { + return -1, -1 + } + return fieldCol, defaultCol +} + +// normalizeDocDefault turns a documented Default cell into the same rendering +// KeyValues produces, so the two are directly comparable. The bool is false when +// the cell is not a literal value at all (an em dash, prose, or empty). +func normalizeDocDefault(cell string) (string, bool) { + v := strings.TrimSpace(cell) + v = strings.TrimSpace(strings.Trim(v, "`")) + if v == "" || v == "—" || v == "-" { + return "", false + } + + // Lists render comma-joined with no spaces, matching KeyValues/joinInts. + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + var strs []string + if err := json.Unmarshal([]byte(v), &strs); err == nil { + return strings.Join(strs, ","), true + } + var nums []int + if err := json.Unmarshal([]byte(v), &nums); err == nil { + parts := make([]string, len(nums)) + for i, n := range nums { + parts[i] = strconv.Itoa(n) + } + return strings.Join(parts, ","), true + } + return "", false + } + + // Durations and strings are quoted in the tables ("15s", "info"); ints and + // bools are bare. + if unquoted, err := strconv.Unquote(v); err == nil { + return unquoted, true + } + if strings.ContainsAny(v, " ") { + return v, false // prose, e.g. "8 geo-IP URLs" + } + return v, true +} From ad8e348e6fe0bd5702ef48b4afc463735aa90746 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 19:30:15 +0330 Subject: [PATCH 04/27] feat(gui): the app reads what a setting is instead of guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings now labels, explains, and hints every field from `config schema --json` rather than from literal strings in the view. This was a live bug, not just duplication. The hints were written by hand and had drifted: the pane offered "Endpoint refresh (e.g. 30s)" and "Tunnel watch (e.g. 5s)" for keys whose shipped defaults are 1m and 1s. Anyone reading Settings to learn what dezhban does was being told the wrong thing. The hints now say "(default 1m0s)" because they are the default, read from the daemon. New DezhbanCore/ConfigSchema.swift mirrors the daemon's shape, the same "Mirrors Go's ..." convention PresetInfo.swift uses, plus DezhbanCLI.readSchema(). It passes no --config on purpose: the schema describes the keys, not this host's values, so it answers before anything is configured — which is what a first-run surface will need. A CLI too old to know the subcommand yields no schema, and every use falls back to a plainer label that states no value. A missing schema must degrade to terse, never to wrong. SettingsFields now stores values KEYED by dotted key. It used to hold twenty-five properties destructured out of an array by literal index (values[0]...values[24]) behind a precondition on the count alone, so inserting a key anywhere but the end, or swapping two, would write one field's value under another field's key and pass every check. The named properties survive as computed accessors over the dictionary, so the SwiftUI bindings are untouched, but the failure mode is now unrepresentable. ConfigApply.seed returns a keyed map for the same reason: a positional result is only correct while every consumer agrees on the order, and that agreement was the fragile part. durationFieldsForValidation takes the schema and derives both which fields are durations and what they are called, replacing a hand-maintained list of thirteen labels that had to track the pane by memory. With no schema it validates nothing and lets the daemon reject a bad duration — guessing the field set would either miss one or invent labels that disagree with the ones on screen. vpn.pauseMax gains a control while the pane is being rebuilt: it was settable from the CLI and by editing the file, but the app offered no way to see how long a pause may last. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 20 ++ .../Sources/DezhbanCore/ConfigSchema.swift | 109 +++++++ .../Sources/DezhbanCore/SettingsFields.swift | 304 ++++++++++-------- .../Sources/DezhbanMenu/ConfigApply.swift | 18 +- .../Sources/DezhbanMenu/DezhbanCLI.swift | 17 + .../Sources/DezhbanMenu/SettingsView.swift | 196 ++++++----- .../SettingsFieldsTests.swift | 213 ++++++++++-- 7 files changed, 629 insertions(+), 248 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/ConfigSchema.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d7484..6b25d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,26 @@ current as you land changes. table, which itself derives the values from the shipped defaults, so the same drift cannot recur. +- **`vpn.pauseMax` has a control in the macOS app**, under Windows alongside the + switch and redial windows. It was settable from the CLI and reachable by + editing the file, but the app offered no way to see or change how long a pause + may last. + +### Fixed + +- **The macOS app's Settings pane no longer advertises wrong defaults.** Its + field hints were literal strings that had drifted from the shipped values — it + suggested a 30s endpoint refresh and a 5s tunnel watch when the defaults are + 1m and 1s. Labels, hints, and help text now come from the daemon's own schema, + so the pane says what dezhban actually does. Against a CLI too old to report a + schema the pane falls back to a plainer label rather than a stale value: less + helpful, never wrong. + +- **A settings value can no longer be written under the wrong key.** The pane + staged its twenty-five values as an array destructured by position, with only + a count check — so inserting or reordering a key would have silently applied + one field's value to another setting. Values are now keyed throughout. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/gui/macos/Sources/DezhbanCore/ConfigSchema.swift b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift new file mode 100644 index 0000000..8fe7612 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Mirrors Go's schemaEntry (`dezhban config schema --json`), which is +/// `internal/config.Tunable` plus a `preset` flag. +/// +/// This exists so the app stops restating what the daemon already knows. Before +/// it, every field's label, hint, and default were literal strings in +/// SettingsView — and they had drifted: the pane advertised a 30s endpoint +/// refresh and a 5s tunnel watch while the shipped defaults were 1m and 1s. +/// Anyone reading Settings was being told the wrong thing. +public struct ConfigTunable: Codable, Identifiable, Hashable { + public var id: String { key } + + /// The dotted key `config set` accepts — the identity everything joins on. + public let key: String + /// Human name for the concept, in the vocabulary of docs/concepts/glossary.md. + public let label: String + /// Value shape: duration, bool, int, list, or string. + public let kind: String + /// What this key is when the config does not set it, rendered the way + /// `config get` renders a live value. + public let defaultValue: String + /// The key that bounds this one, if any. A key rather than a number because + /// the ceiling is itself settable — a slider's top must be read from the + /// live config, never hardcoded. + public let capKey: String? + /// What an int counts. Empty for every other kind. + public let unit: String? + /// Whether "0" is an explicit, persisted off-switch for this key rather than + /// "reset me to the default". Only these keys may be offered an **Off** + /// choice: for the others Normalize restores the default, so an Off would + /// silently do nothing. + public let disablable: Bool + /// The `vpn.advanced.*` knobs, shown behind the Advanced disclosure. + public let advanced: Bool + /// Whether a strictness preset writes this key, so editing it by hand drifts + /// the config to Custom. + public let preset: Bool + public let help: String + /// Where this key is documented, as "#". + public let docAnchor: String + /// Why a running daemon cannot adopt this key in place; nil when it can. + public let restartReason: String? + + // `default` is a Swift keyword, so the stored property is renamed and mapped. + private enum CodingKeys: String, CodingKey { + case key, label, kind + case defaultValue = "default" + case capKey, unit, disablable, advanced, preset, help, docAnchor, restartReason + } + + /// True when a change to this key takes effect without restarting the daemon. + public var appliesLive: Bool { (restartReason ?? "").isEmpty } + + /// Placeholder text for a text field: the label, then the real default. + /// + /// It says "default", not "e.g.", because it now IS the default rather than + /// an illustrative value someone typed once. + public var placeholder: String { + defaultValue.isEmpty ? label : "\(label) (default \(defaultValue))" + } +} + +/// The whole declared schema, indexed by key. +public struct ConfigSchema { + public let tunables: [ConfigTunable] + private let byKey: [String: ConfigTunable] + + public init(_ tunables: [ConfigTunable]) { + self.tunables = tunables + self.byKey = Dictionary(uniqueKeysWithValues: tunables.map { ($0.key, $0) }) + } + + public static func decode(_ data: Data) -> ConfigSchema? { + guard let entries = try? JSONDecoder().decode([ConfigTunable].self, from: data) else { return nil } + return ConfigSchema(entries) + } + + public subscript(key: String) -> ConfigTunable? { byKey[key] } + + public var isEmpty: Bool { tunables.isEmpty } + + /// Placeholder for a key, falling back to a plain label when the schema is + /// unavailable (an old CLI, or a failed read). A missing schema must degrade + /// to a less helpful hint, never to a wrong one — which is why there is no + /// hardcoded default to fall back to. + public func placeholder(for key: String, fallback: String) -> String { + self[key]?.placeholder ?? fallback + } + + /// The help line for a key, or nil when the schema is unavailable. + public func help(for key: String) -> String? { self[key]?.help } + + /// The live ceiling for a key: the current value of whatever `capKey` names, + /// looked up in the values the pane was seeded with. Nil when the key is + /// unbounded or the cap's value is not to hand. + /// + /// Resolved against seeded values rather than the cap's own default, because + /// lowering a cap by hand must actually lower the control's top. + public func cap(for key: String, in values: [String: String]) -> String? { + guard let capKey = self[key]?.capKey else { return nil } + return values[capKey] ?? self[capKey]?.defaultValue + } + + /// Every key the daemon says needs a restart, among those given. + public func restartRequired(among keys: [String]) -> [String] { + keys.filter { self[$0].map { !$0.appliesLive } ?? false } + } +} diff --git a/gui/macos/Sources/DezhbanCore/SettingsFields.swift b/gui/macos/Sources/DezhbanCore/SettingsFields.swift index a01b566..bb0080a 100644 --- a/gui/macos/Sources/DezhbanCore/SettingsFields.swift +++ b/gui/macos/Sources/DezhbanCore/SettingsFields.swift @@ -1,21 +1,30 @@ import Foundation /// The staged values for the Settings pane's batched `config set`, and the -/// dotted keys they correspond to. Pulled out of SettingsView so the -/// seed→pairs round trip — the exact place a silent index mismatch would write -/// one field's value under another field's key — can be unit-tested without -/// standing up SwiftUI state. +/// dotted keys they correspond to. +/// +/// Values are stored **keyed by dotted key**, not positionally. They used to be +/// twenty-five stored properties destructured out of an array by literal index, +/// with a `precondition` on the count alone — so inserting a key anywhere but the +/// end, or reordering two of them, silently wrote one field's value under another +/// field's key and passed every check. Keying the storage makes that +/// unrepresentable: a value can only ever be read back under the key it was +/// stored against. +/// +/// The named properties survive as computed accessors so the SwiftUI bindings +/// (`$fields.switchWindow`) keep working; they are now views onto the dictionary +/// rather than a second copy of it. public struct SettingsFields { - // Dotted keys from configFields (cmd/dezhban/config_cmd.go), in the same - // order `seed()`/`pairs()` destructure and rebuild them. The last twelve are - // the vpn.advanced.* block, staged into the same batch and shown behind the - // Settings pane's Advanced disclosure. + // Dotted keys from configFields (cmd/dezhban/config_cmd.go). This is the set + // the pane stages — a deliberate subset of every settable key, since identity + // data (profiles) and the control socket are managed elsewhere. Order is + // presentation order only; nothing correctness-bearing depends on it now. public static let keys = [ "vpn.tunnelInterfaces", "vpn.endpoints", "vpn.autoDetect", "vpn.autoDiscoverEndpoints", "vpn.autoArm", "vpn.allowLocalNetwork", "blockedCountries", "pollInterval", - "vpn.switchWindow", "vpn.redialWindow", "vpn.endpointGrace", + "vpn.switchWindow", "vpn.redialWindow", "vpn.pauseMax", "vpn.endpointGrace", "vpn.endpointRefresh", "vpn.tunnelWatch", "vpn.advanced.switchWindowMax", "vpn.advanced.redialWindowMax", "vpn.advanced.redialMinUptime", "vpn.advanced.commandFreshness", "vpn.advanced.windowDiscoveryInterval", "vpn.advanced.tunnelPruneAfter", @@ -23,140 +32,167 @@ public struct SettingsFields { "vpn.advanced.endpointWarnThreshold", "vpn.advanced.windowProtocols", "vpn.advanced.windowPorts", ] - public var tunnelInterfaces = "" - public var endpoints = "" - public var autoDetect = false - public var autoDiscover = false - public var autoArm = false - public var allowLocalNetwork = true - public var blockedCountries = "" - public var pollInterval = "" - public var switchWindow = "" - public var redialWindow = "" - public var endpointGrace = "" - public var endpointRefresh = "" - public var tunnelWatch = "" + /// Raw staged values, exactly as `config get` returned them and exactly as + /// `config set` will receive them. Bools travel as "true"/"false" because + /// that is what both ends of the round trip use. + private var values: [String: String] - public var advSwitchWindowMax = "" - public var advRedialWindowMax = "" - public var advRedialMinUptime = "" - public var advCommandFreshness = "" - public var advWindowDiscoveryInterval = "" - public var advTunnelPruneAfter = "" - public var advLearnedEndpointTTL = "" - public var advLearnedMaxPerProfile = "" - public var advPromoteAfterRefreshes = "" - public var advEndpointWarnThreshold = "" - public var advWindowProtocols = "" - public var advWindowPorts = "" + public init() { + values = Dictionary(uniqueKeysWithValues: Self.keys.map { ($0, "") }) + // Only bools need a non-empty resting value; an empty string would be + // written back as an invalid bool if the pane were applied unseeded. + for key in Self.boolKeys { values[key] = "false" } + values["vpn.allowLocalNetwork"] = "true" + } - public init() {} + /// Rebuilds a SettingsFields from what `config get` returned, keyed by the + /// key each value was read for. A key the caller did not read keeps its + /// resting value rather than silently taking a neighbour's. + public init(seeded: [String: String]) { + self.init() + for (key, value) in seeded where values[key] != nil { + values[key] = value + } + } - /// Rebuilds a SettingsFields from `keys`-ordered values, as returned by a - /// batch of `config get` calls (ConfigApply.seed). Out-of-range access is a - /// programmer error (a caller passing the wrong key list), so this traps - /// rather than silently seeding partial/wrong fields. - public init(seeded values: [String]) { - precondition(values.count == Self.keys.count, - "SettingsFields.init(seeded:) got \(values.count) values, want \(Self.keys.count)") - tunnelInterfaces = values[0] - endpoints = values[1] - autoDetect = (values[2] == "true") - autoDiscover = (values[3] == "true") - autoArm = (values[4] == "true") - allowLocalNetwork = (values[5] == "true") - blockedCountries = values[6] - pollInterval = values[7] - switchWindow = values[8] - redialWindow = values[9] - endpointGrace = values[10] - endpointRefresh = values[11] - tunnelWatch = values[12] + /// The keys whose values are booleans, so accessors and seeding agree on the + /// representation. + static let boolKeys: Set = [ + "vpn.autoDetect", "vpn.autoDiscoverEndpoints", "vpn.autoArm", "vpn.allowLocalNetwork", + ] - advSwitchWindowMax = values[13] - advRedialWindowMax = values[14] - advRedialMinUptime = values[15] - advCommandFreshness = values[16] - advWindowDiscoveryInterval = values[17] - advTunnelPruneAfter = values[18] - advLearnedEndpointTTL = values[19] - advLearnedMaxPerProfile = values[20] - advPromoteAfterRefreshes = values[21] - advEndpointWarnThreshold = values[22] - advWindowProtocols = values[23] - advWindowPorts = values[24] - } + /// Reads one staged value by key. Returns "" for a key this pane does not + /// stage, which is the same thing an unset field looks like. + public func value(for key: String) -> String { values[key] ?? "" } - /// The current values in `keys` order — the dirtiness check compares this - /// against what the pane was last seeded with. - public var currentValues: [String] { - [tunnelInterfaces, endpoints, - String(autoDetect), String(autoDiscover), String(autoArm), - String(allowLocalNetwork), - blockedCountries, pollInterval, - switchWindow, redialWindow, endpointGrace, - endpointRefresh, tunnelWatch, - advSwitchWindowMax, advRedialWindowMax, advRedialMinUptime, - advCommandFreshness, advWindowDiscoveryInterval, advTunnelPruneAfter, - advLearnedEndpointTTL, advLearnedMaxPerProfile, advPromoteAfterRefreshes, - advEndpointWarnThreshold, advWindowProtocols, advWindowPorts] + /// Stages one value by key. Ignores keys the pane does not carry, so a + /// schema-driven control cannot invent storage by writing to it. + public mutating func setValue(_ value: String, for key: String) { + guard values[key] != nil else { return } + values[key] = value } - /// Renders `key=value` pairs for one batched `config set`. Durations are - /// trimmed (whitespace typed into a text field is not part of the value); - /// everything else travels as-is — `config set`/Normalize (Go) does the - /// canonicalisation (upper-casing country codes, etc.), not this layer. + /// Every staged value, keyed. The dirtiness check compares this against what + /// the pane was last seeded with. + public var currentValues: [String: String] { values } + + /// Renders `key=value` pairs for one batched `config set`, in `keys` order so + /// the resulting command line is stable and diffable. Durations are trimmed + /// (whitespace typed into a text field is not part of the value); everything + /// else travels as-is — `config set`/Normalize (Go) does the canonicalisation + /// (upper-casing country codes, etc.), not this layer. public func pairs() -> [String] { - [ - "vpn.tunnelInterfaces=\(tunnelInterfaces)", - "vpn.endpoints=\(endpoints)", - "vpn.autoDetect=\(autoDetect)", - "vpn.autoDiscoverEndpoints=\(autoDiscover)", - "vpn.autoArm=\(autoArm)", - "vpn.allowLocalNetwork=\(allowLocalNetwork)", - "blockedCountries=\(blockedCountries.trimmingCharacters(in: .whitespaces))", - "pollInterval=\(pollInterval.trimmingCharacters(in: .whitespaces))", - "vpn.switchWindow=\(switchWindow.trimmingCharacters(in: .whitespaces))", - "vpn.redialWindow=\(redialWindow.trimmingCharacters(in: .whitespaces))", - "vpn.endpointGrace=\(endpointGrace.trimmingCharacters(in: .whitespaces))", - "vpn.endpointRefresh=\(endpointRefresh.trimmingCharacters(in: .whitespaces))", - "vpn.tunnelWatch=\(tunnelWatch.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.switchWindowMax=\(advSwitchWindowMax.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.redialWindowMax=\(advRedialWindowMax.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.redialMinUptime=\(advRedialMinUptime.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.commandFreshness=\(advCommandFreshness.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowDiscoveryInterval=\(advWindowDiscoveryInterval.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.tunnelPruneAfter=\(advTunnelPruneAfter.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.learnedEndpointTTL=\(advLearnedEndpointTTL.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.learnedMaxPerProfile=\(advLearnedMaxPerProfile.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.promoteAfterRefreshes=\(advPromoteAfterRefreshes.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.endpointWarnThreshold=\(advEndpointWarnThreshold.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowProtocols=\(advWindowProtocols.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowPorts=\(advWindowPorts.trimmingCharacters(in: .whitespaces))", - ] + Self.keys.map { key in + "\(key)=\(value(for: key).trimmingCharacters(in: .whitespaces))" + } } - /// The duration-valued fields that must pass `looksLikeGoDuration` before - /// Apply spends a privileged round trip — label, then the trimmed value. - /// The three int-valued and two list-valued advanced fields aren't duration - /// strings, so (matching the existing precedent for `hysteresis`, which was - /// never validated this way either) they're left to the daemon's own - /// rejection if malformed. - public var durationFieldsForValidation: [(label: String, value: String)] { - [ - ("Geo IP lookup interval", pollInterval.trimmingCharacters(in: .whitespaces)), - ("Switch window", switchWindow.trimmingCharacters(in: .whitespaces)), - ("Redial window", redialWindow.trimmingCharacters(in: .whitespaces)), - ("Endpoint grace", endpointGrace.trimmingCharacters(in: .whitespaces)), - ("Endpoint refresh", endpointRefresh.trimmingCharacters(in: .whitespaces)), - ("Tunnel watch", tunnelWatch.trimmingCharacters(in: .whitespaces)), - ("Manual switch window cap", advSwitchWindowMax.trimmingCharacters(in: .whitespaces)), - ("Redial window cap", advRedialWindowMax.trimmingCharacters(in: .whitespaces)), - ("Redial anti-flap uptime", advRedialMinUptime.trimmingCharacters(in: .whitespaces)), - ("Command freshness", advCommandFreshness.trimmingCharacters(in: .whitespaces)), - ("Window discovery interval", advWindowDiscoveryInterval.trimmingCharacters(in: .whitespaces)), - ("Tunnel prune delay", advTunnelPruneAfter.trimmingCharacters(in: .whitespaces)), - ("Learned endpoint TTL", advLearnedEndpointTTL.trimmingCharacters(in: .whitespaces)), - ] + /// The duration-valued fields that must parse before Apply spends a + /// privileged round trip, as (label, trimmed value) pairs. + /// + /// Both which fields these are and what they are called now come from the + /// daemon's schema instead of a hand-kept list that had to be updated in + /// lockstep with the pane. Without a schema this returns nothing: refusing to + /// pre-validate is safe (the daemon still rejects a malformed duration and + /// says so), whereas guessing the field set would either miss one or invent + /// labels that disagree with the ones on screen. + public func durationFieldsForValidation(schema: ConfigSchema) -> [(label: String, value: String)] { + Self.keys.compactMap { key in + guard let tunable = schema[key], tunable.kind == "duration" else { return nil } + return (tunable.label, value(for: key).trimmingCharacters(in: .whitespaces)) + } + } + + // MARK: - Named accessors + // + // Views onto `values`, so SwiftUI bindings keep their existing spelling. + + private func string(_ key: String) -> String { values[key] ?? "" } + private mutating func setString(_ key: String, _ newValue: String) { values[key] = newValue } + private func bool(_ key: String) -> Bool { values[key] == "true" } + private mutating func setBool(_ key: String, _ newValue: Bool) { values[key] = String(newValue) } + + public var tunnelInterfaces: String { + get { string("vpn.tunnelInterfaces") } set { setString("vpn.tunnelInterfaces", newValue) } + } + public var endpoints: String { + get { string("vpn.endpoints") } set { setString("vpn.endpoints", newValue) } + } + public var autoDetect: Bool { + get { bool("vpn.autoDetect") } set { setBool("vpn.autoDetect", newValue) } + } + public var autoDiscover: Bool { + get { bool("vpn.autoDiscoverEndpoints") } set { setBool("vpn.autoDiscoverEndpoints", newValue) } + } + public var autoArm: Bool { + get { bool("vpn.autoArm") } set { setBool("vpn.autoArm", newValue) } + } + public var allowLocalNetwork: Bool { + get { bool("vpn.allowLocalNetwork") } set { setBool("vpn.allowLocalNetwork", newValue) } + } + public var blockedCountries: String { + get { string("blockedCountries") } set { setString("blockedCountries", newValue) } + } + public var pollInterval: String { + get { string("pollInterval") } set { setString("pollInterval", newValue) } + } + public var switchWindow: String { + get { string("vpn.switchWindow") } set { setString("vpn.switchWindow", newValue) } + } + public var redialWindow: String { + get { string("vpn.redialWindow") } set { setString("vpn.redialWindow", newValue) } + } + public var pauseMax: String { + get { string("vpn.pauseMax") } set { setString("vpn.pauseMax", newValue) } + } + public var endpointGrace: String { + get { string("vpn.endpointGrace") } set { setString("vpn.endpointGrace", newValue) } + } + public var endpointRefresh: String { + get { string("vpn.endpointRefresh") } set { setString("vpn.endpointRefresh", newValue) } + } + public var tunnelWatch: String { + get { string("vpn.tunnelWatch") } set { setString("vpn.tunnelWatch", newValue) } + } + + public var advSwitchWindowMax: String { + get { string("vpn.advanced.switchWindowMax") } set { setString("vpn.advanced.switchWindowMax", newValue) } + } + public var advRedialWindowMax: String { + get { string("vpn.advanced.redialWindowMax") } set { setString("vpn.advanced.redialWindowMax", newValue) } + } + public var advRedialMinUptime: String { + get { string("vpn.advanced.redialMinUptime") } set { setString("vpn.advanced.redialMinUptime", newValue) } + } + public var advCommandFreshness: String { + get { string("vpn.advanced.commandFreshness") } set { setString("vpn.advanced.commandFreshness", newValue) } + } + public var advWindowDiscoveryInterval: String { + get { string("vpn.advanced.windowDiscoveryInterval") } + set { setString("vpn.advanced.windowDiscoveryInterval", newValue) } + } + public var advTunnelPruneAfter: String { + get { string("vpn.advanced.tunnelPruneAfter") } set { setString("vpn.advanced.tunnelPruneAfter", newValue) } + } + public var advLearnedEndpointTTL: String { + get { string("vpn.advanced.learnedEndpointTTL") } set { setString("vpn.advanced.learnedEndpointTTL", newValue) } + } + public var advLearnedMaxPerProfile: String { + get { string("vpn.advanced.learnedMaxPerProfile") } + set { setString("vpn.advanced.learnedMaxPerProfile", newValue) } + } + public var advPromoteAfterRefreshes: String { + get { string("vpn.advanced.promoteAfterRefreshes") } + set { setString("vpn.advanced.promoteAfterRefreshes", newValue) } + } + public var advEndpointWarnThreshold: String { + get { string("vpn.advanced.endpointWarnThreshold") } + set { setString("vpn.advanced.endpointWarnThreshold", newValue) } + } + public var advWindowProtocols: String { + get { string("vpn.advanced.windowProtocols") } set { setString("vpn.advanced.windowProtocols", newValue) } + } + public var advWindowPorts: String { + get { string("vpn.advanced.windowPorts") } set { setString("vpn.advanced.windowPorts", newValue) } } } diff --git a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift index 48bdae4..e13c598 100644 --- a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift +++ b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift @@ -11,11 +11,16 @@ enum ConfigApply { /// on-disk file is the source of truth, never a cached second-schema mirror. /// Short-circuits on the first failure so an error string can never be seeded /// into a field (and later written back as a value by Apply). Calls back on - /// the main queue with the resolved path, the values (in key order), or nil - /// plus the failure text — the one resolution done here is also the one the - /// caller needs for display, so it never has to resolve a second time itself. + /// the main queue with the resolved path, the values **keyed by the key each + /// was read for**, or nil plus the failure text — the one resolution done + /// here is also the one the caller needs for display, so it never has to + /// resolve a second time itself. + /// + /// Keyed rather than in key order: a positional result is only correct while + /// every consumer agrees on the ordering, and that agreement is exactly what + /// used to be able to break silently. static func seed(keys: [String], - completion: @escaping (_ path: String, _ values: [String]?, _ error: String?) -> Void) { + completion: @escaping (_ path: String, _ values: [String: String]?, _ error: String?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { // Resolved HERE, not on the caller's main thread: resolving shells out, // and a shell-out on the main thread spins the run loop (DezhbanCLI.exec). @@ -27,7 +32,10 @@ enum ConfigApply { completion(cfgPath, nil, "Failed to read \(failed.key): \(detail)") return } - completion(cfgPath, results.map { $0.result.output.trimmingCharacters(in: .whitespacesAndNewlines) }, nil) + let values = Dictionary(uniqueKeysWithValues: results.map { + ($0.key, $0.result.output.trimmingCharacters(in: .whitespacesAndNewlines)) + }) + completion(cfgPath, values, nil) } } } diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index ce2c449..e2fe8a7 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -284,6 +284,23 @@ enum DezhbanCLI { return PresetSummary.decodeList(data) } + /// Reads what every settable key IS — its label, default, cap, whether it + /// can be turned off — via `config schema --json`. + /// + /// Deliberately passes no `--config`: the schema describes the keys, not this + /// host's values, so it answers the same before anything is configured. That + /// is what lets a first-run surface label its fields. + /// + /// Returns nil against a CLI too old to know the subcommand, which callers + /// must degrade gracefully for — a missing schema means less helpful hints, + /// never wrong ones. + static func readSchema() -> ConfigSchema? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["config", "schema", "--json"]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return ConfigSchema.decode(data) + } + /// Reads the keys that differ from `name` (or, if nil, the /// matched-or-nearest preset — the same default `preset diff` uses), /// via `config preset diff [name] --json`. diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 1645488..8c62198 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -28,11 +28,17 @@ struct SettingsView: View { @State private var fields = SettingsFields() - /// The values this pane was last seeded with, in `SettingsFields.keys` order. - /// Comparing the live fields against these is how an unsaved edit is told - /// from a pane that is merely displaying what is on disk — which decides - /// whether it is safe to re-read the file underneath the user. - @State private var seededValues: [String] = [] + /// The values this pane was last seeded with, keyed by config key. Comparing + /// the live fields against these is how an unsaved edit is told from a pane + /// that is merely displaying what is on disk — which decides whether it is + /// safe to re-read the file underneath the user. + @State private var seededValues: [String: String] = [:] + + /// What every key IS, read once from `config schema --json`. Nil until it + /// loads, and nil forever against a CLI too old to know the subcommand — + /// every use falls back to a plainer label rather than a hardcoded value, + /// because a wrong hint is worse than a terse one. + @State private var schema: ConfigSchema? private var hasUnsavedEdits: Bool { !seededValues.isEmpty && fields.currentValues != seededValues @@ -77,56 +83,35 @@ struct SettingsView: View { + "works either way.") } Section("VPN guard") { - TextField("Your VPN tunnel (comma-sep)", text: $fields.tunnelInterfaces) - .disabled(!canApply) - TextField("Endpoints (comma-sep)", text: $fields.endpoints) - .disabled(!canApply) + schemaField("vpn.tunnelInterfaces", "Your VPN tunnel (comma-sep)", + text: $fields.tunnelInterfaces) + schemaField("vpn.endpoints", "VPN server addresses (comma-sep)", text: $fields.endpoints) } Section("Autodetection") { - Toggle("Find my VPN tunnel automatically", isOn: $fields.autoDetect) - .disabled(!canApply) - Toggle("Auto-discover endpoints (vpn.autoDiscoverEndpoints)", isOn: $fields.autoDiscover) - .disabled(!canApply) - Toggle("Auto-arm when a VPN connects (vpn.autoArm)", isOn: $fields.autoArm) - .disabled(!canApply) - .help("With no VPN connected dezhban idles in standby (nothing blocked) and arms " - + "the guard the moment a tunnel appears. It never disarms on a drop — that's the " - + "kill switch — only an explicit Unblock with the VPN off returns to standby.") + schemaToggle("vpn.autoDetect", "Find my VPN tunnel automatically", isOn: $fields.autoDetect) + schemaToggle("vpn.autoDiscoverEndpoints", "Find the VPN server address automatically", + isOn: $fields.autoDiscover) + schemaToggle("vpn.autoArm", "Arm the guard when a VPN connects", isOn: $fields.autoArm) } Section("Local network") { - Toggle("Keep local devices reachable", isOn: $fields.allowLocalNetwork) - .disabled(!canApply) - .help("Printers, NAS, your router's admin page, AirPlay and Chromecast, and local " - + "dev servers keep working while the guard is armed. This is not a hole in the " - + "kill switch: it allows local destinations only, so anything on the internet " - + "stays blocked. The one cost is on untrusted Wi-Fi (a café, a hotel), where it " - + "also lets other devices on that network reach you.") + schemaToggle("vpn.allowLocalNetwork", "Keep local devices reachable", + isOn: $fields.allowLocalNetwork) } Section("Blocking") { - TextField("Blocked countries (comma-sep, e.g. IR,RU,KP)", text: $fields.blockedCountries) - .disabled(!canApply) - TextField("Geo IP lookup interval (e.g. 15s)", text: $fields.pollInterval) - .disabled(!canApply) - .help("How often the current VPN exit's country is checked.") + schemaField("blockedCountries", "Blocked countries (comma-sep)", + text: $fields.blockedCountries) + schemaField("pollInterval", "Exit country check interval", text: $fields.pollInterval) } Section("Windows") { - TextField("Switch window (e.g. 5s)", text: $fields.switchWindow) - .disabled(!canApply) - .help("Manual switch window (`dezhban switch`): 0 disables it, otherwise up to 3m.") - TextField("Redial window (e.g. 30s)", text: $fields.redialWindow) - .disabled(!canApply) - .help("Automatic window opened when a healthy tunnel drops, so the VPN client can " - + "redial: 0 disables it, otherwise up to 10m.") - TextField("Endpoint grace (e.g. 15m)", text: $fields.endpointGrace) - .disabled(!canApply) - .help("How long a discovered VPN server stays reachable after its connection " - + "disappears, so a dropped VPN can redial the same server.") + schemaField("vpn.switchWindow", "Switch window", text: $fields.switchWindow) + schemaField("vpn.redialWindow", "Redial window", text: $fields.redialWindow) + schemaField("vpn.pauseMax", "Longest pause", text: $fields.pauseMax) + schemaField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) } Section("Timing") { - TextField("Endpoint refresh (e.g. 30s)", text: $fields.endpointRefresh) - .disabled(!canApply) - TextField("Tunnel watch (e.g. 5s)", text: $fields.tunnelWatch) - .disabled(!canApply) + schemaField("vpn.endpointRefresh", "VPN server address refresh", + text: $fields.endpointRefresh) + schemaField("vpn.tunnelWatch", "Tunnel check interval", text: $fields.tunnelWatch) } Section("Authorization") { Toggle("Use Touch ID for settings changes", isOn: tokenBinding) @@ -147,37 +132,30 @@ struct SettingsView: View { Text("Touch only if you know why — these override recommended defaults.") .font(.callout) .foregroundStyle(.secondary) - TextField("Manual switch window cap (e.g. 3m)", text: $fields.advSwitchWindowMax) - .disabled(!canApply) - TextField("Redial window cap (e.g. 10m)", text: $fields.advRedialWindowMax) - .disabled(!canApply) - TextField("Redial anti-flap uptime (e.g. 15s; 0 disables the gate)", text: $fields.advRedialMinUptime) - .disabled(!canApply) - TextField("Command freshness (e.g. 30s)", text: $fields.advCommandFreshness) - .disabled(!canApply) - .help("How recent a control command must be to be acted on (replay guard).") - TextField("Window discovery interval (e.g. 1s)", text: $fields.advWindowDiscoveryInterval) - .disabled(!canApply) - .help("How often a new VPN server is looked for while a switch window is open.") - TextField("Tunnel prune delay (e.g. 60s)", text: $fields.advTunnelPruneAfter) - .disabled(!canApply) - .help("How long a dynamically-detected tunnel must be gone before it's dropped.") - TextField("Learned endpoint lifetime (e.g. 720h)", text: $fields.advLearnedEndpointTTL) - .disabled(!canApply) - .help("How long an unused learned endpoint is kept.") - TextField("Learned endpoints per profile (e.g. 16)", text: $fields.advLearnedMaxPerProfile) - .disabled(!canApply) - TextField("Promote after N sightings (e.g. 3)", text: $fields.advPromoteAfterRefreshes) - .disabled(!canApply) - .help("Consecutive sightings before a discovered endpoint is learned under normal guard.") - TextField("Endpoint-bloat warning threshold (e.g. 256)", text: $fields.advEndpointWarnThreshold) - .disabled(!canApply) - TextField("Switch-window protocols (comma-sep, e.g. udp,tcp)", text: $fields.advWindowProtocols) - .disabled(!canApply) - .help("Restricts a switch window to these protocols instead of allowing all outbound. Needs a restart to take effect.") - TextField("Switch-window ports (comma-sep, e.g. 51820,443)", text: $fields.advWindowPorts) - .disabled(!canApply) - .help("Restricts a switch window to these ports instead of allowing all outbound. Needs a restart to take effect.") + schemaField("vpn.advanced.switchWindowMax", "Switch window cap", + text: $fields.advSwitchWindowMax) + schemaField("vpn.advanced.redialWindowMax", "Redial window cap", + text: $fields.advRedialWindowMax) + schemaField("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", + text: $fields.advRedialMinUptime) + schemaField("vpn.advanced.commandFreshness", "Command freshness", + text: $fields.advCommandFreshness) + schemaField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", + text: $fields.advWindowDiscoveryInterval) + schemaField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", + text: $fields.advTunnelPruneAfter) + schemaField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", + text: $fields.advLearnedEndpointTTL) + schemaField("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", + text: $fields.advLearnedMaxPerProfile) + schemaField("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", + text: $fields.advPromoteAfterRefreshes) + schemaField("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", + text: $fields.advEndpointWarnThreshold) + schemaField("vpn.advanced.windowProtocols", "Window protocols (comma-sep)", + text: $fields.advWindowProtocols) + schemaField("vpn.advanced.windowPorts", "Window ports (comma-sep)", + text: $fields.advWindowPorts) } } Section { @@ -359,6 +337,56 @@ struct SettingsView: View { } } + /// Reads the key schema once per pane opening. Off the main thread because + /// it shells out, like every other CLI read here. + /// + /// It is only re-read on open rather than watched: the schema is a property + /// of the installed binary, so the one thing that can change it — an upgrade + /// — also relaunches the app. + private func refreshSchema() { + DispatchQueue.global(qos: .userInitiated).async { + let loaded = DezhbanCLI.readSchema() + DispatchQueue.main.async { schema = loaded } + } + } + + /// Placeholder for a text field: the concept's name and its real default, + /// from the daemon. `fallback` is used only when the schema is unavailable, + /// and deliberately states no value — a stale hardcoded default is exactly + /// what this replaces. + private func hint(_ key: String, _ fallback: String) -> String { + schema?.placeholder(for: key, fallback: fallback) ?? fallback + } + + /// Help text for a field, from the daemon's schema. + private func helpText(_ key: String) -> String? { schema?.help(for: key) } + + /// A text field for one config key, labelled and explained from the schema. + /// + /// `fallback` is the label to use when the schema is unavailable. It names + /// the concept but states no default, because the whole point of this change + /// is that the app no longer holds an opinion about what a default is. + @ViewBuilder + private func schemaField(_ key: String, _ fallback: String, text: Binding) -> some View { + let field = TextField(hint(key, fallback), text: text).disabled(!canApply) + if let help = helpText(key) { + field.help(help) + } else { + field + } + } + + /// A toggle for one boolean config key, labelled and explained from the schema. + @ViewBuilder + private func schemaToggle(_ key: String, _ fallback: String, isOn: Binding) -> some View { + let toggle = Toggle(schema?[key]?.label ?? fallback, isOn: isOn).disabled(!canApply) + if let help = helpText(key) { + toggle.help(help) + } else { + toggle + } + } + // MARK: - reset to defaults /// Restores every tunable to its shipped default via `config reset --all`, @@ -508,6 +536,7 @@ struct SettingsView: View { fields = SettingsFields() state.refreshServiceState() refreshPresets() + refreshSchema() // `path` is the same resolution ConfigApply.seed already did for the // `config get` calls — reusing it here means configPath never needs its // own second background resolve, so there's nothing to race. @@ -531,10 +560,17 @@ struct SettingsView: View { // MARK: - apply (staged fields) private func apply() { - for (label, value) in fields.durationFieldsForValidation { - guard DurationText.looksLikeGoDuration(value) else { - ConfigApply.invalidDurationAlert(label, value) - return + // Which fields are durations, and what they are called, both come from + // the daemon's schema. With no schema there is nothing to pre-validate + // against, and the daemon still rejects a malformed duration and says + // so — better than guessing the field set or inventing labels that + // disagree with the ones on screen. + if let schema { + for (label, value) in fields.durationFieldsForValidation(schema: schema) { + guard DurationText.looksLikeGoDuration(value) else { + ConfigApply.invalidDurationAlert(label, value) + return + } } } diff --git a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift index ba097e2..178a8ea 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift @@ -1,14 +1,74 @@ +import Foundation import Testing @testable import DezhbanCore +/// Builds a schema good enough to exercise the pane's use of it, without +/// shelling out to the CLI. Kinds and labels match what `config schema --json` +/// reports for these keys. +private func testSchema() -> ConfigSchema { + func tunable(_ key: String, _ label: String, _ kind: String, + defaultValue: String = "", capKey: String? = nil, + disablable: Bool = false) -> ConfigTunable { + let json = """ + {"key":"\(key)","label":"\(label)","kind":"\(kind)","default":"\(defaultValue)", + \(capKey.map { "\"capKey\":\"\($0)\"," } ?? "") + "disablable":\(disablable),"advanced":false,"preset":false, + "help":"help for \(key)","docAnchor":"usage/config.md#fields"} + """ + return try! JSONDecoder().decode(ConfigTunable.self, from: Data(json.utf8)) + } + + return ConfigSchema([ + tunable("vpn.tunnelInterfaces", "Your VPN tunnel", "list"), + tunable("vpn.endpoints", "VPN server addresses", "list"), + tunable("vpn.autoDetect", "Find my VPN tunnel automatically", "bool", defaultValue: "true"), + tunable("vpn.autoDiscoverEndpoints", "Find the VPN server address automatically", "bool", + defaultValue: "true"), + tunable("vpn.autoArm", "Arm the guard when a VPN connects", "bool", defaultValue: "true"), + tunable("vpn.allowLocalNetwork", "Keep local devices reachable", "bool", defaultValue: "true"), + tunable("blockedCountries", "Blocked countries", "list", defaultValue: "IR,RU,KP"), + tunable("pollInterval", "Exit country check interval", "duration", defaultValue: "15s"), + tunable("vpn.switchWindow", "Switch window", "duration", defaultValue: "5s", + capKey: "vpn.advanced.switchWindowMax", disablable: true), + tunable("vpn.redialWindow", "Redial window", "duration", defaultValue: "30s", + capKey: "vpn.advanced.redialWindowMax", disablable: true), + tunable("vpn.pauseMax", "Longest pause", "duration", defaultValue: "30m0s", disablable: true), + tunable("vpn.endpointGrace", "VPN server address grace", "duration", defaultValue: "15m0s"), + tunable("vpn.endpointRefresh", "VPN server address refresh", "duration", defaultValue: "1m0s"), + tunable("vpn.tunnelWatch", "Tunnel check interval", "duration", defaultValue: "1s"), + tunable("vpn.advanced.switchWindowMax", "Switch window cap", "duration", defaultValue: "3m0s"), + tunable("vpn.advanced.redialWindowMax", "Redial window cap", "duration", defaultValue: "10m0s"), + tunable("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", "duration", + defaultValue: "15s", disablable: true), + tunable("vpn.advanced.commandFreshness", "Command freshness", "duration", defaultValue: "30s"), + tunable("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", "duration", + defaultValue: "1s"), + tunable("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", "duration", defaultValue: "1m0s"), + tunable("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", "duration", + defaultValue: "720h0m0s"), + tunable("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", "int", + defaultValue: "16"), + tunable("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", "int", + defaultValue: "3"), + tunable("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", "int", + defaultValue: "256"), + tunable("vpn.advanced.windowProtocols", "Window protocols", "list"), + tunable("vpn.advanced.windowPorts", "Window ports", "list"), + ]) +} + struct SettingsFieldsTests { - @Test func keysAndPairsHaveTheSameCount() { - #expect(SettingsFields.keys.count == SettingsFields().pairs().count) + @Test func pairsCoverEveryStagedKey() { + let pairs = SettingsFields().pairs() + #expect(pairs.count == SettingsFields.keys.count) + for (key, pair) in zip(SettingsFields.keys, pairs) { + #expect(pair.hasPrefix("\(key)="), "expected pair \"\(pair)\" to start with \"\(key)=\"") + } } - @Test func everyPairsEntryIsKeyedByTheCorrespondingKey() { - // Ordering matters: init(seeded:) and pairs() must agree on which index - // is which key, or a value gets staged under the WRONG key silently. + /// The bug this storage change exists to make unrepresentable: a value must + /// come back out under the key it went in under, whatever the key order is. + @Test func everyValueIsStagedUnderItsOwnKey() { var f = SettingsFields() f.tunnelInterfaces = "utun9" f.endpoints = "203.0.113.5" @@ -20,6 +80,7 @@ struct SettingsFieldsTests { f.pollInterval = "20s" f.switchWindow = "10s" f.redialWindow = "40s" + f.pauseMax = "45m" f.endpointGrace = "5m" f.endpointRefresh = "1m" f.tunnelWatch = "2s" @@ -36,46 +97,140 @@ struct SettingsFieldsTests { f.advWindowProtocols = "udp,tcp" f.advWindowPorts = "51820,443" + // Named accessor and keyed lookup are the same storage, not two copies. + #expect(f.value(for: "vpn.tunnelInterfaces") == "utun9") + #expect(f.value(for: "vpn.switchWindow") == "10s") + #expect(f.value(for: "vpn.pauseMax") == "45m") + #expect(f.value(for: "vpn.advanced.windowPorts") == "51820,443") + #expect(f.value(for: "vpn.autoDetect") == "true") + #expect(f.value(for: "vpn.allowLocalNetwork") == "false") + let pairs = f.pairs() - for (key, pair) in zip(SettingsFields.keys, pairs) { - #expect(pair.hasPrefix("\(key)="), "expected pair \"\(pair)\" to start with \"\(key)=\"") - } + #expect(pairs.contains("vpn.switchWindow=10s")) + #expect(pairs.contains("vpn.pauseMax=45m")) + #expect(pairs.contains("blockedCountries=IR,RU")) } - @Test func seedThenPairsRoundTrips() { - // Values in SettingsFields.keys order. + @Test func seedThenCurrentValuesRoundTrips() { let seeded = [ - "utun4", "203.0.113.9", - "true", "true", "false", - "true", - "IR,RU", "15s", - "5s", "30s", "15m", - "1m", "1s", - "3m", "10m", "15s", - "30s", "1s", "60s", - "720h", "16", "3", - "256", "udp", "51820", + "vpn.tunnelInterfaces": "utun4", + "vpn.endpoints": "203.0.113.9", + "vpn.autoDetect": "true", + "vpn.switchWindow": "5s", + "vpn.pauseMax": "30m0s", + "vpn.advanced.windowPorts": "51820", ] let f = SettingsFields(seeded: seeded) - #expect(f.currentValues == seeded) + for (key, value) in seeded { + #expect(f.value(for: key) == value) + } + #expect(f.tunnelInterfaces == "utun4") + #expect(f.autoDetect) + } + + /// A key the seed did not include must keep its resting value rather than + /// taking a neighbour's — the failure the positional version could not rule out. + @Test func seedingASubsetLeavesOtherKeysAlone() { + let f = SettingsFields(seeded: ["vpn.switchWindow": "12s"]) + #expect(f.switchWindow == "12s") + #expect(f.redialWindow == "") + #expect(f.pollInterval == "") + } + + /// A key this pane does not stage must not create storage by being written. + @Test func unknownKeysAreIgnored() { + var f = SettingsFields(seeded: ["control.group": "wheel"]) + #expect(f.value(for: "control.group") == "") + f.setValue("wheel", for: "control.group") + #expect(f.value(for: "control.group") == "") + #expect(!f.pairs().contains { $0.hasPrefix("control.group=") }) } - @Test func durationFieldsForValidationCoversEveryDurationKey() { + /// Labels now come from the daemon, so the pane and the validation alert + /// cannot call the same field two different things. + @Test func durationValidationUsesSchemaLabelsAndCoversEveryDurationKey() { let f = SettingsFields() - let labels = Set(f.durationFieldsForValidation.map(\.label)) + let labels = Set(f.durationFieldsForValidation(schema: testSchema()).map(\.label)) #expect(labels == [ - "Geo IP lookup interval", "Switch window", "Redial window", - "Endpoint grace", "Endpoint refresh", "Tunnel watch", - "Manual switch window cap", "Redial window cap", "Redial anti-flap uptime", + "Exit country check interval", "Switch window", "Redial window", "Longest pause", + "VPN server address grace", "VPN server address refresh", "Tunnel check interval", + "Switch window cap", "Redial window cap", "Redial anti-flap uptime", "Command freshness", "Window discovery interval", "Tunnel prune delay", - "Learned endpoint TTL", + "Learned address lifetime", ]) } - @Test func durationFieldsForValidationTrimsWhitespace() { + @Test func durationValidationTrimsWhitespace() { var f = SettingsFields() f.pollInterval = " 15s " - let entry = f.durationFieldsForValidation.first { $0.label == "Geo IP lookup interval" } + let entry = f.durationFieldsForValidation(schema: testSchema()) + .first { $0.label == "Exit country check interval" } #expect(entry?.value == "15s") } + + /// Without a schema there is nothing to pre-validate. Returning nothing is + /// deliberate: the daemon still rejects a malformed duration, whereas + /// guessing the field set would miss one or invent a label. + @Test func durationValidationIsEmptyWithoutASchema() { + let f = SettingsFields() + #expect(f.durationFieldsForValidation(schema: ConfigSchema([])).isEmpty) + } +} + +struct ConfigSchemaTests { + @Test func decodesTheDaemonsShape() { + let json = """ + [{"key":"vpn.redialWindow","label":"Redial window","kind":"duration","default":"30s", + "capKey":"vpn.advanced.redialWindowMax","disablable":true,"advanced":false,"preset":true, + "help":"How long the guard relaxes by itself.","docAnchor":"usage/config.md#vpn-block", + "restartReason":""}] + """ + let schema = ConfigSchema.decode(Data(json.utf8)) + let tunable = try! #require(schema?["vpn.redialWindow"]) + #expect(tunable.label == "Redial window") + #expect(tunable.defaultValue == "30s") + #expect(tunable.capKey == "vpn.advanced.redialWindowMax") + #expect(tunable.disablable) + #expect(tunable.preset) + #expect(tunable.appliesLive) + } + + @Test func aRestartReasonMeansItDoesNotApplyLive() { + let json = """ + [{"key":"logLevel","label":"Log level","kind":"string","default":"info", + "disablable":false,"advanced":false,"preset":false,"help":"h", + "docAnchor":"usage/config.md#fields", + "restartReason":"the logger is wired up before the run loop starts"}] + """ + let schema = try! #require(ConfigSchema.decode(Data(json.utf8))) + #expect(!(schema["logLevel"]?.appliesLive ?? true)) + #expect(schema.restartRequired(among: ["logLevel", "unknown"]) == ["logLevel"]) + } + + /// The placeholder states the real default, which is the whole point: the + /// pane used to say "e.g. 30s" for a key whose default was 1m. + @Test func placeholderStatesTheRealDefault() { + let schema = testSchema() + #expect(schema["vpn.endpointRefresh"]?.placeholder == "VPN server address refresh (default 1m0s)") + // No default to state (an empty list): just the concept's name. + #expect(schema["vpn.advanced.windowPorts"]?.placeholder == "Window ports") + } + + /// A missing schema must degrade to a plainer label, never to a wrong value. + @Test func placeholderFallsBackWithoutASchema() { + let empty = ConfigSchema([]) + #expect(empty.placeholder(for: "vpn.redialWindow", fallback: "Redial window") == "Redial window") + #expect(empty.help(for: "vpn.redialWindow") == nil) + } + + /// The ceiling is read from the live config, not from the cap's default — + /// lowering a cap by hand must actually lower the control's top. + @Test func capResolvesThroughSeededValues() { + let schema = testSchema() + #expect(schema.cap(for: "vpn.switchWindow", in: ["vpn.advanced.switchWindowMax": "90s"]) == "90s") + // Falls back to the cap's own default when the value is not to hand. + #expect(schema.cap(for: "vpn.switchWindow", in: [:]) == "3m0s") + // An unbounded key has no ceiling. + #expect(schema.cap(for: "vpn.pauseMax", in: [:]) == nil) + } } From 22a76b9d88d40894a0237b64e57fbaa76a168c40 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 19:41:38 +0330 Subject: [PATCH 05/27] feat(state): record the moment the guard cut a dropped tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state.Snapshot gains Drop{at, cut}, set on a tunnel-down edge and carried until a tunnel is up again. The cut was unobservable on the common path. runGuard opens the automatic redial window inside the same tunnel-down edge, so the snapshot showing the guard holding a downed tunnel was superseded within microseconds — and observers read the state file about once a second. Publishing it in the right order alone would not have been enough: nobody would ever have read that snapshot. So the drop is both published BEFORE anything relaxes and carried across whatever follows, which is what lets a surface say "your VPN dropped at 3:04PM" instead of only "a window is open". Carrying it does not make a window look like a cut. An open window is a relaxation — traffic is flowing and the real IP may be exposed — so the posture stays amber and Drop describes what happened, not what is happening. `cut` is false when the drop occurred in standby, where nothing was being enforced and claiming traffic was cut would be a lie. The record clears the moment a tunnel is back, verified exit or not: keeping it longer would leave both surfaces narrating an event that has ended. Two tests, both verified to fail without the change. One pins the ordering (the first snapshot carrying a drop must precede the first switch-window snapshot, and must itself read as guard) — with the early publish removed it fails with both landing on the same snapshot, posture "switch-window", which is exactly the bug. The other pins that the record does not outlive the drop. No surface reads the field yet; the renderer is next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 7 ++ docs/contribute/architecture.md | 18 ++++ internal/runner/recovery_test.go | 4 +- internal/runner/runner.go | 30 ++++++- internal/runner/runner_test.go | 150 ++++++++++++++++++++++++++++++- internal/state/state.go | 29 ++++++ 6 files changed, 232 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b25d32..f68f8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,13 @@ current as you land changes. table, which itself derives the values from the shipped defaults, so the same drift cannot recur. +- **The state file records when your VPN dropped.** `status --json` gains a + `drop` object (`at`, `cut`) present from a tunnel drop until a tunnel is up + again. Until now the moment the guard cut traffic was unobservable on the + common path: the automatic redial window opens on the same edge, so the cut + snapshot was replaced within microseconds while observers read the file about + once a second — leaving both surfaces able to say only "a window is open". + - **`vpn.pauseMax` has a control in the macOS app**, under Windows alongside the switch and redial windows. It was settable from the CLI and reachable by editing the file, but the app offered no way to see or change how long a pause diff --git a/docs/contribute/architecture.md b/docs/contribute/architecture.md index 80afc64..762bea1 100644 --- a/docs/contribute/architecture.md +++ b/docs/contribute/architecture.md @@ -72,10 +72,28 @@ something to describe — absent in STANDBY, before any tunnel is known. "until": "2026-07-01T12:02:00Z", "profile": "newvpn", "trigger": "manual" // "manual" (operator command) | "auto" (redial window on a tunnel drop); absent from older daemons — treat as "manual" + }, + "drop": { // (vpn) present from a tunnel drop until a tunnel is up again + "at": "2026-07-01T12:01:30Z", + "cut": true // the guard was enforcing at that moment, so traffic really was cut } } ``` +`drop` is carried, not merely published once, and that is the only reason any +surface can report a drop. The run loop opens the automatic redial window on the +same tunnel-down edge, so the snapshot showing the guard holding a downed tunnel +is superseded within microseconds — and observers read this file about once a +second. Without carrying the record across the window, the CLI and the app could +only ever say "a window is open", never "your VPN dropped at 12:01:30 and the +guard cut traffic before relaxing so it could redial". + +Carrying it does **not** make an open window look like a cut: the window is a +relaxation, traffic is flowing, and the posture stays amber. `drop` says what +happened; `posture` says what is happening. `cut` is false when the drop +occurred in standby, where nothing was being enforced and claiming traffic was +cut would be false. + `lookupErr` and `exitUnknown` are mutually exclusive, and the split matters. A lookup that fails because **no tunnel is up** is not a fault — it is the normal state during a switch or redial window, in standby, and across any drop. That diff --git a/internal/runner/recovery_test.go b/internal/runner/recovery_test.go index 3e8019a..1a62d61 100644 --- a/internal/runner/recovery_test.go +++ b/internal/runner/recovery_test.go @@ -29,7 +29,7 @@ func TestSnapshotCarriesTheHysteresisStreak(t *testing.T) { Interval: time.Minute, Publish: func(s state.Snapshot) { got = s }, } - o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "") + o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil) if got.Pending == nil { t.Fatal("no pending flip published while a hysteresis streak was running") @@ -47,7 +47,7 @@ func TestPublishingProgressDoesNotDisturbTheStreak(t *testing.T) { o := Options{Decider: d, Interval: time.Minute, Publish: func(state.Snapshot) {}} for range 5 { - o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "") + o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil) } _, have, _ := d.Pending() if have != 1 { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 93929a2..ccc9e94 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -331,7 +331,7 @@ func anyTunnelUp(tunnels []state.Tunnel) bool { // only a nil check when observability is off. Each call emits a complete snapshot // (the file is replaced atomically), so callers pass the last-known reading even // on tunnel/endpoint events to avoid blanking IP/country between polls. -func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string) { +func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord) { if o.Publish == nil { return } @@ -348,6 +348,7 @@ func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupEr PID: os.Getpid(), ActiveProfile: profile, Switch: win, + Drop: drop, } if r.IP.IsValid() { snap.IP = r.IP.String() @@ -742,8 +743,19 @@ func (o Options) runGuard(ctx context.Context) error { } return &state.SwitchState{Open: true, Until: windowDeadline, Profile: windowProfile, Trigger: windowTrigger} } + // lastDrop is the tunnel drop currently being lived through: set on the + // down edge, carried across whatever follows it (a redial window, a guard + // holding the line), and cleared once a tunnel is up again. + // + // It has to be carried rather than merely published once. The down edge + // opens the redial window in the same loop pass, so a snapshot showing the + // guard holding a downed tunnel is replaced within microseconds, and + // observers poll the state file about once a second — they would never see + // it. Carrying the record is what lets both surfaces say "your VPN dropped + // at 3:04PM" instead of only "a window is open". + var lastDrop *state.DropRecord snapshot := func() { - o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile) + o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop) } rebuild := func() { guard, fullBlock = o.vpnPolicies(tunnels, endpoints, providers) } @@ -1603,8 +1615,22 @@ func (o Options) runGuard(ctx context.Context) error { tryAutoArm(st.Detail) } if wasUp && !st.Up { + // Record the cut, and publish it, BEFORE anything relaxes. + // maybeAutoWindow may open a redial window on this very line, + // which would otherwise make the guard-holding-a-downed-tunnel + // state unreachable on the common path. Cut is false in standby + // because nothing was being enforced, and saying traffic was cut + // would be a lie. + lastDrop = &state.DropRecord{At: time.Now(), Cut: !standby} + snapshot() maybeAutoWindow(time.Now(), st.Detail) } + if st.Up { + // The drop is over the moment a tunnel is back, whether or not + // the exit has been verified yet. Keeping it past that point + // would leave both surfaces narrating an event that has ended. + lastDrop = nil + } // A tunnel coming back while blocked is the strongest hint available // that the forbidden exit may be gone — it is exactly the moment the // user has redialed and is waiting. Going down ends any episode: there diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 6aa86f4..5198cda 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1064,6 +1064,152 @@ func TestVPNAutoRedialWindowOpensAndExpires(t *testing.T) { } } +// The cut must be observable. On a tunnel drop the run loop opens the redial +// window in the same pass, so unless the guard-holding-a-downed-tunnel snapshot +// is published FIRST and the drop is then carried through the window, no +// observer could ever report the drop — they poll the state file about once a +// second and would only ever see "a window is open". +func TestTunnelDropPublishesTheCutBeforeRelaxing(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var mu sync.Mutex + var snaps []state.Snapshot + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(5), + RedialWindow: 50 * time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + firstDrop, firstWindow := -1, -1 + for i, s := range snaps { + if firstDrop == -1 && s.Drop != nil { + firstDrop = i + } + if firstWindow == -1 && s.Posture == "switch-window" { + firstWindow = i + } + } + if firstDrop == -1 { + t.Fatalf("no snapshot carried a Drop record; postures = %v", postures(snaps)) + } + if firstWindow == -1 { + t.Fatalf("the redial window never opened; postures = %v", postures(snaps)) + } + if firstDrop >= firstWindow { + t.Errorf("the drop was first reported at snapshot %d but the window opened at %d — "+ + "the cut must be published before anything relaxes", firstDrop, firstWindow) + } + if got := snaps[firstDrop].Posture; got != "guard" { + t.Errorf("the cut snapshot's posture = %q, want \"guard\" (the guard holding a downed tunnel)", got) + } + if !snaps[firstDrop].Drop.Cut { + t.Error("the cut snapshot reports Cut=false, but the guard was enforcing when the tunnel dropped") + } + + // Carried through the window, or the surface that users actually look at + // still cannot name the drop. + if snaps[firstWindow].Drop == nil { + t.Error("the window snapshot dropped the record; both surfaces would be back to only saying \"window open\"") + } else if !snaps[firstWindow].Drop.At.Equal(snaps[firstDrop].Drop.At) { + t.Error("the window snapshot carries a different drop time than the cut it followed") + } +} + +// The drop record must not outlive the drop: once a tunnel is back, continuing +// to carry it would leave both surfaces narrating an event that has ended. +func TestDropRecordClearsWhenTheTunnelReturns(t *testing.T) { + // Down for the first three samples, then up for the rest. + n := 0 + watcher := &netdetect.Watcher{ + Interval: time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + n++ + if n <= 2 || (n > 4 && n <= 6) { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + if n > 6 { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + return netdetect.TunnelState{} + }, + } + + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var mu sync.Mutex + var snaps []state.Snapshot + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 10 * time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + sawDrop := false + for _, s := range snaps { + if s.Drop != nil { + sawDrop = true + } + } + if !sawDrop { + t.Fatal("the tunnel never dropped in this test; the fixture is wrong") + } + // The last snapshot with a tunnel up must carry no drop. + for i := len(snaps) - 1; i >= 0; i-- { + if !anyTunnelUp(snaps[i].Tunnels) { + continue + } + if snaps[i].Drop != nil { + t.Errorf("snapshot %d has a tunnel up but still carries a drop record", i) + } + break + } +} + +// postures summarizes a snapshot run for failure messages. +func postures(snaps []state.Snapshot) []string { + out := make([]string, 0, len(snaps)) + for _, s := range snaps { + out = append(out, s.Posture) + } + return out +} + // A manual command taking over an already-open AUTO window must keep the // episode's original (auto) exposure cap, never the manual cap — see // Options.RedialWindowMax's doc comment and the windowMax fork in Run's @@ -1244,7 +1390,7 @@ func TestLookupFailureClassification(t *testing.T) { t.Run(c.name, func(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} - o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "") + o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil) if hasErr := got.LookupErr != ""; hasErr != c.wantLookupErr { t.Errorf("LookupErr set = %v, want %v (got %q)", hasErr, c.wantLookupErr, got.LookupErr) @@ -1266,7 +1412,7 @@ func TestSuccessfulLookupSetsNoErrorFields(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} o.publish(false, false, monitor.Reading{CountryCode: "NL"}, nil, nil, - []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "") + []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil) if got.LookupErr != "" || got.ExitUnknown != "" { t.Errorf("a successful lookup set LookupErr=%q ExitUnknown=%q, want both empty", got.LookupErr, got.ExitUnknown) } diff --git a/internal/state/state.go b/internal/state/state.go index b424d97..9c304a6 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -84,6 +84,12 @@ type Snapshot struct { // Pending describes a posture change the daemon is counting toward but has // not committed, present only while a hysteresis streak is running. Pending *PendingFlip `json:"pending,omitempty"` + // Drop records the most recent tunnel drop, from the moment it happened + // until a tunnel is up again — including across the redial window that + // follows, which is the only reason any surface can report the drop at all. + // Additive field: absent from older snapshots, so nil means "no drop is + // being carried", never "no drop has ever happened". + Drop *DropRecord `json:"drop,omitempty"` // Display is the rendered posture sentence — see internal/render, the // package that composes it from this same Snapshot. Carried here for the // one consumer that cannot call Go directly: the macOS menubar app reads @@ -138,6 +144,29 @@ type SwitchState struct { Trigger string `json:"trigger,omitempty"` } +// DropRecord is the moment a healthy tunnel went down and the guard cut traffic, +// carried forward until a tunnel is up again. +// +// It exists because the cut is otherwise unobservable on the common path. The +// run loop opens the automatic redial window on the same tunnel-down edge, so a +// snapshot showing the guard holding a downed tunnel is superseded microseconds +// later, and observers read the state file about once a second — they would +// never see it. Without this, both surfaces could only say "a window is open" +// and never "your VPN dropped at 3:04PM, and here is what happened next". +// +// Carrying it does NOT make the window look like a cut. An open window is a +// relaxation: traffic is flowing and the real IP may be exposed, so it stays +// amber. The record says what happened, not what is happening. +type DropRecord struct { + // At is when the tunnel was observed down. + At time.Time `json:"at"` + // Cut reports that the guard was actually enforcing at that moment, so + // traffic really was cut before anything relaxed. False when the drop + // happened in a posture that was not cutting (standby), where saying + // "traffic was cut" would be a lie. + Cut bool `json:"cut"` +} + // Trigger values for SwitchState.Trigger. Stable identifiers — status --json // consumers match on them. const ( From d9a8b9dc4e94973d83280f4f456d0a729051ab81 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 19:50:19 +0330 Subject: [PATCH 06/27] feat(render): a relaxed guard says what it costs, first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every window sentence now leads with the exposure and when it ends, rather than opening with the machinery and leaving the consequence trailing behind a dash: before: Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM). after: Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect. An open window is the one state in which this tool is not protecting the user. That fact should not be the second half of a subordinate clause. The redial sentence now names when the tunnel actually dropped, from the record the previous commit began carrying: "Your VPN dropped at 3:03PM and the guard relaxed so it can redial." A guard holding a downed tunnel names it too. The drop time is the one detail a user can check against their own experience of the network dying, which is what turns "a window is open" into an account of what happened. Pause reads as a present condition rather than a risk — "You are using your real IP at your request, until 3:04PM" — because during a pause the exposure is the point, not a side effect. Sentences are composed with joinSentences instead of dashes, so each clause stands on its own and the drop time can be woven in or left out without re-punctuating the rest. Both surfaces change together: the app displays these strings and never composes its own. Golden strings updated in the renderer table, the `switch --status` tests, and the one example in cli.md. Two new table cases cover the drop time being present, with a drop time deliberately a minute before the window deadline so a case carrying both cannot pass by confusing them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 8 ++++++ cmd/dezhban/vpn_cmd_test.go | 6 ++-- docs/usage/cli.md | 2 +- internal/render/render.go | 51 ++++++++++++++++++++++++++++++---- internal/render/render_test.go | 41 +++++++++++++++++++++++---- 5 files changed, 93 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f68f8d2..8570933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ current as you land changes. table, which itself derives the values from the shipped defaults, so the same drift cannot recur. +- **A relaxed guard says so first, and names when your VPN dropped.** Every + window sentence now leads with the exposure and when it ends, instead of + opening with the machinery and leaving the consequence trailing after a dash: + *"Your real IP may be exposed until 3:04PM. Your VPN dropped at 3:03PM and the + guard relaxed so it can redial."* A guard holding a downed tunnel names the + drop time too. Both surfaces change together, because both display the same + rendered strings. + - **The state file records when your VPN dropped.** `status --json` gains a `drop` object (`at`, `cut`) present from a tunnel drop until a tunnel is up again. Until now the moment the guard cut traffic was unobservable on the diff --git a/cmd/dezhban/vpn_cmd_test.go b/cmd/dezhban/vpn_cmd_test.go index 410a305..c2d0f0c 100644 --- a/cmd/dezhban/vpn_cmd_test.go +++ b/cmd/dezhban/vpn_cmd_test.go @@ -41,7 +41,7 @@ func TestPrintSwitchStatus(t *testing.T) { Open: true, Until: until, Trigger: state.TriggerManual, Profile: "home-wg", }, }, - want: "switch window: OPEN — Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM). (profile \"home-wg\")\n" + + want: "switch window: OPEN — Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect. (profile \"home-wg\")\n" + "until: 2026-07-25T15:04:00Z\n", }, { @@ -50,7 +50,7 @@ func TestPrintSwitchStatus(t *testing.T) { Posture: "switch-window", Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerAuto}, }, - want: "switch window: OPEN — Your VPN dropped. The guard is relaxed while it redials — your real IP may be exposed until it closes (3:04PM).\n" + + want: "switch window: OPEN — Your real IP may be exposed until 3:04PM. Your VPN dropped and the guard relaxed so it can redial.\n" + "until: 2026-07-25T15:04:00Z\n", }, { @@ -59,7 +59,7 @@ func TestPrintSwitchStatus(t *testing.T) { Posture: "switch-window", Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerPause}, }, - want: "pause: OPEN — Using your real IP at your request. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + + want: "pause: OPEN — You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + "until: 2026-07-25T15:04:00Z\n", }, } diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 8aed9c8..b9654fe 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -331,7 +331,7 @@ carries the exact deadline, since the sentence dates a window only to a wall-clock time and a window can outlive both its date and its minute: ``` -switch window: OPEN — Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM). +switch window: OPEN — Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect. until: 2026-07-25T15:04:00Z ``` diff --git a/internal/render/render.go b/internal/render/render.go index c7d567a..c4aca92 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -155,10 +155,14 @@ func postureDisplay(s state.Snapshot) Display { func guardDisplay(s state.Snapshot) Display { if GuardHoldsDownedTunnel(s) { + detail := "Guard active, but no tunnel is up — all traffic is cut until your VPN redials." + if at := dropTime(s); at != "" { + detail = "Your VPN dropped at " + at + ". " + detail + } return Display{ Key: KeyBlocked, Headline: "VPN down — traffic cut", - Detail: "Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", + Detail: detail, } } return Display{ @@ -221,24 +225,29 @@ func windowDisplay(s state.Snapshot) Display { until = s.Switch.Until.Format(untilFormat) } } + // The exposure and when it ends LEAD every one of these sentences. A relaxed + // guard is the one state where this tool is not protecting the user, so the + // copy must not open with machinery ("Guard relaxed so…") and leave the + // consequence trailing behind a dash. switch trigger { case state.TriggerPause: return Display{ Key: KeyPaused, Headline: "Paused", - Detail: "Using your real IP at your request. " + rearmSentence(until), + Detail: joinSentences(pausedSentence(until), rearmSentence(until)), } case state.TriggerAuto: return Display{ Key: KeyWarning, Headline: "Redial window open", - Detail: "Your VPN dropped. The guard is relaxed while it redials — " + exposedSentence(until), + Detail: joinSentences(exposedSentence(until), redialCause(s)), } default: return Display{ Key: KeyWarning, Headline: "Switch window open", - Detail: "Guard relaxed so a new VPN can connect — " + exposedSentence(until), + Detail: joinSentences(exposedSentence(until), + "The guard is relaxed so a new VPN can connect."), } } } @@ -250,11 +259,41 @@ func rearmSentence(until string) string { return "The guard re-arms automatically at " + until + "." } +// pausedSentence leads with the fact that matters — the real IP is in use — and +// says so as a present condition, not a risk: during a pause the exposure is the +// point, not a side effect. +func pausedSentence(until string) string { + if until == "" { + return "You are using your real IP at your request." + } + return "You are using your real IP at your request, until " + until + "." +} + func exposedSentence(until string) string { if until == "" { - return "your real IP may be exposed until it closes." + return "Your real IP may be exposed until this window closes." + } + return "Your real IP may be exposed until " + until + "." +} + +// redialCause explains why the guard relaxed, after the exposure has already +// been stated. It names the drop time when the snapshot carries one — a drop is +// the one thing a user can check against their own experience of the network +// dying, and it is what turns "a window is open" into an account of what +// happened. +func redialCause(s state.Snapshot) string { + if at := dropTime(s); at != "" { + return "Your VPN dropped at " + at + " and the guard relaxed so it can redial." + } + return "Your VPN dropped and the guard relaxed so it can redial." +} + +// dropTime renders the carried drop's time, or "" when no drop is being carried. +func dropTime(s state.Snapshot) string { + if s.Drop == nil || s.Drop.At.IsZero() { + return "" } - return "your real IP may be exposed until it closes (" + until + ")." + return s.Drop.At.Format(untilFormat) } // lookupNote surfaces a genuine exit-country lookup failure. ExitUnknown is diff --git a/internal/render/render_test.go b/internal/render/render_test.go index e8bcace..4ffe4d3 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -9,6 +9,9 @@ import ( func TestText(t *testing.T) { until := time.Date(2026, 7, 25, 15, 4, 0, 0, time.UTC) + // A minute before the window's deadline, so a case carrying both can tell + // the drop time and the close time apart. + dropAt := time.Date(2026, 7, 25, 15, 3, 0, 0, time.UTC) cases := []struct { name string @@ -54,6 +57,20 @@ func TestText(t *testing.T) { wantHeadline: "VPN down — traffic cut", wantDetail: "Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", }, + { + // The whole point of carrying the drop record: a cut the user can + // check against their own experience of the network dying. + name: "guard holds downed tunnel, drop time known", + snap: state.Snapshot{ + Posture: PostureGuard, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until, Cut: true}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM. Guard active, but no tunnel is up — " + + "all traffic is cut until your VPN redials.", + }, { name: "full block with country", snap: state.Snapshot{Posture: PostureFullBlock, CountryCode: "IR"}, @@ -75,7 +92,7 @@ func TestText(t *testing.T) { }}, wantKey: KeyWarning, wantHeadline: "Switch window open", - wantDetail: "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM).", + wantDetail: "Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect.", }, { name: "automatic redial window", @@ -84,7 +101,21 @@ func TestText(t *testing.T) { }}, wantKey: KeyWarning, wantHeadline: "Redial window open", - wantDetail: "Your VPN dropped. The guard is relaxed while it redials — your real IP may be exposed until it closes (3:04PM).", + wantDetail: "Your real IP may be exposed until 3:04PM. Your VPN dropped and the guard relaxed so it can redial.", + }, + { + // A redial window that still knows when the drop happened reports + // both halves: the exposure and its cause, in that order. + name: "automatic redial window, drop time known", + snap: state.Snapshot{ + Posture: PostureSwitchWindow, + Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerAuto}, + Drop: &state.DropRecord{At: dropAt, Cut: true}, + }, + wantKey: KeyWarning, + wantHeadline: "Redial window open", + wantDetail: "Your real IP may be exposed until 3:04PM. " + + "Your VPN dropped at 3:03PM and the guard relaxed so it can redial.", }, { name: "pause window", @@ -93,14 +124,14 @@ func TestText(t *testing.T) { }}, wantKey: KeyPaused, wantHeadline: "Paused", - wantDetail: "Using your real IP at your request. The guard re-arms automatically at 3:04PM.", + wantDetail: "You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM.", }, { name: "switch window with no Switch struct falls back to manual wording", snap: state.Snapshot{Posture: PostureSwitchWindow}, wantKey: KeyWarning, wantHeadline: "Switch window open", - wantDetail: "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes.", + wantDetail: "Your real IP may be exposed until this window closes. The guard is relaxed so a new VPN can connect.", }, { name: "unknown posture", @@ -217,7 +248,7 @@ func TestPostureIgnoresEnforcementErrAndNotes(t *testing.T) { } got := Posture(snap) - want := "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM)." + want := "Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect." if got.Key != KeyWarning || got.Headline != "Switch window open" || got.Detail != want { t.Errorf("Posture() = %+v, want the window sentence alone (detail %q)", got, want) } From c6332eb31b627492a95a43e10236bacf2168a387 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:06:38 +0330 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20hold=20the=20line=20=E2=80=94=20k?= =?UTF-8?q?eep=20a=20deliberate=20disconnect=20cut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dezhban cannot tell a VPN you turned off from a VPN that fell over, so it treats every drop the same way and opens an automatic redial window. When you are the one disconnecting, that is a relaxation nobody asked for — and the reported confusion about the icon staying amber was really this: the tool was doing the right thing for the wrong drop. `dezhban hold` arms a one-shot flag; the next tunnel drop then stays cut, with the icon red because traffic really is cut. `hold --status` reports it, `hold --cancel` disarms it. New control ops hold-arm / hold-cancel, mirrored as command-file ops so it still works when no daemon answers the socket, plus state.HoldState on the snapshot. It only ever SUPPRESSES. The three sanctioned relaxation triggers are unchanged and there is no fourth, which is why this needs no ADR. It also carries no control.allow* gate, deliberately: every such gate exists to withhold an authority, this op grants none, and adding one would only hand an operator a way to switch off the safer behaviour. CLAUDE.md records both points, since the next person to read the three-triggers invariant needs to know why this is not a fourth. One-shot and un-persisted, three ways: spent inside maybeAutoWindow by the drop it covers, disarmed on a tunnel-up edge (the deliberate disconnect it was armed for is over), and gone on restart. An armed flag surviving a reboot would leave a later ACCIDENTAL drop with no redial help — the one failure this feature must never cause. The spend happens after the "is there even a window to suppress" checks but before the flap guard, so arming is only consumed when it actually prevented something. Arming while vpn.redialWindow is "0" is refused rather than reported as success: there is nothing to hold. Three tests. One arms and asserts no switch window is ever applied; one runs the identical fixture WITHOUT arming and asserts a window does open, so the first cannot pass because the fixture never dropped; one drives two drops and asserts the second opens a window, pinning that the arm does not outlive the drop it covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 14 +++ CLAUDE.md | 14 ++- cmd/dezhban/completion.go | 2 +- cmd/dezhban/main.go | 14 +-- cmd/dezhban/vpn_cmd.go | 86 +++++++++++++++++++ docs/concepts/glossary.md | 11 +++ docs/contribute/architecture.md | 4 + docs/usage/cli.md | 23 +++++ internal/command/command.go | 6 ++ internal/control/protocol.go | 13 +++ internal/control/server.go | 3 +- internal/runner/recovery_test.go | 4 +- internal/runner/runner.go | 78 ++++++++++++++++- internal/runner/runner_test.go | 143 ++++++++++++++++++++++++++++++- internal/state/state.go | 24 ++++++ 15 files changed, 425 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8570933..5ecdf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ current as you land changes. table, which itself derives the values from the shipped defaults, so the same drift cannot recur. +- **`dezhban hold` keeps a deliberate disconnect cut.** dezhban cannot tell a VPN + you turned off from a VPN that fell over, so it treats every drop the same way + and opens a redial window — a relaxation you never asked for when you are the + one disconnecting. Arm hold the line first and the next drop stays cut, with + the icon red because traffic really is cut. `hold --status` reports it, + `hold --cancel` disarms it. + + It only ever **removes** a relaxation, so the three sanctioned triggers are + unchanged and there is no fourth — and it carries no `control.allow*` gate, + because there is no authority to withhold. One-shot on purpose: spent by the + drop it covers, disarmed once a tunnel is back, and forgotten if the daemon + restarts, so a flag left armed can never cost a later *accidental* drop the + redial help it should have had. `status --json` gains a `hold` object. + - **A relaxed guard says so first, and names when your VPN dropped.** Every window sentence now leads with the exposure and when it ends, instead of opening with the machinery and leaving the consequence trailing after a dash: diff --git a/CLAUDE.md b/CLAUDE.md index e14b8c1..bb99c76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ dev tooling only, never the daemon path); non-TTY prints the grouped menu Subcommands: `run`, `block`, `unblock`, `status`, `panic`, `install`, `uninstall`, `start`, `stop`, `restart`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, -`switch`, `pause`, `resume`, `vpn`, `setup`, `config`, `token`, `completion`, +`switch`, `pause`, `resume`, `hold`, `vpn`, `setup`, `config`, `token`, `completion`, `upgrade`, `version`, `help` (also `--help`/`-h`; `--version` aliases `version`), plus three globals: `-v`/`--verbose`, `--no-sudo` (skip auto-elevation), `--no-daemon` (skip the control socket, act on the firewall directly). @@ -182,6 +182,18 @@ The design depends on these invariants (rationale in trigger at first open (`Run`'s `openWindow` closure) exist for exactly this reason. Never widen a window past its own cap, never add a FOURTH trigger without a new ADR, never let any of the three outlive its deadline. +- **"Hold the line" SUPPRESSES trigger 2; it is not a fourth trigger.** + `dezhban hold` arms a one-shot flag that makes the next tunnel drop stay cut + instead of opening the automatic redial window — it answers the one thing the + daemon cannot infer, whether a disconnect was deliberate. It only ever + *removes* a relaxation, which is why it needs no ADR and carries **no + `control.allow*` gate**: every such gate exists to withhold an authority, this + op grants none, and adding one would only hand an operator a way to switch off + the safer behaviour. Keep it one-shot and un-persisted — spent by the drop it + covers (`maybeAutoWindow`), disarmed on a tunnel-up edge, gone on restart. An + armed flag surviving a reboot would leave a later *accidental* drop with no + redial help, which is the one failure this feature must never cause. Anything + added here must likewise only subtract. - **All three windows are independently disableable, and "disabled" must survive `Normalize`.** `vpn.switchWindow: "0"` removes trigger (1); `vpn.redialWindow: "0"` removes trigger (2); `vpn.pauseMax: "0"` removes diff --git a/cmd/dezhban/completion.go b/cmd/dezhban/completion.go index 685b78e..d5aa4cb 100644 --- a/cmd/dezhban/completion.go +++ b/cmd/dezhban/completion.go @@ -40,7 +40,7 @@ func cmdCompletion(args []string) int { // completionCommands is the subcommand list the scripts offer. Kept next to the // scripts so it is obvious to update when a command is added. -const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn switch pause resume vpn setup config token completion upgrade version help" +const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn switch pause resume hold vpn setup config token completion upgrade version help" const bashCompletion = `# dezhban bash completion _dezhban() { diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 95cc171..9bbb977 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -77,6 +77,7 @@ Commands: switch Open a bounded window to connect a brand-new VPN (learns its server) pause Open a bounded pause: real ISP IP for a while, then re-arms itself resume End an open pause early + hold Keep the next VPN drop cut: no automatic redial window vpn Manage VPN profiles and learned endpoints (list/add/remove/import/…) setup Interactive wizard to create or update the config config Inspect or change the config without hand-editing JSON @@ -90,11 +91,12 @@ Global flags: --no-sudo Don't auto-elevate; print the root error instead --no-daemon Don't use the daemon's control socket; act on the firewall directly -block, unblock, switch, pause and resume ask the running daemon over its control -socket, which needs no password (see the "daemon control" line in dezhban -status). With no daemon listening, block/unblock fall back to acting on the -firewall directly; switch/pause/resume fall back to the root-owned command file, -which needs a running daemon to consume it — either way, needing root. +block, unblock, switch, pause, resume and hold ask the running daemon over its +control socket, which needs no password (see the "daemon control" line in +dezhban status). With no daemon listening, block/unblock fall back to acting on +the firewall directly; switch/pause/resume/hold fall back to the root-owned +command file, which needs a running daemon to consume it — either way, needing +root. Privileged commands re-run themselves under sudo automatically when not root (unix, interactive terminal). Use --no-sudo (or DEZHBAN_NO_SUDO=1) to opt out. @@ -147,6 +149,8 @@ func run(args []string) int { return cmdPause(rest) case "resume": return cmdResume(rest) + case "hold": + return cmdHold(rest) case "vpn": return cmdVPN(rest) case "setup": diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 7228285..5ea1235 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -125,6 +125,92 @@ func cmdSwitch(args []string) int { // itself with no further action. For deliberately, temporarily using the real // ISP IP — e.g. a sanctioned-country-only service the VPN's exit can't reach — // without leaving protection off by mistake afterward. +// cmdHold arms or disarms "hold the line": the next tunnel drop stays cut +// instead of opening an automatic redial window. +// +// dezhban cannot tell a deliberate disconnect from an accidental drop, and that +// ambiguity is what this answers. It is strictly MORE restrictive than the +// default — it only removes a relaxation — so it adds no fourth trigger, needs +// no ADR, and carries no control.allow* gate of its own: there is no authority +// here to withhold. +func cmdHold(args []string) int { + fs := flag.NewFlagSet("hold", flag.ExitOnError) + cfgPath := fs.String("config", "", "path to config file (JSON)") + cancel := fs.Bool("cancel", false, "disarm, so the next drop opens a redial window normally") + statusOnly := fs.Bool("status", false, "report whether it is armed, change nothing") + _ = fs.Parse(args) + + if *cancel && *statusOnly { + fmt.Fprintln(os.Stderr, "hold: --cancel and --status are mutually exclusive") + return 2 + } + + if *statusOnly { + return holdStatus(*cfgPath) + } + + // Refusing early beats reporting success for something that cannot take + // effect: with no automatic window there is nothing to suppress. + if !*cancel { + if cfg, err := loadConfig(*cfgPath); err == nil && cfg.VPN.RedialWindow <= 0 { + fmt.Fprintln(os.Stderr, "hold: the automatic redial window is already disabled "+ + "(vpn.redialWindow: \"0\"), so every drop already stays cut.") + return 1 + } + } + + op, fileOp := control.OpHoldArm, command.OpHoldArm + armedMsg := "hold the line armed — the next VPN drop stays cut, and no redial window opens.\n" + + "It disarms itself once a tunnel is up again, or with `dezhban hold --cancel`." + if *cancel { + op, fileOp = control.OpHoldCancel, command.OpHoldCancel + armedMsg = "hold the line disarmed — the next VPN drop opens a redial window as usual." + } + + // Passwordless path first, same shape as pause/switch: the daemon does it + // itself, and the root-owned command file is the fallback when no daemon + // answers. + if !noDaemon() { + if code, handled := tryControl(*cfgPath, control.Request{Op: op}); handled { + if code == 0 { + fmt.Println(armedMsg) + } + return code + } + } + + if !requireRoot("hold") { + return 1 + } + if err := command.Write(defaultCommandPath(), newCommand(fileOp, "", "")); err != nil { + fmt.Fprintln(os.Stderr, "hold:", err) + return 1 + } + fmt.Println(armedMsg) + return 0 +} + +// holdStatus reports from the published snapshot, like `switch --status`. +func holdStatus(cfgPath string) int { + snap, err := state.Read(defaultStatePath()) + if err != nil { + fmt.Fprintln(os.Stderr, "hold --status: no state file — is dezhban running?") + return 1 + } + if cfg, err := loadConfig(cfgPath); err == nil && cfg.VPN.RedialWindow <= 0 { + fmt.Println("hold the line: not needed — the automatic redial window is disabled " + + "(vpn.redialWindow: \"0\"), so every drop already stays cut.") + return 0 + } + if snap.Hold != nil && snap.Hold.Armed { + fmt.Printf("hold the line: ARMED since %s — the next VPN drop stays cut.\n", + snap.Hold.At.Format(time.Kitchen)) + return 0 + } + fmt.Println("hold the line: not armed — a VPN drop opens a redial window so it can redial.") + return 0 +} + func cmdPause(args []string) int { fs := flag.NewFlagSet("pause", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index ea90444..468190b 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -102,6 +102,17 @@ gate (`control.allowPauseOps`), and `switch --cancel` refuses to touch it (use `resume`). See [ADR-0008](../adr/0008-arm-at-boot.md). User-facing: "Paused — the guard re-arms automatically at «time»." +**Hold the line** — an armed intent that the NEXT tunnel drop stays cut: no +redial window opens, so a deliberate disconnect does not get a relaxation nobody +asked for. **Not a fourth trigger.** It is the only thing in this section that +*removes* a relaxation rather than granting one, which is also why it has no +`control.allow*` gate — there is no authority to withhold. One-shot: spent by the +drop it covers, disarmed when a tunnel returns, and gone on restart, so a +forgotten flag can never leave a later accidental drop without redial help. +`dezhban hold` / `hold --cancel` / `hold --status`. User-facing: "The next VPN +drop stays cut." Contrast with Pause, which is its opposite in both directions: +pause means "let me use my real IP", hold the line means "keep me cut". + ## Network concepts **Tunnel interface** — the virtual network interface the VPN creates (`utun4`, `tun0`). diff --git a/docs/contribute/architecture.md b/docs/contribute/architecture.md index 762bea1..6d6d680 100644 --- a/docs/contribute/architecture.md +++ b/docs/contribute/architecture.md @@ -76,6 +76,10 @@ something to describe — absent in STANDBY, before any tunnel is known. "drop": { // (vpn) present from a tunnel drop until a tunnel is up again "at": "2026-07-01T12:01:30Z", "cut": true // the guard was enforcing at that moment, so traffic really was cut + }, + "hold": { // (vpn) present only while "hold the line" is armed + "armed": true, // the NEXT tunnel drop stays cut — no automatic redial window + "at": "2026-07-01T12:00:00Z" } } ``` diff --git a/docs/usage/cli.md b/docs/usage/cli.md index b9654fe..7be2462 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -350,6 +350,29 @@ duration and re-arms the guard by itself at the deadline, so there's nothing to remember to turn back on. See [modes.md](../concepts/modes.md#pause--deliberately-using-your-real-ip). +## Keep a deliberate disconnect cut + +dezhban cannot tell a VPN you turned off from a VPN that fell over, so by default +it treats every drop the same way: it opens a redial window so the client can get +back. When you are the one disconnecting, that is a relaxation you never asked +for. Arm **hold the line** first and the next drop stays cut: + +```sh +dezhban hold # the next VPN drop stays cut — no redial window +dezhban hold --status # armed or not +dezhban hold --cancel # back to the usual behaviour +``` + +It is the opposite of `pause` in both directions: pause says *let me use my real +IP*, hold the line says *keep me cut*. It only ever **removes** a relaxation, so +the three sanctioned triggers are unchanged and there is no fourth — which is +also why it needs no `control.allow*` gate of its own. + +It is one-shot on purpose: spent by the drop it covers, disarmed as soon as a +tunnel is up again, and forgotten if the daemon restarts. A flag that survived a +reboot would eventually cut an *accidental* drop off from the redial help it +should have had. + ## Shell completion ```sh diff --git a/internal/command/command.go b/internal/command/command.go index f1b7069..1419e79 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -36,6 +36,12 @@ const ( // PauseMax/AllowPauseOps doc comments. OpPause Op = "pause" OpResume Op = "resume" + // OpHoldArm arms "hold the line" so the next tunnel drop stays cut instead + // of opening an automatic redial window; OpHoldCancel disarms it. Mirror + // control.OpHoldArm/OpHoldCancel for the root-owned command-file path, which + // stays available when no daemon answers the socket. + OpHoldArm Op = "hold-arm" + OpHoldCancel Op = "hold-cancel" ) // Command is one control message. Duration/Profile/Name are op-specific. diff --git a/internal/control/protocol.go b/internal/control/protocol.go index 4fb9cc7..2b2c383 100644 --- a/internal/control/protocol.go +++ b/internal/control/protocol.go @@ -64,6 +64,19 @@ const ( // additionally governed by control.allowConfigOps. A client that cannot // prove it holds the enrolled token is refused, whatever its group. OpConfigWrite Op = "config-write" + // OpHoldArm arms "hold the line": the next tunnel drop stays cut instead of + // opening an automatic redial window, so a deliberate disconnect does not + // get a relaxation the operator never asked for. + // + // Ungated, and deliberately so. Every other gate exists to withhold an + // authority; this op grants none — it only ever SUPPRESSES a relaxation, so + // the worst an unwanted call can do is leave the host more protected than it + // would otherwise be. Adding a control.allow* flag would imply there is + // something here to protect against, and would give an operator a way to + // switch off the safer behaviour. + OpHoldArm Op = "hold-arm" + // OpHoldCancel disarms it, so the next drop opens a redial window normally. + OpHoldCancel Op = "hold-cancel" // OpReload makes the daemon re-read its own config file and adopt whatever // it can without restarting. Ungated, and deliberately so: the config file // is root-owned, so this op grants no authority its caller did not already diff --git a/internal/control/server.go b/internal/control/server.go index 707e4ed..583e4a3 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -212,7 +212,8 @@ func (s *Server) serve(ctx context.Context, conn net.Conn) { } switch req.Op { - case OpStatus, OpBlock, OpUnblock, OpOpenSwitch, OpCancelSwitch, OpPause, OpResume, OpReload: + case OpStatus, OpBlock, OpUnblock, OpOpenSwitch, OpCancelSwitch, OpPause, OpResume, OpReload, + OpHoldArm, OpHoldCancel: case OpConfigWrite: // Authorised here rather than in the run loop, so a request that cannot // prove itself never reaches the goroutine that touches the firewall. diff --git a/internal/runner/recovery_test.go b/internal/runner/recovery_test.go index 1a62d61..e291175 100644 --- a/internal/runner/recovery_test.go +++ b/internal/runner/recovery_test.go @@ -29,7 +29,7 @@ func TestSnapshotCarriesTheHysteresisStreak(t *testing.T) { Interval: time.Minute, Publish: func(s state.Snapshot) { got = s }, } - o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil) + o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil, nil) if got.Pending == nil { t.Fatal("no pending flip published while a hysteresis streak was running") @@ -47,7 +47,7 @@ func TestPublishingProgressDoesNotDisturbTheStreak(t *testing.T) { o := Options{Decider: d, Interval: time.Minute, Publish: func(state.Snapshot) {}} for range 5 { - o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil) + o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil, nil) } _, have, _ := d.Pending() if have != 1 { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ccc9e94..aeec241 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -331,7 +331,7 @@ func anyTunnelUp(tunnels []state.Tunnel) bool { // only a nil check when observability is off. Each call emits a complete snapshot // (the file is replaced atomically), so callers pass the last-known reading even // on tunnel/endpoint events to avoid blanking IP/country between polls. -func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord) { +func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord, hold *state.HoldState) { if o.Publish == nil { return } @@ -349,6 +349,7 @@ func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupEr ActiveProfile: profile, Switch: win, Drop: drop, + Hold: hold, } if r.IP.IsValid() { snap.IP = r.IP.String() @@ -754,8 +755,23 @@ func (o Options) runGuard(ctx context.Context) error { // it. Carrying the record is what lets both surfaces say "your VPN dropped // at 3:04PM" instead of only "a window is open". var lastDrop *state.DropRecord + // holdArmed suppresses the NEXT automatic redial window, so a deliberate + // disconnect stays cut instead of being handed a relaxation nobody asked + // for. It only ever suppresses — there is still no fourth relaxation + // trigger — and it is one-shot: spent by the drop it covers, cleared by a + // tunnel coming back, and gone on restart. Not persisted on purpose, since + // a forgotten armed flag surviving a reboot would leave a later accidental + // drop cut with no redial help. + holdArmed := false + holdArmedAt := time.Time{} + holdState := func() *state.HoldState { + if !holdArmed { + return nil + } + return &state.HoldState{Armed: true, At: holdArmedAt} + } snapshot := func() { - o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop) + o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState()) } rebuild := func() { guard, fullBlock = o.vpnPolicies(tunnels, endpoints, providers) } @@ -972,6 +988,17 @@ func (o Options) runGuard(ctx context.Context) error { if o.RedialWindow <= 0 || windowActive || standby || blocked || !sawTunnelUp { return } + // Hold the line, checked before the flap guard: an operator who said + // this drop is deliberate has answered the only question the window + // exists to guess at. One-shot — spent here, whether or not anything + // else would have suppressed the window anyway, so "armed" never + // silently carries over to a later accidental drop. + if holdArmed { + holdArmed = false + o.Log.Warn("vpn tunnel down — redial window suppressed (hold the line was armed); "+ + "guard holds, traffic stays cut", "detail", detail) + return + } if minUp := o.RedialMinUptime; minUp > 0 && !goodExitThisUp && !tunnelUpSince.IsZero() && now.Sub(tunnelUpSince) < minUp { o.Log.Warn("vpn tunnel down — redial window suppressed (flap guard: tunnel up "+ @@ -1378,6 +1405,32 @@ func (o Options) runGuard(ctx context.Context) error { return reply(false, "resume failed — pause held open, revert is being retried") } return reply(true, "") + + // Hold the line is ungated on purpose: every control.allow* flag exists + // to withhold an authority, and these two grant none — they only ever + // suppress a relaxation. A gate would imply there is something to + // protect against, and would hand an operator a way to switch off the + // safer behaviour. + case control.OpHoldArm: + if o.RedialWindow <= 0 { + // Nothing to suppress. Saying so beats reporting success for an + // action that cannot have an effect. + return reply(false, "the automatic redial window is already disabled (vpn.redialWindow is \"0\"), so there is nothing to hold") + } + if !holdArmed { + holdArmed = true + holdArmedAt = time.Now() + o.Log.Info("hold the line armed — the next tunnel drop will stay cut") + } + snapshot() + return reply(true, "") + case control.OpHoldCancel: + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed (control socket)") + } + snapshot() + return reply(true, "") } return reply(false, fmt.Sprintf("unsupported op %q", req.Op)) } @@ -1630,6 +1683,14 @@ func (o Options) runGuard(ctx context.Context) error { // the exit has been verified yet. Keeping it past that point // would leave both surfaces narrating an event that has ended. lastDrop = nil + // A tunnel coming back also ends the intent behind "hold the + // line": the deliberate disconnect it was armed for is over. + // Leaving it armed would silently cut a LATER, accidental drop + // off from the redial help it should have had. + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed — a tunnel is up again") + } } // A tunnel coming back while blocked is the strongest hint available // that the forbidden exit may be gone — it is exactly the moment the @@ -1707,6 +1768,19 @@ func (o Options) runGuard(ctx context.Context) error { if windowActive && windowTrigger == state.TriggerPause { closeWindowRevert("resumed") } + case command.OpHoldArm: + if o.RedialWindow > 0 && !holdArmed { + holdArmed = true + holdArmedAt = time.Now() + o.Log.Info("hold the line armed — the next tunnel drop will stay cut") + } + snapshot() + case command.OpHoldCancel: + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed") + } + snapshot() default: o.Log.Debug("ignoring unsupported command", "op", cmd.Op) } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 5198cda..701053e 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1201,6 +1201,145 @@ func TestDropRecordClearsWhenTheTunnelReturns(t *testing.T) { } } +// Hold the line must suppress the automatic redial window for exactly one drop: +// a deliberate disconnect stays cut instead of being handed a relaxation nobody +// asked for. It never opens anything — the three sanctioned triggers are +// unchanged — so the assertion is the absence of a switch window. +func TestHoldTheLineSuppressesTheRedialWindow(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + // Arm it over the command file before the tunnel drops. + cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} + sent := 0 + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { + if sent < len(cmds) { + c := cmds[sent] + sent++ + return c, true + } + return command.Command{}, false + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + for _, c := range be.calls { + if c == "apply-switch" { + t.Fatalf("a redial window opened despite hold the line being armed; calls = %v", be.calls) + } + } +} + +// Without the arm, the very same fixture must open a window — otherwise the +// test above would pass for the wrong reason (a fixture that never drops). +func TestWithoutHoldTheSameDropOpensAWindow(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { return command.Command{}, false }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + opened := false + for _, c := range be.calls { + if c == "apply-switch" { + opened = true + } + } + if !opened { + t.Fatalf("the control fixture never opened a window, so the hold test proves nothing; calls = %v", be.calls) + } +} + +// It is one-shot. Arming must not silently persist and cut a LATER, accidental +// drop off from the redial help it should have had. +func TestHoldTheLineIsSpentByTheDropItCovers(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + // up, up, DOWN (hold spends here), up, up, DOWN (must open a window). + n := 0 + watcher := &netdetect.Watcher{ + Interval: 2 * time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + n++ + up := netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + switch { + case n <= 3: + return up + case n <= 6: + return netdetect.TunnelState{} + case n <= 10: + return up + default: + return netdetect.TunnelState{} + } + }, + } + + cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} + sent := 0 + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 30 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { + if sent < len(cmds) { + c := cmds[sent] + sent++ + return c, true + } + return command.Command{}, false + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + switches := 0 + for _, c := range be.calls { + if c == "apply-switch" { + switches++ + } + } + if switches == 0 { + t.Fatalf("the second drop opened no window — hold the line outlived the drop it covered; calls = %v", be.calls) + } +} + // postures summarizes a snapshot run for failure messages. func postures(snaps []state.Snapshot) []string { out := make([]string, 0, len(snaps)) @@ -1390,7 +1529,7 @@ func TestLookupFailureClassification(t *testing.T) { t.Run(c.name, func(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} - o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil) + o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil, nil) if hasErr := got.LookupErr != ""; hasErr != c.wantLookupErr { t.Errorf("LookupErr set = %v, want %v (got %q)", hasErr, c.wantLookupErr, got.LookupErr) @@ -1412,7 +1551,7 @@ func TestSuccessfulLookupSetsNoErrorFields(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} o.publish(false, false, monitor.Reading{CountryCode: "NL"}, nil, nil, - []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil) + []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil, nil) if got.LookupErr != "" || got.ExitUnknown != "" { t.Errorf("a successful lookup set LookupErr=%q ExitUnknown=%q, want both empty", got.LookupErr, got.ExitUnknown) } diff --git a/internal/state/state.go b/internal/state/state.go index 9c304a6..cb2175c 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -90,6 +90,10 @@ type Snapshot struct { // Additive field: absent from older snapshots, so nil means "no drop is // being carried", never "no drop has ever happened". Drop *DropRecord `json:"drop,omitempty"` + // Hold reports that the next tunnel drop will stay cut instead of opening + // an automatic redial window. Present only while armed. Additive field: + // absent from older snapshots, so nil means "not armed". + Hold *HoldState `json:"hold,omitempty"` // Display is the rendered posture sentence — see internal/render, the // package that composes it from this same Snapshot. Carried here for the // one consumer that cannot call Go directly: the macOS menubar app reads @@ -167,6 +171,26 @@ type DropRecord struct { Cut bool `json:"cut"` } +// HoldState reports that "hold the line" is armed: the next tunnel drop will +// NOT open an automatic redial window, so a deliberate disconnect stays cut. +// +// dezhban cannot tell an intentional disconnect from an accidental drop, and +// that ambiguity is the whole reason this exists — disconnecting by hand +// otherwise opens a redial window the user never wanted. Arming says which kind +// of drop the next one will be. +// +// It only ever SUPPRESSES. It removes an automatic relaxation for one drop and +// grants nothing, so the three sanctioned relaxation triggers are unchanged and +// there is no fourth. Being strictly more restrictive is also why it needs no +// config gate of its own: there is no setting to protect. +type HoldState struct { + // Armed is true from the moment it is armed until the drop it covers, an + // explicit cancel, or a tunnel coming back up. + Armed bool `json:"armed"` + // At is when it was armed. + At time.Time `json:"at"` +} + // Trigger values for SwitchState.Trigger. Stable identifiers — status --json // consumers match on them. const ( From 3d9bf8bd14da37d0d0b6a17bacdecc2b83280a79 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:15:06 +0330 Subject: [PATCH 08/27] feat(gui): hold the line, beside pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menubar gains "Hold the line — keep me cut", placed next to Pause so the pair reads as the two answers to the same situation: pause says let me use my real IP, hold the line says keep me cut. When it is armed, the item becomes "Don't hold the line" so the state and the way out of it are the same control. DezhbanCore learns to decode the two new snapshot fields — DropRecord and HoldState — plus a holdArmed convenience. Both are optional and additive, so a snapshot from an older daemon still decodes; absent reads as "not armed", never as a failure. Offered whenever the daemon is running, with no other gating: it can only ever suppress a relaxation, so there is no state in which arming it makes a host less protected. The red icon this makes reachable needed no new logic — PostureUI.guardHoldsDownedTunnel and dockState were already right, and already tested. What was missing was any way to REACH that state on the automatic path, which is what the daemon-side commit fixed. Tests cover the drop record surviving decode across a window (the one moment posture alone says nothing about the drop), a standby drop reading as not-a-cut, and both fields being absent. The window case asserts the drop time is earlier than both the window deadline and the snapshot time rather than pinning an epoch, so it fails if the two timestamps are ever confused for one another. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- gui/macos/Sources/DezhbanCore/Snapshot.swift | 28 ++++++++++ .../Sources/DezhbanMenu/AppDelegate.swift | 19 +++++++ .../DezhbanCoreTests/SnapshotTests.swift | 54 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index 633f684..dba2c6a 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -25,6 +25,29 @@ public struct SwitchState: Codable { public var isPause: Bool { trigger == "pause" } } +/// The moment a healthy tunnel went down and the guard cut traffic — mirrors +/// Go's `state.DropRecord`. Carried from the drop until a tunnel is up again, +/// including across the redial window that follows, which is the only reason any +/// surface can report the drop at all. +public struct DropRecord: Codable { + public let at: Date + /// The guard was enforcing at that moment, so traffic really was cut. False + /// for a drop in standby, where nothing was being enforced. + public let cut: Bool +} + +/// "Hold the line" is armed — mirrors Go's `state.HoldState`. The next tunnel +/// drop stays cut instead of opening an automatic redial window, so a deliberate +/// disconnect does not get a relaxation the user never asked for. +/// +/// It only ever suppresses, so it is not a fourth relaxation trigger and the +/// icon rules are unaffected: it makes the RED state reachable rather than +/// introducing a new colour. +public struct HoldState: Codable { + public let armed: Bool + public let at: Date +} + /// The daemon's posture at a point in time — mirrors Go's `state.Snapshot`. /// JSON keys match the lowerCamelCase struct tags in internal/state/state.go. public struct Snapshot: Codable { @@ -50,9 +73,14 @@ public struct Snapshot: Codable { public let `switch`: SwitchState? // present only while a switch window is open public let pending: PendingFlip? // present only while a posture change is being counted toward public let display: Display? // the rendered sentence — nil from an older daemon + public let drop: DropRecord? // present from a tunnel drop until a tunnel is up again + public let hold: HoldState? // present only while "hold the line" is armed /// Wall-clock age of this snapshot. public var age: TimeInterval { Date().timeIntervalSince(time) } + + /// The next VPN drop will stay cut rather than opening a redial window. + public var holdArmed: Bool { hold?.armed ?? false } } /// The rendered form of a Snapshot — mirrors Go's `render.Display` (carried via diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index b5fee27..66da715 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -282,6 +282,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return AppState.shared.routineHint( "Deliberately drops to your real ISP IP, then re-arms the guard automatically.") }() + + // The mirror image of Pause, and placed beside it so the pair reads + // as the two answers to "I am about to change my VPN situation": + // pause = let me use my real IP, hold the line = keep me cut. + // It only ever suppresses a relaxation, so it is safe to offer + // whenever the daemon is running. + if s?.holdArmed ?? false { + addAction("Don’t hold the line", #selector(cancelHold), enabled: isRunning) + .toolTip = "Armed — the next VPN drop stays cut. Choose this to let it redial normally instead." + } else { + addAction("Hold the line — keep me cut", #selector(holdLine), enabled: isRunning) + .toolTip = isRunning + ? AppState.shared.routineHint( + "For when YOU are disconnecting: the next VPN drop stays cut instead of " + + "opening a window so it can redial.") + : "Unavailable — dezhban isn’t running. Start it first." + } } // Panic is the lockout escape hatch: it must never depend on the main @@ -320,6 +337,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func cancelSwitch() { AppActions.routine(["switch", "--cancel"], "cancel the switch window") } @objc private func pauseNow() { AppActions.routine(["pause"], "pause the guard") } @objc private func resumeNow() { AppActions.routine(["resume"], "resume the guard") } + @objc private func holdLine() { AppActions.routine(["hold"], "hold the line") } + @objc private func cancelHold() { AppActions.routine(["hold", "--cancel"], "cancel hold the line") } /// Menubar panic: confirmation, then a direct privileged run with the result /// in an NSAlert (scrollable transcript) — deliberately NOT routed through diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index 7f0114e..2bef64d 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -63,4 +63,58 @@ struct SnapshotTests { let json = "{ \"time\": \"2026-07-25T10:00:00Z\" }".data(using: .utf8)! #expect(StateReader.decode(json) == nil) } + + /// The drop record is what lets the app report a VPN drop at all, so it has + /// to survive decoding — including across a window, which is precisely when + /// the posture alone says nothing about the drop. + @Test func decodesTheDropRecordCarriedThroughAWindow() { + let json = """ + { + "time": "2026-07-25T10:00:05Z", "posture": "switch-window", "blocked": false, + "switch": { "open": true, "until": "2026-07-25T10:00:35Z", "trigger": "auto" }, + "drop": { "at": "2026-07-25T10:00:00Z", "cut": true } + } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop?.cut == true) + #expect(s.switch?.isAutoRedial == true) + // The drop happened before the window it opened, and the two timestamps + // must not be confused for one another. + let dropAt = try! #require(s.drop?.at) + let until = try! #require(s.switch?.until) + #expect(dropAt < until) + #expect(dropAt < s.time) + } + + /// A drop in standby is not a cut — nothing was being enforced — and the app + /// must not claim otherwise. + @Test func aStandbyDropIsNotACut() { + let json = """ + { "time": "2026-07-25T10:00:05Z", "posture": "standby", "blocked": false, + "drop": { "at": "2026-07-25T10:00:00Z", "cut": false } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop?.cut == false) + } + + @Test func decodesHoldTheLineArmed() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false, + "hold": { "armed": true, "at": "2026-07-25T09:59:00Z" } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.holdArmed) + } + + /// Both fields are additive: a daemon that predates them, or one where + /// nothing is armed and nothing has dropped, must still decode. Absent must + /// read as "not armed", never as a decode failure. + @Test func absentDropAndHoldAreNotAFailure() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop == nil) + #expect(!s.holdArmed) + } } From fe01ed370a32d2616369d46ecc09bfc78bff153b Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:26:41 +0330 Subject: [PATCH 09/27] feat(pause): offer real choices, and stop clamping silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pause lengths are now defined once in the config core (PauseOptions), so the CLI and the app offer the same set. `dezhban pause --list` prints them with what each is for, because the question a user actually has is never "how many seconds" but "how long do I need my real IP for". Any duration up to the cap still works; this is what gets offered, not what is allowed. Lengths above vpn.pauseMax are LISTED as unavailable with the cap named, not hidden. Hiding them teaches the user their cap is something other than it is, and they find out by bumping into it. The behaviour change: a pause longer than the cap is refused and explained rather than shortened to fit. clampPause used to hand back the cap, so asking for an hour against a 30m cap granted thirty minutes and reported success — indistinguishable, from the caller's side, from having got the hour. That is the same class of bug as accepting a disabled window and quietly restoring the default, which the config layer goes to considerable lengths to prevent. Refused in both places on purpose: the CLI refuses early with the cap named, and Options.pauseDuration refuses in the run loop, so a control-socket client that is not our CLI cannot reach the old behaviour either. A pause with NO duration given is deliberately unchanged and still clamped: nobody asked for a particular length, so there is no expectation to violate. TestReloadLowersThePauseCapImmediately previously asserted the clamping directly. It now asserts the refusal names the new cap, and follows up with a within-cap pause that must still succeed — so the test proves the cap bound, rather than that pausing broke. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 17 +++++ cmd/dezhban/vpn_cmd.go | 65 +++++++++++++++-- docs/usage/cli.md | 12 ++++ internal/config/pause_test.go | 106 ++++++++++++++++++++++++++++ internal/config/preset.go | 75 ++++++++++++++++++++ internal/runner/configwrite_test.go | 27 +++++-- internal/runner/runner.go | 44 +++++++++--- 7 files changed, 325 insertions(+), 21 deletions(-) create mode 100644 internal/config/pause_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecdf8d..7c3b67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,23 @@ current as you land changes. editing the file, but the app offered no way to see or change how long a pause may last. +- **`dezhban pause --list` offers realistic pause lengths**, each saying what it + is for, defined once in the config core so the CLI and the macOS app offer the + same choices. Lengths above `vpn.pauseMax` are listed as unavailable with the + cap as the reason rather than hidden — a cap you cannot see is one you keep + bumping into. Any duration up to the cap still works. + +### Changed + +- **A pause longer than `vpn.pauseMax` is now refused and explained, instead of + silently shortened to the cap.** Asking for an hour against a 30-minute cap + used to grant thirty minutes and report success, which is indistinguishable + from having got what you asked for. It now fails, names the cap, and says how + to raise it. Both the CLI and the daemon refuse, so a control-socket client + cannot get the old behaviour either. A pause with *no* duration given is + unchanged: nobody asked for a particular length, so the built-in default is + still clamped to the cap. + ### Fixed - **The macOS app's Settings pane no longer advertises wrong defaults.** Its diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 5ea1235..28db2e9 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -3,6 +3,7 @@ package main import ( "crypto/rand" "encoding/hex" + "encoding/json" "flag" "fmt" "os" @@ -125,6 +126,41 @@ func cmdSwitch(args []string) int { // itself with no further action. For deliberately, temporarily using the real // ISP IP — e.g. a sanctioned-country-only service the VPN's exit can't reach — // without leaving protection off by mistake afterward. +// pauseList prints the offered pause lengths. Options above vpn.pauseMax are +// listed as unavailable with the reason rather than hidden: a user whose cap +// forbids two hours should learn that from the list, not from a refusal after +// they have chosen. +func pauseList(cfgPath string, jsonOut bool) int { + cfg, err := loadConfig(cfgPath) + if err != nil { + fmt.Fprintln(os.Stderr, "pause --list:", err) + return 1 + } + opts := config.PauseOptions(cfg) + + if jsonOut { + data, err := json.MarshalIndent(opts, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "pause --list json:", err) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + for _, o := range opts { + if o.Unavailable != "" { + fmt.Printf(" %-12s unavailable — %s\n", o.Label, o.Unavailable) + continue + } + fmt.Printf(" %-12s %-8s %s\n", o.Label, o.Value, o.Why) + } + if cfg.VPN.PauseMax > 0 { + fmt.Printf("\nAny duration up to %s works: dezhban pause 7m\n", cfg.VPN.PauseMax) + } + return 0 +} + // cmdHold arms or disarms "hold the line": the next tunnel drop stays cut // instead of opening an automatic redial window. // @@ -214,24 +250,39 @@ func holdStatus(cfgPath string) int { func cmdPause(args []string) int { fs := flag.NewFlagSet("pause", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") + list := fs.Bool("list", false, "print the offered pause lengths and stop") + jsonOut := fs.Bool("json", false, "with --list, print machine-readable JSON") _ = fs.Parse(args) + + if *list { + return pauseList(*cfgPath, *jsonOut) + } + dur := "" if fs.NArg() > 0 { dur = fs.Arg(0) } - // Validate here rather than letting the daemon's clampPause silently fall - // back to its default on a typo — `dezhban pause 15x` must error, not - // quietly become a 15m exposure the operator never asked for. + // Validate here rather than letting the daemon fall back to its default on a + // typo — `dezhban pause 15x` must error, not quietly become a 15m exposure + // the operator never asked for. + var requested time.Duration if dur != "" { - if d, err := time.ParseDuration(dur); err != nil || d <= 0 { + d, err := time.ParseDuration(dur) + if err != nil || d <= 0 { fmt.Fprintf(os.Stderr, "pause: invalid duration %q (want e.g. \"15m\")\n", dur) return 2 } + requested = d } - if cfg, err := loadConfig(*cfgPath); err == nil && cfg.VPN.PauseMax <= 0 { - fmt.Fprintln(os.Stderr, "pause: disabled by vpn.pauseMax: \"0\". Set it to a duration (e.g. \"30m\") to enable.") - return 1 + // Refused here and again in the daemon, and in both places explained rather + // than clamped. A pause quietly shortened to the cap is a pause the operator + // did not ask for, and they would have no way to know. + if cfg, err := loadConfig(*cfgPath); err == nil { + if refusal := config.PauseRefusal(cfg, requested); refusal != "" { + fmt.Fprintln(os.Stderr, "pause:", refusal) + return 1 + } } // Passwordless path first: the daemon opens the pause itself when diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7be2462..8941384 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -341,6 +341,7 @@ For the times the *correct* traffic is the one the guard blocks — a domestic-o service that refuses a foreign VPN exit: ```sh +dezhban pause --list # the offered lengths, and what each is for sudo dezhban pause 15m # real IP for 15 minutes, capped by vpn.pauseMax sudo dezhban resume # end it early ``` @@ -350,6 +351,17 @@ duration and re-arms the guard by itself at the deadline, so there's nothing to remember to turn back on. See [modes.md](../concepts/modes.md#pause--deliberately-using-your-real-ip). +`--list` offers a short set of realistic lengths — the question is never "how +many seconds" but "how long do I need my real IP for" — and any duration up to +the cap still works if none of them fit. Lengths above `vpn.pauseMax` are shown +as **unavailable with the cap as the reason**, not hidden: a cap you cannot see +is a cap you will keep bumping into. + +A pause longer than the cap is **refused and explained, never shortened**. Asking +for an hour against a 30-minute cap fails with the cap named, rather than quietly +granting thirty minutes you did not ask for and cannot tell apart from the hour +you wanted. + ## Keep a deliberate disconnect cut dezhban cannot tell a VPN you turned off from a VPN that fell over, so by default diff --git a/internal/config/pause_test.go b/internal/config/pause_test.go new file mode 100644 index 0000000..ed93ff1 --- /dev/null +++ b/internal/config/pause_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// The offered lengths must be usable as typed: a surface passes Value straight +// to `dezhban pause`, so a Value that does not parse back to its own Duration +// would silently grant a different pause than the label promised. +func TestPauseOptionValuesRoundTrip(t *testing.T) { + c := Default() + Normalize(&c) + for _, o := range PauseOptions(&c) { + got, err := time.ParseDuration(o.Value) + if err != nil { + t.Errorf("%s: Value %q does not parse: %v", o.Label, o.Value, err) + continue + } + if got != o.Duration { + t.Errorf("%s: Value %q parses to %v, want %v", o.Label, o.Value, got, o.Duration) + } + if o.Why == "" { + t.Errorf("%s: no reason given; a pause length should be chosen on the need, not the number", o.Label) + } + } +} + +// Over-cap options are LISTED and explained, never hidden and never shortened. +// Hiding them teaches the user their cap is something other than it is. +func TestPauseOptionsMarkOverCapWithoutHiding(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = 20 * time.Minute + + opts := PauseOptions(&c) + if len(opts) != len(pauseOptions) { + t.Fatalf("PauseOptions returned %d of %d — an over-cap option was hidden", len(opts), len(pauseOptions)) + } + for _, o := range opts { + overCap := o.Duration > c.VPN.PauseMax + if overCap && o.Unavailable == "" { + t.Errorf("%s is above the cap but is offered as available", o.Label) + } + if !overCap && o.Unavailable != "" { + t.Errorf("%s is within the cap but is marked unavailable: %s", o.Label, o.Unavailable) + } + if overCap && !strings.Contains(o.Unavailable, "20m") { + t.Errorf("%s: %q does not name the cap, so the user cannot tell what to change", + o.Label, o.Unavailable) + } + } +} + +// With pausing off, every option is unavailable and says why — the reason is +// the disabled setting, not the length. +func TestPauseOptionsWhenPausingIsDisabled(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = Disabled + + for _, o := range PauseOptions(&c) { + if o.Unavailable == "" { + t.Errorf("%s is offered even though pausing is disabled", o.Label) + } + if !strings.Contains(o.Unavailable, "pauseMax") { + t.Errorf("%s: %q does not name the setting that disabled it", o.Label, o.Unavailable) + } + } +} + +// PauseRefusal is where "clamp nothing silently" lives: an over-cap request is +// refused and explained rather than quietly shortened. +func TestPauseRefusal(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = 30 * time.Minute + + if got := PauseRefusal(&c, 15*time.Minute); got != "" { + t.Errorf("a within-cap pause was refused: %s", got) + } + if got := PauseRefusal(&c, 30*time.Minute); got != "" { + t.Errorf("a pause exactly at the cap was refused: %s", got) + } + // Unspecified: the daemon picks its own default, which is its business. + if got := PauseRefusal(&c, 0); got != "" { + t.Errorf("an unspecified duration was refused: %s", got) + } + + over := PauseRefusal(&c, time.Hour) + if over == "" { + t.Fatal("an over-cap pause was accepted; it would have been silently shortened") + } + for _, want := range []string{"1h", "30m", "vpn.pauseMax"} { + if !strings.Contains(over, want) { + t.Errorf("refusal %q does not mention %q — it must name what was asked, the cap, and the key", + over, want) + } + } + + c.VPN.PauseMax = Disabled + if got := PauseRefusal(&c, 5*time.Minute); got == "" { + t.Error("a pause was accepted with pausing disabled") + } +} diff --git a/internal/config/preset.go b/internal/config/preset.go index 4aeb067..6d3b26e 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -253,3 +253,78 @@ func MatchPreset(c *Config) (name string, exact bool) { } return "", false } + +// Pause durations, defined once so the CLI and the app offer the same choices. +// +// A pause is the one relaxation a user asks for on purpose, so the question is +// never "how many seconds" but "how long do I need my real IP for". These are +// the realistic answers to that; anything else is still typeable +// (`dezhban pause 7m`), this is just what gets offered. +var pauseOptions = []PauseOption{ + {Label: "5 minutes", Duration: 5 * time.Minute, Why: "long enough to load one stubborn site"}, + {Label: "15 minutes", Duration: 15 * time.Minute, Why: "a bank transfer or a delivery tracker"}, + {Label: "30 minutes", Duration: 30 * time.Minute, Why: "a form you have to fill in properly"}, + {Label: "1 hour", Duration: time.Hour, Why: "a long call with a domestic-only service"}, + {Label: "2 hours", Duration: 2 * time.Hour, Why: "an evening of something the VPN's exit can't reach"}, +} + +// A PauseOption is one offered pause length. +type PauseOption struct { + Label string `json:"label"` + Duration time.Duration `json:"-"` + // Value is the Duration as `dezhban pause` accepts it, so a surface can + // pass it straight through without formatting a duration itself. + Value string `json:"value"` + // Why says what this length is for, so the choice is made on the need + // rather than on the number. + Why string `json:"why"` + // Unavailable is non-empty when vpn.pauseMax forbids this length, and says + // so in words. The option is still LISTED: silently hiding or shortening a + // choice teaches a user their cap is something other than it is. See the + // "clamp nothing silently" decision in issue #33's second addendum. + Unavailable string `json:"unavailable,omitempty"` +} + +// PauseOptions returns the offered pause lengths for a config, each marked +// available or not against that config's vpn.pauseMax. +// +// Options above the cap are returned, not filtered. A picker must show them as +// unavailable with the reason, because the alternative — quietly dropping them, +// or worse, accepting one and shortening it — leaves the user with a wrong idea +// of what their own setting does. +func PauseOptions(c *Config) []PauseOption { + max := c.VPN.PauseMax + out := make([]PauseOption, 0, len(pauseOptions)) + for _, o := range pauseOptions { + o.Value = durString(o.Duration) + switch { + case max <= 0: + o.Unavailable = "pausing is off (vpn.pauseMax is \"0\")" + case o.Duration > max: + o.Unavailable = fmt.Sprintf("longer than your %s cap (vpn.pauseMax)", durString(max)) + } + out = append(out, o) + } + return out +} + +// PauseRefusal reports why a requested pause duration cannot be granted, or "" +// when it can. +// +// This is where "clamp nothing silently" is enforced. The runner used to shorten +// an over-cap request to the cap, which is the same class of bug as accepting a +// disabled window and restoring the default: the user asked for one thing, got +// another, and was not told. +func PauseRefusal(c *Config, requested time.Duration) string { + switch { + case c.VPN.PauseMax <= 0: + return "pausing is disabled (vpn.pauseMax is \"0\"). Set it to a duration to enable it." + case requested <= 0: + return "" + case requested > c.VPN.PauseMax: + return fmt.Sprintf("%s is longer than your %s cap (vpn.pauseMax). "+ + "Ask for %s or less, or raise the cap with `dezhban config set vpn.pauseMax=%s`.", + durString(requested), durString(c.VPN.PauseMax), durString(c.VPN.PauseMax), durString(requested)) + } + return "" +} diff --git a/internal/runner/configwrite_test.go b/internal/runner/configwrite_test.go index 9a97e27..34555a2 100644 --- a/internal/runner/configwrite_test.go +++ b/internal/runner/configwrite_test.go @@ -181,9 +181,13 @@ func TestConfigWriteUnavailableWithoutAWriter(t *testing.T) { } // Lowering vpn.pauseMax has to bind the very next pause. The cap is a security -// setting: a reload that reported it applied while the loop kept clamping to the +// setting: a reload that reported it applied while the loop kept honouring the // old, larger value would be exactly the silent-discard failure the config layer // goes to such lengths to prevent. +// +// The new cap binds by REFUSING an over-cap request, not by shortening it — see +// Options.pauseDuration. A caller who asked for nine minutes and was handed one +// would have no way to know, which is the same failure in a different costume. func TestReloadLowersThePauseCapImmediately(t *testing.T) { be := &fakeBackend{} w := &writeRecorder{} @@ -212,10 +216,23 @@ func TestReloadLowersThePauseCapImmediately(t *testing.T) { t.Fatalf("config-write refused: %+v", resp) } - // Ask for far more than the new cap allows. + // Ask for far more than the new cap allows: it must be refused, and the + // refusal must name the NEW cap, or the reload did not take effect. + resp := do(t, path, control.Request{Op: control.OpPause, Duration: "9m"}) + if resp.OK { + t.Fatal("a 9m pause was accepted after the cap was lowered to 1m — either the reload " + + "did not bind, or the request was silently shortened") + } + if !strings.Contains(resp.Error, "1m") { + t.Errorf("refusal = %q, want it to name the reloaded 1m cap (the old 10m cap may still be in force)", + resp.Error) + } + + // A request within the new cap still works, so the refusal above is the cap + // binding rather than pausing having broken outright. before := time.Now() - if resp := do(t, path, control.Request{Op: control.OpPause, Duration: "9m"}); !resp.OK { - t.Fatalf("pause refused: %+v", resp) + if resp := do(t, path, control.Request{Op: control.OpPause, Duration: "30s"}); !resp.OK { + t.Fatalf("a within-cap pause was refused: %+v", resp) } var until time.Time @@ -233,6 +250,6 @@ func TestReloadLowersThePauseCapImmediately(t *testing.T) { t.Fatal("no snapshot reported an open pause") } if got := until.Sub(before); got > 2*time.Minute { - t.Fatalf("pause runs for %s, want it clamped to the reloaded 1m cap (the old 10m cap was still in force)", got.Round(time.Second)) + t.Fatalf("pause runs for %s, want the 30s that was asked for", got.Round(time.Second)) } } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index aeec241..ee9deec 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1384,7 +1384,13 @@ func (o Options) runGuard(ctx context.Context) error { if windowActive && windowTrigger != state.TriggerPause { return reply(false, "a switch window is open — cancel it first") } - openWindow(time.Now(), o.clampPause(req.Duration), "", state.TriggerPause) + // Refused, not shortened: see pauseDuration. A client that asked for + // an hour must not be handed thirty minutes and told it succeeded. + dur, refusal := o.pauseDuration(req.Duration) + if refusal != "" { + return reply(false, refusal) + } + openWindow(time.Now(), dur, "", state.TriggerPause) if !windowActive { return reply(false, "open pause failed") } @@ -2205,19 +2211,39 @@ const defaultPauseDuration = 15 * time.Minute // here means a future caller that skipped the gate can never turn "disabled" // into a real relaxation of the guard, whatever value req parses to. func (o Options) clampPause(req string) time.Duration { + dur, _ := o.pauseDuration(req) + return dur +} + +// pauseDuration resolves a requested pause length, or explains why it cannot be +// granted. The refusal string is empty when the duration is usable. +// +// An explicitly requested length longer than vpn.pauseMax is REFUSED, not +// shortened. Quietly granting 30m to someone who asked for an hour is the same +// class of bug as accepting a disabled window and restoring the default: the +// operator asked for one thing, got another, and was never told. An unspecified +// request is different — nobody asked for a particular length, so the built-in +// default is clamped to the cap rather than refused. +func (o Options) pauseDuration(req string) (time.Duration, string) { if o.PauseMax <= 0 { - return 0 + return 0, "pausing is disabled (vpn.pauseMax is \"0\")" } - dur := defaultPauseDuration - if req != "" { - if d, err := time.ParseDuration(req); err == nil && d > 0 { - dur = d + if req == "" { + dur := defaultPauseDuration + if dur > o.PauseMax { + dur = o.PauseMax } + return dur, "" } - if dur > o.PauseMax { - dur = o.PauseMax + d, err := time.ParseDuration(req) + if err != nil || d <= 0 { + return 0, fmt.Sprintf("invalid pause duration %q", req) } - return dur + if d > o.PauseMax { + return 0, fmt.Sprintf("%s is longer than the %s cap (vpn.pauseMax); "+ + "ask for %s or less, or raise the cap", d, o.PauseMax, o.PauseMax) + } + return d, "" } // configKeys is the sorted key list of a config-write request, for logging. From 61969c2118966cfe21df8c758e9b9fc3ee26be1d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:33:29 +0330 Subject: [PATCH 10/27] feat(gui): pick how long to pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menubar's Pause item gains a submenu of the same lengths `dezhban pause --list` prints, read from the daemon via `pause --list --json` rather than listed in Swift — a picker with its own list would drift from the CLI the first time either changed. Clicking the parent still pauses for the built-in default; the submenu is for choosing deliberately. A length above vpn.pauseMax appears disabled with the cap as its tooltip. It is not hidden: a cap you cannot see is one you keep bumping into, and the previous behaviour — accepting the choice and quietly shortening it — was worse still. The list is read on the existing refresh, alongside status and profiles, so building the menu reads a cache. Shelling out while a menu is opening would stall it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 7 +-- .../Sources/DezhbanCore/PauseOptions.swift | 32 +++++++++++++ .../Sources/DezhbanMenu/AppDelegate.swift | 42 ++++++++++++++--- gui/macos/Sources/DezhbanMenu/AppState.swift | 12 +++++ .../Sources/DezhbanMenu/DezhbanCLI.swift | 9 ++++ .../DezhbanCoreTests/PauseOptionsTests.swift | 47 +++++++++++++++++++ 6 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/PauseOptions.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3b67f..ccaf296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,9 +64,10 @@ current as you land changes. editing the file, but the app offered no way to see or change how long a pause may last. -- **`dezhban pause --list` offers realistic pause lengths**, each saying what it - is for, defined once in the config core so the CLI and the macOS app offer the - same choices. Lengths above `vpn.pauseMax` are listed as unavailable with the +- **Pause offers realistic lengths, in both surfaces.** `dezhban pause --list` + prints them with what each one is for, and the macOS app's Pause item gains a + submenu of the same choices. They are defined once in the config core and read + from the daemon, so the two cannot drift apart. Lengths above `vpn.pauseMax` are listed as unavailable with the cap as the reason rather than hidden — a cap you cannot see is one you keep bumping into. Any duration up to the cap still works. diff --git a/gui/macos/Sources/DezhbanCore/PauseOptions.swift b/gui/macos/Sources/DezhbanCore/PauseOptions.swift new file mode 100644 index 0000000..c8819de --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/PauseOptions.swift @@ -0,0 +1,32 @@ +import Foundation + +/// One offered pause length — mirrors Go's `config.PauseOption` +/// (`dezhban pause --list --json`). +/// +/// Defined by the daemon rather than here so the CLI and the app offer the same +/// choices. A picker that invented its own list would drift from `pause --list` +/// the first time either changed. +public struct PauseOption: Codable, Identifiable, Hashable { + public var id: String { value } + + /// How the length reads to a person: "15 minutes". + public let label: String + /// The duration as `dezhban pause` accepts it, passed straight through so + /// the app never formats a duration itself. + public let value: String + /// What this length is for, so the choice is made on the need rather than + /// on the number. + public let why: String + /// Non-empty when `vpn.pauseMax` forbids this length, and says so in words. + /// + /// Such an option is still SHOWN, disabled, with this as its tooltip. + /// Hiding it would leave the user with a wrong idea of their own cap, and + /// silently shortening it — the behaviour this replaced — is worse still. + public let unavailable: String? + + public var isAvailable: Bool { (unavailable ?? "").isEmpty } + + public static func decodeList(_ data: Data) -> [PauseOption]? { + try? JSONDecoder().decode([PauseOption].self, from: data) + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 66da715..0756e06 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -275,13 +275,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // down, so pauseIsEnabled is true in exactly that case. let pauseAllowed = AppState.shared.pauseIsEnabled let pauseEnabled = isRunning && pauseAllowed - addAction("Pause — use my real IP", #selector(pauseNow), enabled: pauseEnabled) - .toolTip = { - if !pauseAllowed { return "Disabled — vpn.pauseMax is \"0\" in your config." } - if !isRunning { return "Unavailable — dezhban isn’t running. Start it first." } - return AppState.shared.routineHint( - "Deliberately drops to your real ISP IP, then re-arms the guard automatically.") - }() + let pauseItem = addAction("Pause — use my real IP", #selector(pauseNow), enabled: pauseEnabled) + pauseItem.toolTip = { + if !pauseAllowed { return "Disabled — vpn.pauseMax is \"0\" in your config." } + if !isRunning { return "Unavailable — dezhban isn’t running. Start it first." } + return AppState.shared.routineHint( + "Deliberately drops to your real ISP IP, then re-arms the guard automatically.") + }() + // The offered lengths come from the daemon (`pause --list --json`), + // so this menu and `dezhban pause --list` can never offer different + // choices. Clicking the parent still pauses for the built-in + // default; the submenu is for picking deliberately. + if pauseEnabled, let options = AppState.shared.pauseOptions, !options.isEmpty { + let submenu = NSMenu() + for option in options { + let item = NSMenuItem(title: option.label, + action: #selector(pauseForOption(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = option.value + // An over-cap length is shown disabled with the reason, + // never hidden and never quietly shortened — a cap you + // cannot see is one you keep bumping into. + item.isEnabled = option.isAvailable + item.toolTip = option.isAvailable ? option.why : option.unavailable + submenu.addItem(item) + } + submenu.autoenablesItems = false + pauseItem.submenu = submenu + } // The mirror image of Pause, and placed beside it so the pair reads // as the two answers to "I am about to change my VPN situation": @@ -337,6 +359,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func cancelSwitch() { AppActions.routine(["switch", "--cancel"], "cancel the switch window") } @objc private func pauseNow() { AppActions.routine(["pause"], "pause the guard") } @objc private func resumeNow() { AppActions.routine(["resume"], "resume the guard") } + /// Pauses for a length chosen from the submenu. The value travels verbatim + /// from the daemon's own list, so the app never formats a duration itself. + @objc private func pauseForOption(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String else { return } + AppActions.routine(["pause", value], "pause the guard for \(sender.title)") + } @objc private func holdLine() { AppActions.routine(["hold"], "hold the line") } @objc private func cancelHold() { AppActions.routine(["hold", "--cancel"], "cancel hold the line") } diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index ad332e7..4ba35b8 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -113,6 +113,14 @@ final class AppState: ObservableObject { /// Advisory only, same convention as `controlIsReachable` — `dezhban pause` /// still refuses for real if this cache is stale. @Published var pauseIsEnabled = true + /// The pause lengths the daemon offers, and which of them this host's + /// vpn.pauseMax allows (nil: not yet read, or the CLI is too old to know + /// `pause --list`). Cached so building the menu never shells out — a + /// shell-out while the menu is opening would stall it. + /// + /// Read from the daemon rather than listed here so the menu and + /// `dezhban pause --list` cannot offer different choices. + @Published var pauseOptions: [PauseOption]? /// The configured VPN profiles (nil: not yet read, or config unreadable). /// Lives in config, not the daemon's Snapshot — see DezhbanCLI.readProfiles. @Published var profiles: ProfilesInfo? @@ -160,11 +168,15 @@ final class AppState: ObservableObject { // control socket independently on top of that). let status = DezhbanCLI.readStatusJSON() let profiles = DezhbanCLI.readProfiles() + // Read here, with the rest, so the menu can build from a cache + // instead of shelling out while it is opening. + let pauseOptions = DezhbanCLI.readPauseOptions() DispatchQueue.main.async { self?.serviceIsInstalled = status?.serviceInstalled ?? false self?.controlIsReachable = status?.controlReachable ?? false self?.pauseIsEnabled = status?.pauseEnabled ?? false self?.profiles = profiles + self?.pauseOptions = pauseOptions } } } diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index e2fe8a7..2724b0b 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -301,6 +301,15 @@ enum DezhbanCLI { return ConfigSchema.decode(data) } + /// Reads the offered pause lengths and which of them this host's + /// vpn.pauseMax allows, via `pause --list --json`. Read-only, no root. + static func readPauseOptions() -> [PauseOption]? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["pause", "--list", "--json", "--config", resolvedConfigPath()]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return PauseOption.decodeList(data) + } + /// Reads the keys that differ from `name` (or, if nil, the /// matched-or-nearest preset — the same default `preset diff` uses), /// via `config preset diff [name] --json`. diff --git a/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift b/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift new file mode 100644 index 0000000..8e86f94 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import DezhbanCore + +struct PauseOptionsTests { + /// Decodes what `dezhban pause --list --json` actually emits. + @Test func decodesTheDaemonsList() { + let json = """ + [ + {"label":"15 minutes","value":"15m0s","why":"a bank transfer or a delivery tracker"}, + {"label":"2 hours","value":"2h0m0s","why":"an evening of something the VPN can't reach", + "unavailable":"longer than your 30m0s cap (vpn.pauseMax)"} + ] + """.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts.count == 2) + #expect(opts[0].isAvailable) + #expect(opts[0].value == "15m0s") + #expect(!opts[1].isAvailable) + } + + /// An over-cap length is still an option — shown, disabled, explained. The + /// alternative teaches the user their cap is something other than it is. + @Test func overCapOptionsAreKeptWithTheirReason() { + let json = """ + [{"label":"2 hours","value":"2h0m0s","why":"a long evening", + "unavailable":"longer than your 30m0s cap (vpn.pauseMax)"}] + """.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts.count == 1, "an unavailable option must not be dropped at decode time") + #expect(opts[0].unavailable?.contains("vpn.pauseMax") == true, + "the reason must name the setting to change") + } + + /// Absent `unavailable` means available — the JSON omits it rather than + /// sending an empty string. + @Test func absentUnavailableMeansAvailable() { + let json = #"[{"label":"5 minutes","value":"5m0s","why":"one stubborn site"}]"#.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts[0].isAvailable) + #expect(opts[0].unavailable == nil) + } + + @Test func corruptDataFailsToDecode() { + #expect(PauseOption.decodeList(Data("not json".utf8)) == nil) + } +} From 08b2fc637946050d740b450bbbbbbe5a353f8bb4 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:40:41 +0330 Subject: [PATCH 11/27] feat(gui): duration settings become choices, not syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every duration field in Settings was a bare text field that required knowing Go's duration syntax, offered no idea what values were sensible, and gave its only feedback as a modal alert after Apply had been clicked. Each is now a menu of real lengths with a Custom entry that validates as you type. The choices are DERIVED, in DezhbanCore/DurationChoices, from the key's own default and its live cap — multiples of the default, plus the cap itself, with anything above the cap dropped. Nothing is listed per key. A new duration setting therefore gets a usable control by existing, and a lowered cap narrows the menu, because the ceiling is resolved against the values the pane is holding rather than a constant. Listing choices per key here would have re-created, one layer up, exactly the drift Phase M deleted. An explicit Off appears only where the schema says the "0" sentinel survives Normalize — the two windows, the pause cap, and the anti-flap gate — and states its consequence in words at the point of choosing. Offering Off anywhere else would present a security decision that silently does nothing, which is the worst bug this tool can have; the schema is what makes that judgement rather than a guess. DurationChoices carries its own Go-duration parser and renderer, tested for round-tripping, because a value the app writes has to read back identically from `config get`. Its isOff recognises all three spellings the round trip produces — the app writes "0", `config get` reports "0s", KeyValues renders "off" — since recognising only one would show a disabled setting as enabled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 13 ++ .../Sources/DezhbanCore/DurationChoices.swift | 159 ++++++++++++++++++ .../Sources/DezhbanMenu/DurationField.swift | 125 ++++++++++++++ .../Sources/DezhbanMenu/SettingsView.swift | 45 ++--- .../DurationChoicesTests.swift | 119 +++++++++++++ 5 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/DurationChoices.swift create mode 100644 gui/macos/Sources/DezhbanMenu/DurationField.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ccaf296..44442c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,19 @@ current as you land changes. snapshot was replaced within microseconds while observers read the file about once a second — leaving both surfaces able to say only "a window is open". +- **Duration settings in the macOS app are a menu of real choices**, not a text + field demanding Go's duration syntax. Each offers lengths derived from that + key's own default and its live cap, marks the recommended value, and provides + a Custom entry with immediate validity feedback instead of a modal alert after + Apply. Lowering a cap by hand narrows the menu, because the ceiling is read + from your config rather than a constant. + + Where `"0"` is a real, persisted opt-out — the switch window, the redial + window, the pause cap, and the anti-flap gate — an explicit **Off** is offered + and states its consequence in words. It is offered *only* for those keys: for + every other duration a `0` is coerced back to the default, so an Off there + would be a security choice that silently did nothing. + - **`vpn.pauseMax` has a control in the macOS app**, under Windows alongside the switch and redial windows. It was settable from the CLI and reachable by editing the file, but the app offered no way to see or change how long a pause diff --git a/gui/macos/Sources/DezhbanCore/DurationChoices.swift b/gui/macos/Sources/DezhbanCore/DurationChoices.swift new file mode 100644 index 0000000..473e732 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/DurationChoices.swift @@ -0,0 +1,159 @@ +import Foundation + +/// Turns a duration setting into a short list of choices a person can pick from, +/// instead of a text field that demands they know Go's duration syntax. +/// +/// The choices are DERIVED from the key's own default and cap rather than listed +/// per key, so a new duration setting gets a usable control by existing, and a +/// changed default or a lowered cap moves the choices with it. Nothing here +/// hardcodes a value for any particular setting — that was the drift Phase M +/// removed, and re-adding it one layer up would be the same mistake. +public enum DurationChoices { + /// One offered value. + public struct Choice: Identifiable, Hashable { + public var id: String { value } + /// How it reads: "30 seconds", "Off". + public let label: String + /// The value as `config set` accepts it. "0" for Off. + public let value: String + /// Set for the shipped default, so a control can mark it. + public let isDefault: Bool + /// Set for Off, which is not a duration but a persisted opt-out. + public let isOff: Bool + } + + /// The multiples of the default to offer, either side of it. Chosen to span + /// a useful range in few enough steps to stay a menu rather than a form: + /// noticeably tighter, the default, and progressively looser. + private static let multipliers: [Double] = [0.5, 1, 2, 4, 8] + + /// Builds the choice list for one setting. + /// + /// - `defaultValue`/`cap` are Go duration strings from the daemon's schema; + /// an unparsable or absent cap simply means unbounded. + /// - `disablable` decides whether **Off** is offered. It must come from the + /// schema, never a guess: offering Off for a key whose `0` is coerced back + /// to the default would present a security choice that silently does + /// nothing. + public static func build(defaultValue: String, cap: String?, disablable: Bool) -> [Choice] { + guard let base = seconds(defaultValue), base > 0 else { + // No usable default to derive from. Off may still be a real choice, + // and Custom always is — better a short menu than a wrong one. + return disablable ? [offChoice] : [] + } + let ceiling = cap.flatMap(seconds).map { Int($0.rounded()) } + + var out: [Choice] = [] + if disablable { + out.append(offChoice) + } + + var seen = Set() + for m in multipliers { + let secs = Int((base * m).rounded()) + guard secs > 0 else { continue } + // A choice above the cap is not offered at all — unlike a pause + // length, this is not a menu of things you might want, it is a menu + // of things that will be accepted. An option the daemon would reject + // is noise. + if let ceiling, secs > ceiling { continue } + guard seen.insert(secs).inserted else { continue } + out.append(Choice(label: humanize(secs), value: goDuration(secs), + isDefault: secs == Int(base.rounded()), isOff: false)) + } + + // The cap itself is always worth offering: it is the most relaxed + // setting the operator has allowed, and reaching it by multiplying + // upward is luck. + if let ceiling, ceiling > 0, seen.insert(ceiling).inserted { + out.append(Choice(label: humanize(ceiling), value: goDuration(ceiling), + isDefault: false, isOff: false)) + } + return out + } + + private static let offChoice = Choice(label: "Off", value: "0", isDefault: false, isOff: true) + + /// Parses a Go duration into whole seconds. Returns nil for anything that is + /// not a duration — including "off", which callers handle separately. + public static func seconds(_ text: String) -> Double? { + let s = text.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty, s != "off" else { return nil } + + var total = 0.0 + var number = "" + var unit = "" + var sawAny = false + + func flush() -> Bool { + guard let n = Double(number) else { return false } + let scale: Double + switch unit { + case "h": scale = 3600 + case "m": scale = 60 + case "s": scale = 1 + case "ms": scale = 0.001 + case "us", "µs", "μs": scale = 0.000_001 + case "ns": scale = 0.000_000_001 + default: return false + } + total += n * scale + sawAny = true + number = "" + unit = "" + return true + } + + for ch in s { + if ch.isNumber || ch == "." { + if !unit.isEmpty, !flush() { return nil } + number.append(ch) + } else { + guard !number.isEmpty else { return nil } + unit.append(ch) + } + } + guard unit.isEmpty || flush(), sawAny, number.isEmpty else { return nil } + return total + } + + /// Renders whole seconds the way Go's Duration.String would, so a value the + /// app writes reads back identically from `config get`. + public static func goDuration(_ secs: Int) -> String { + if secs == 0 { return "0s" } + var rest = secs + var out = "" + let h = rest / 3600 + rest %= 3600 + let m = rest / 60 + rest %= 60 + if h > 0 { out += "\(h)h" } + if h > 0 || m > 0 { out += "\(m)m" } + out += "\(rest)s" + return out + } + + /// Renders a duration in plain words, for a label a person reads rather than + /// parses. + public static func humanize(_ secs: Int) -> String { + func plural(_ n: Int, _ word: String) -> String { "\(n) \(word)\(n == 1 ? "" : "s")" } + switch secs { + case 0: return "Off" + case ..<60: return plural(secs, "second") + case ..<3600 where secs % 60 == 0: return plural(secs / 60, "minute") + case ..<86_400 where secs % 3600 == 0: return plural(secs / 3600, "hour") + default: + if secs % 3600 == 0 { return plural(secs / 3600, "hour") } + if secs % 60 == 0 { return plural(secs / 60, "minute") } + return goDuration(secs) + } + } + + /// True when `value` is the persisted "off" sentinel in any of the spellings + /// the round trip produces: the app writes "0", `config get` reports "0s", + /// and KeyValues renders "off". + public static func isOff(_ value: String) -> Bool { + let v = value.trimmingCharacters(in: .whitespaces) + return v == "0" || v == "0s" || v == "off" + } +} diff --git a/gui/macos/Sources/DezhbanMenu/DurationField.swift b/gui/macos/Sources/DezhbanMenu/DurationField.swift new file mode 100644 index 0000000..34e2644 --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/DurationField.swift @@ -0,0 +1,125 @@ +import SwiftUI +import DezhbanCore + +/// A duration setting as a menu of real choices, with **Off** where that is a +/// real choice and a Custom entry for anything else. +/// +/// It replaces a bare text field that required knowing Go's duration syntax, and +/// whose only feedback was a modal after Apply. Every value it offers comes from +/// the daemon's schema — the key's own default, its live cap, and whether `"0"` +/// is a persisted opt-out — so nothing here holds an opinion about any +/// particular setting. +struct DurationField: View { + let key: String + /// Shown when the schema is unavailable: names the concept, states no value. + let fallbackLabel: String + let schema: ConfigSchema? + /// The pane's staged values, used to resolve this key's cap to whatever the + /// operator has actually set — not to the cap's own default. + let values: [String: String] + @Binding var text: String + let enabled: Bool + + /// True while the user is typing a value that is not one of the choices. + @State private var isCustom = false + + private var tunable: ConfigTunable? { schema?[key] } + private var label: String { tunable?.label ?? fallbackLabel } + + private var choices: [DurationChoices.Choice] { + guard let tunable else { return [] } + return DurationChoices.build(defaultValue: tunable.defaultValue, + cap: schema?.cap(for: key, in: values), + disablable: tunable.disablable) + } + + /// The choice the current value corresponds to, or nil when it is custom. + private var selected: DurationChoices.Choice? { + if DurationChoices.isOff(text) { + return choices.first(where: \.isOff) + } + guard let secs = DurationChoices.seconds(text) else { return nil } + return choices.first { DurationChoices.seconds($0.value) == secs } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + Spacer() + if choices.isEmpty { + // No schema, or nothing sensible to derive: fall back to the + // plain field rather than an empty menu. + TextField("", text: $text).frame(width: 160) + } else { + menu + } + } + if isCustom || (selected == nil && !text.isEmpty) { + HStack { + Spacer() + TextField("e.g. 45s, 2m30s", text: $text) + .frame(width: 160) + // Immediate, quiet feedback — the old design let a typo + // through to a modal alert after Apply had already been + // clicked. + Image(systemName: valid ? "checkmark.circle" : "exclamationmark.triangle.fill") + .foregroundStyle(valid ? Color.secondary : Color.orange) + .help(valid ? "A valid duration" : "Not a duration — try 45s, 2m30s, 1h") + } + } + if let consequence { + Text(consequence) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .disabled(!enabled) + } + + private var valid: Bool { + DurationChoices.isOff(text) || DurationChoices.seconds(text) != nil + } + + @ViewBuilder + private var menu: some View { + Menu(selected?.label ?? (text.isEmpty ? "Choose…" : "Custom: \(text)")) { + ForEach(choices) { choice in + Button { + text = choice.value + isCustom = false + } label: { + Text(choice.isDefault ? "\(choice.label) (recommended)" : choice.label) + } + } + Divider() + Button("Custom…") { isCustom = true } + } + .frame(width: 200) + .help(tunable?.help ?? "") + } + + /// What this setting costs, stated where the choice is made rather than in a + /// tooltip nobody hovers. Off is the case worth spelling out: for these keys + /// it is a real, persisted decision with a real consequence. + private var consequence: String? { + guard let tunable else { return nil } + if DurationChoices.isOff(text) { + switch key { + case "vpn.switchWindow": + return "Off — nothing you type can relax the guard. Adding a new VPN means " + + "entering its server address by hand first." + case "vpn.redialWindow": + return "Off — a dropped VPN stays cut until it redials on its own. " + + "No exposure, but no automatic help either." + case "vpn.pauseMax": + return "Off — pausing is unavailable, so there is no way to use your real IP on purpose." + case "vpn.advanced.redialMinUptime": + return "Off — a flapping VPN can open a redial window on every drop." + default: + return "Off." + } + } + return tunable.help + } +} diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 8c62198..97f578e 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -100,18 +100,17 @@ struct SettingsView: View { Section("Blocking") { schemaField("blockedCountries", "Blocked countries (comma-sep)", text: $fields.blockedCountries) - schemaField("pollInterval", "Exit country check interval", text: $fields.pollInterval) + durationField("pollInterval", "Exit country check interval", text: $fields.pollInterval) } Section("Windows") { - schemaField("vpn.switchWindow", "Switch window", text: $fields.switchWindow) - schemaField("vpn.redialWindow", "Redial window", text: $fields.redialWindow) - schemaField("vpn.pauseMax", "Longest pause", text: $fields.pauseMax) - schemaField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) + durationField("vpn.switchWindow", "Switch window", text: $fields.switchWindow) + durationField("vpn.redialWindow", "Redial window", text: $fields.redialWindow) + durationField("vpn.pauseMax", "Longest pause", text: $fields.pauseMax) + durationField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) } Section("Timing") { - schemaField("vpn.endpointRefresh", "VPN server address refresh", - text: $fields.endpointRefresh) - schemaField("vpn.tunnelWatch", "Tunnel check interval", text: $fields.tunnelWatch) + durationField("vpn.endpointRefresh", "VPN server address refresh", text: $fields.endpointRefresh) + durationField("vpn.tunnelWatch", "Tunnel check interval", text: $fields.tunnelWatch) } Section("Authorization") { Toggle("Use Touch ID for settings changes", isOn: tokenBinding) @@ -132,20 +131,13 @@ struct SettingsView: View { Text("Touch only if you know why — these override recommended defaults.") .font(.callout) .foregroundStyle(.secondary) - schemaField("vpn.advanced.switchWindowMax", "Switch window cap", - text: $fields.advSwitchWindowMax) - schemaField("vpn.advanced.redialWindowMax", "Redial window cap", - text: $fields.advRedialWindowMax) - schemaField("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", - text: $fields.advRedialMinUptime) - schemaField("vpn.advanced.commandFreshness", "Command freshness", - text: $fields.advCommandFreshness) - schemaField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", - text: $fields.advWindowDiscoveryInterval) - schemaField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", - text: $fields.advTunnelPruneAfter) - schemaField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", - text: $fields.advLearnedEndpointTTL) + durationField("vpn.advanced.switchWindowMax", "Switch window cap", text: $fields.advSwitchWindowMax) + durationField("vpn.advanced.redialWindowMax", "Redial window cap", text: $fields.advRedialWindowMax) + durationField("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", text: $fields.advRedialMinUptime) + durationField("vpn.advanced.commandFreshness", "Command freshness", text: $fields.advCommandFreshness) + durationField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", text: $fields.advWindowDiscoveryInterval) + durationField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", text: $fields.advTunnelPruneAfter) + durationField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", text: $fields.advLearnedEndpointTTL) schemaField("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", text: $fields.advLearnedMaxPerProfile) schemaField("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", @@ -361,6 +353,15 @@ struct SettingsView: View { /// Help text for a field, from the daemon's schema. private func helpText(_ key: String) -> String? { schema?.help(for: key) } + /// A duration setting as a menu of real choices rather than a text field + /// that demands Go's duration syntax. Bounds and Off-availability come from + /// the schema, and the cap is resolved against the values this pane is + /// actually holding, so lowering a cap by hand narrows the menu. + private func durationField(_ key: String, _ fallback: String, text: Binding) -> some View { + DurationField(key: key, fallbackLabel: fallback, schema: schema, + values: fields.currentValues, text: text, enabled: canApply) + } + /// A text field for one config key, labelled and explained from the schema. /// /// `fallback` is the label to use when the schema is unavailable. It names diff --git a/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift b/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift new file mode 100644 index 0000000..bf66342 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift @@ -0,0 +1,119 @@ +import Testing +@testable import DezhbanCore + +struct DurationChoicesTests { + // MARK: - parsing + + @Test(arguments: [ + ("30s", 30.0), ("5m", 300.0), ("1h", 3600.0), ("1h30m", 5400.0), + ("720h", 2_592_000.0), ("1m0s", 60.0), ("30m0s", 1800.0), ("0s", 0.0), + ]) + func parsesGoDurations(_ c: (String, Double)) { + #expect(DurationChoices.seconds(c.0) == c.1) + } + + @Test(arguments: ["", "off", "15x", "abc", "m30", "15", "1h30"]) + func rejectsNonDurations(_ text: String) { + #expect(DurationChoices.seconds(text) == nil) + } + + /// A value the app writes must read back identically from `config get`, + /// which reports Go's own Duration.String rendering. + @Test(arguments: [(0, "0s"), (45, "45s"), (60, "1m0s"), (90, "1m30s"), + (1800, "30m0s"), (3600, "1h0m0s"), (5400, "1h30m0s")]) + func rendersLikeGo(_ c: (Int, String)) { + #expect(DurationChoices.goDuration(c.0) == c.1) + } + + @Test func goDurationRoundTripsThroughTheParser() { + for secs in [1, 45, 60, 90, 300, 1800, 3600, 5400, 2_592_000] { + let rendered = DurationChoices.goDuration(secs) + #expect(DurationChoices.seconds(rendered) == Double(secs), + "\(secs)s rendered as \(rendered) did not parse back") + } + } + + // MARK: - the off sentinel + + /// The round trip spells "off" three ways: the app writes "0", + /// `config get` reports "0s", and KeyValues renders "off". A control that + /// recognised only one would show a disabled setting as enabled. + @Test(arguments: ["0", "0s", "off", " 0s "]) + func recognisesEverySpellingOfOff(_ value: String) { + #expect(DurationChoices.isOff(value)) + } + + @Test(arguments: ["30s", "1m0s", ""]) + func doesNotMistakeADurationForOff(_ value: String) { + #expect(!DurationChoices.isOff(value)) + } + + // MARK: - choices + + /// Off is offered only where the schema says the sentinel survives. Offering + /// it elsewhere would present a security choice that silently does nothing. + @Test func offIsOfferedOnlyForDisablableKeys() { + let disablable = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: true) + #expect(disablable.contains { $0.isOff }) + + let notDisablable = DurationChoices.build(defaultValue: "1m0s", cap: nil, disablable: false) + #expect(!notDisablable.contains { $0.isOff }) + } + + /// Nothing above the cap is offered: this menu lists what will be accepted, + /// so an option the daemon would reject is noise. + @Test func nothingAboveTheCapIsOffered() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "1m0s", disablable: true) + for c in choices where !c.isOff { + let secs = try! #require(DurationChoices.seconds(c.value)) + #expect(secs <= 60, "\(c.label) is above the 1m cap") + } + } + + /// The cap itself is always reachable — it is the most relaxed setting the + /// operator has allowed, and hitting it by doubling upward is luck. + @Test func theCapItselfIsAlwaysOffered() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: true) + #expect(choices.contains { $0.value == "10m0s" }) + } + + /// Lowering the cap must move the choices, which is the point of deriving + /// them from the schema rather than listing them. + @Test func loweringTheCapNarrowsTheChoices() { + let wide = DurationChoices.build(defaultValue: "5s", cap: "3m0s", disablable: true) + let narrow = DurationChoices.build(defaultValue: "5s", cap: "10s", disablable: true) + #expect(narrow.count < wide.count) + #expect(!narrow.contains { $0.value == "3m0s" }) + } + + @Test func theShippedDefaultIsMarked() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: false) + let defaults = choices.filter(\.isDefault) + #expect(defaults.count == 1) + #expect(defaults.first?.value == "30s") + } + + @Test func choicesAreUnique() { + let choices = DurationChoices.build(defaultValue: "1s", cap: "8s", disablable: true) + #expect(Set(choices.map(\.value)).count == choices.count) + } + + /// An unusable default must not produce a bogus menu — Off (where real) and + /// nothing else, leaving Custom as the way in. + @Test func anUnparsableDefaultYieldsNoInventedChoices() { + let choices = DurationChoices.build(defaultValue: "", cap: "1m0s", disablable: true) + #expect(choices.count == 1) + #expect(choices.first?.isOff == true) + #expect(DurationChoices.build(defaultValue: "nonsense", cap: nil, disablable: false).isEmpty) + } + + @Test func humanizesForReading() { + #expect(DurationChoices.humanize(0) == "Off") + #expect(DurationChoices.humanize(1) == "1 second") + #expect(DurationChoices.humanize(30) == "30 seconds") + #expect(DurationChoices.humanize(60) == "1 minute") + #expect(DurationChoices.humanize(1800) == "30 minutes") + #expect(DurationChoices.humanize(3600) == "1 hour") + #expect(DurationChoices.humanize(2_592_000) == "720 hours") + } +} From 9d45af0a0a4d30fcd0fbe45873768540f841b8ca Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 20:45:17 +0330 Subject: [PATCH 12/27] feat(gui): settings organised by what you are deciding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane's eleven sections followed the shape of the config file, so the order was VPN guard, Autodetection, Local network, Blocking, Windows, Timing — three of which are the same decision split three ways, and none of which said why you would touch them. Reordered to follow the decision: which VPN to trust, what gets blocked, when the guard may relax, local network, how closely it watches, then startup. Autodetection folds into "Your VPN" — those toggles were always about which tunnel to trust. Every section gains a line saying what it is for, which is the part a per-field tooltip cannot carry. Headings name the thing rather than the config block it lives in: "When the guard relaxes" instead of "Windows", "What gets blocked" instead of "Blocking", "How closely dezhban watches" instead of "Timing". That follows docs/concepts/glossary.md, which CLAUDE.md names as the authority for user-facing words, and the same rule that already retired "protection" and "egress blocked". Startup and updates moves below the guard settings and says explicitly that it acts immediately — it is the one group Apply does not touch, and the pane previously left the user to infer that from a doc comment they cannot see. The Advanced disclosure now says what its caps actually do, since lowering one visibly narrows the choices the settings above offer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 8 ++ .../Sources/DezhbanMenu/SettingsView.swift | 120 +++++++++++++----- 2 files changed, 94 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44442c4..926f619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,14 @@ current as you land changes. snapshot was replaced within microseconds while observers read the file about once a second — leaving both surfaces able to say only "a window is open". +- **The macOS Settings pane is reorganised around what you are deciding**, not + around the shape of the config file. Sections are ordered which VPN to trust → + what gets blocked → when the guard may relax → local network → how closely it + watches → startup, each with a line saying why you would touch it. Headings say + the thing rather than the config block: "When the guard relaxes", not + "Windows". Autodetection is folded into "Your VPN", where those toggles were + always about the same decision. + - **Duration settings in the macOS app are a menu of real choices**, not a text field demanding Go's duration syntax. Each offers lengths derived from that key's own default and its live cap, marks the recommended value, and provides diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 97f578e..6c924b6 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -63,55 +63,87 @@ struct SettingsView: View { Section("Strictness preset") { presetPicker } - Section("Startup") { - Toggle("Start the guard at boot (install the system service)", isOn: bootBinding) - .disabled(bootBusy || !state.cliFound) - .help("Installs dezhban as a background system service: the guard starts at boot — " - + "before any login — and survives restarts and crashes. Unchecking uninstalls the " - + "service (rules are torn down first so nothing is left blocking).") - Toggle("Open this app at login", isOn: loginBinding) - .help("Registers the app as a login item (System Settings → General → Login Items). " - + "This is only the status display — the guard itself is the system service above.") - Toggle("Notify on essential events", isOn: notifyBinding) - .help("macOS notifications for the transitions that matter: guard armed, egress " - + "blocked, warnings (enforcement error / switch window open), standby, stopped. " - + "Nothing else.") - Toggle("Check for updates automatically", isOn: checkUpdatesBinding) - .help("Checks GitHub for a newer release at launch and every ~24h — never from the " - + "background service, only here, in this app, on this schedule. Turn off to stop this " - + "host contacting GitHub about updates entirely; \"Check Now\" in About still " - + "works either way.") - } - Section("VPN guard") { - schemaField("vpn.tunnelInterfaces", "Your VPN tunnel (comma-sep)", + // Ordered by what a person is actually deciding: which VPN to + // trust, what to block, when the guard may relax, and only then + // the machinery. Headings say the thing rather than the config + // block it lives in, per docs/concepts/glossary.md. + Section { + schemaField("vpn.tunnelInterfaces", "Your VPN tunnel (comma-separated)", text: $fields.tunnelInterfaces) - schemaField("vpn.endpoints", "VPN server addresses (comma-sep)", text: $fields.endpoints) - } - Section("Autodetection") { + schemaField("vpn.endpoints", "VPN server addresses (comma-separated)", text: $fields.endpoints) schemaToggle("vpn.autoDetect", "Find my VPN tunnel automatically", isOn: $fields.autoDetect) schemaToggle("vpn.autoDiscoverEndpoints", "Find the VPN server address automatically", isOn: $fields.autoDiscover) schemaToggle("vpn.autoArm", "Arm the guard when a VPN connects", isOn: $fields.autoArm) + } header: { + sectionHeader("Your VPN", + "Which tunnel the guard trusts. Leave the two fields empty and let " + + "dezhban find them — that is what most setups want.") } - Section("Local network") { - schemaToggle("vpn.allowLocalNetwork", "Keep local devices reachable", - isOn: $fields.allowLocalNetwork) - } - Section("Blocking") { - schemaField("blockedCountries", "Blocked countries (comma-sep)", + + Section { + schemaField("blockedCountries", "Blocked countries (comma-separated)", text: $fields.blockedCountries) durationField("pollInterval", "Exit country check interval", text: $fields.pollInterval) + } header: { + sectionHeader("What gets blocked", + "If your VPN surfaces in one of these countries, everything is cut " + + "until it moves.") } - Section("Windows") { + + Section { durationField("vpn.switchWindow", "Switch window", text: $fields.switchWindow) durationField("vpn.redialWindow", "Redial window", text: $fields.redialWindow) durationField("vpn.pauseMax", "Longest pause", text: $fields.pauseMax) - durationField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) + } header: { + sectionHeader("When the guard relaxes", + "The only three ways traffic is ever let out around the guard. Each is " + + "bounded, re-arms itself, and can be turned Off entirely.") } - Section("Timing") { + + Section { + schemaToggle("vpn.allowLocalNetwork", "Keep local devices reachable", + isOn: $fields.allowLocalNetwork) + } header: { + sectionHeader("Local network", + "Printers, NAS, your router — reachable while the guard is armed. Local " + + "destinations only, so nothing on the internet is opened.") + } + + Section { durationField("vpn.endpointRefresh", "VPN server address refresh", text: $fields.endpointRefresh) + durationField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) durationField("vpn.tunnelWatch", "Tunnel check interval", text: $fields.tunnelWatch) + } header: { + sectionHeader("How closely dezhban watches", + "How quickly a dropped tunnel is noticed, and how long a known VPN " + + "server stays reachable so it can redial.") } + + Section { + Toggle("Start the guard at boot (install the system service)", isOn: bootBinding) + .disabled(bootBusy || !state.cliFound) + .help("Installs dezhban as a background system service: the guard starts at boot — " + + "before any login — and survives restarts and crashes. Unchecking uninstalls the " + + "service (rules are torn down first so nothing is left blocking).") + Toggle("Open this app at login", isOn: loginBinding) + .help("Registers the app as a login item (System Settings → General → Login Items). " + + "This is only the status display — the guard itself is the system service above.") + Toggle("Notify on essential events", isOn: notifyBinding) + .help("macOS notifications for the transitions that matter: guard armed, traffic " + + "cut, warnings (enforcement error / window open), standby, stopped. " + + "Nothing else.") + Toggle("Check for updates automatically", isOn: checkUpdatesBinding) + .help("Checks GitHub for a newer release at launch and every ~24h — never from the " + + "background service, only here, in this app, on this schedule. Turn off to stop this " + + "host contacting GitHub about updates entirely; \"Check Now\" in About still " + + "works either way.") + } header: { + sectionHeader("Startup and updates", + "These take effect immediately — they are actions, not settings, so " + + "Apply does not touch them.") + } + Section("Authorization") { Toggle("Use Touch ID for settings changes", isOn: tokenBinding) .disabled(tokenBusy || !biometryAvailable) @@ -128,7 +160,9 @@ struct SettingsView: View { } Section { DisclosureGroup("Advanced") { - Text("Touch only if you know why — these override recommended defaults.") + Text("Touch only if you know why. These override recommended defaults, and the " + + "three caps below bound how much exposure the settings above can ever " + + "cause — lowering one narrows the choices they offer.") .font(.callout) .foregroundStyle(.secondary) durationField("vpn.advanced.switchWindowMax", "Switch window cap", text: $fields.advSwitchWindowMax) @@ -353,6 +387,24 @@ struct SettingsView: View { /// Help text for a field, from the daemon's schema. private func helpText(_ key: String) -> String? { schema?.help(for: key) } + /// A section heading with a line saying what the section is for. + /// + /// The headings name the thing rather than the config block it lives in — + /// "When the guard relaxes", not "Windows" — following + /// docs/concepts/glossary.md, which is the authority for user-facing words. + /// The description carries the part a tooltip cannot: why you would touch + /// this section at all. + private func sectionHeader(_ title: String, _ description: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + .textCase(nil) + } + .padding(.bottom, 2) + } + /// A duration setting as a menu of real choices rather than a text field /// that demands Go's duration syntax. Bounds and Off-availability come from /// the schema, and the cap is resolved against the values this pane is From db78ef308a44191afd7177cd8349616ceec4a155 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 21:09:30 +0330 Subject: [PATCH 13/27] feat(gui): ship the documentation inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS app now bundles the repo's own documentation, rendered to HTML at build time. It works with every byte of egress cut — which is exactly when someone needs to know what dezhban is doing and cannot reach the web — and the pages always match the version that documents them, because they are built from that version's markdown. New internal/help holds the manifest and a small CommonMark subset renderer; tools/helpgen drives it and build-app.sh invokes it into Contents/Resources/help. Both are dev tooling, stdlib only, never installed and never on the enforcement path — the same standing as tools/taskmenu, and the reason no markdown library was added for a build-time step (CLAUDE.md's dependency rule). The renderer covers exactly what these docs use: headings, paragraphs, fenced code, lists, tables, quotes, rules, and inline links/code/ emphasis. The subset is ENFORCED rather than assumed — anything it cannot represent is reported and fails the build, so a page can never render silently mangled, and the docs stay inside a style the app can actually show. Escaping happens before markup substitution, so no document content can inject HTML. Documentation stays single-source: this renders the markdown, it never restates it. A listed page is load-bearing in the same way as a doc path cited from source, and CLAUDE.md now says so. Tests fail the build when a page is missing, when a page renders to nothing, and — the one that matters for the next commit — when a Tunable's DocAnchor names a heading that does not exist, so a contextual help link cannot rot into a silent no-op. A separate test asserts no rendered page reaches for anything off the machine. Nine pages ship: the four that make a tutorial track, plus the config, CLI, glossary, install and upgrade references. The ADRs and contributor docs deliberately do not — they are written for people working ON dezhban and would drown someone looking for how to unblock themselves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CLAUDE.md | 8 +- gui/macos/build-app.sh | 10 ++ internal/help/bundle.go | 147 ++++++++++++++++++ internal/help/help_test.go | 235 ++++++++++++++++++++++++++++ internal/help/manifest.go | 104 +++++++++++++ internal/help/markdown.go | 306 +++++++++++++++++++++++++++++++++++++ tools/helpgen/main.go | 29 ++++ 7 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 internal/help/bundle.go create mode 100644 internal/help/help_test.go create mode 100644 internal/help/manifest.go create mode 100644 internal/help/markdown.go create mode 100644 tools/helpgen/main.go diff --git a/CLAUDE.md b/CLAUDE.md index bb99c76..df592dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,7 +293,13 @@ The design depends on these invariants (rationale in lies about its own status is worse than none. A doc path cited from Go/Swift source (`grep -rn "docs/" --include="*.go" --include="*.swift"`) is load-bearing: moving or merging a doc means fixing every such reference, not just the ones in - other docs. + other docs. **A page listed in `internal/help.Pages` is load-bearing the same + way**: it ships inside the macOS app, rendered from the repo's markdown at + build time by `tools/helpgen`, so the app's help matches the version it + documents and works with egress cut. `go test ./internal/help` fails when a + bundled page is moved, renamed, or written with markdown the subset renderer + cannot show — and every `Tunable.DocAnchor` is checked to resolve against a + real heading, so a contextual help link cannot rot into a silent no-op. - **Every PR that changes user-visible behavior updates [CHANGELOG.md](CHANGELOG.md)'s `## [Unreleased]` section, in the same PR** — not as a follow-up. `[Unreleased]` *is* the next release's notes (see [docs/contribute/releasing.md](docs/contribute/releasing.md)); a PR diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 0fe1639..ad2578e 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -61,6 +61,16 @@ cp "$HERE/Info.plist" "$APP/Contents/Info.plist" # SUDO_ASKPASS helper. Lives in the (code-signed, read-only) bundle so sudo is # never pointed at a path a local process could swap out from under it. install -m 0755 "$HERE/askpass.sh" "$APP/Contents/Resources/askpass.sh" + +# Documentation, rendered from the repo's own markdown into the bundle. Shipping +# it means the help pane works with every byte of egress cut — which is exactly +# when someone needs it — and that the docs always match the version they +# document. tools/helpgen is dev tooling (stdlib only, never installed); a +# missing or renamed page is an error there, not a silently thinner bundle. +if ! (cd "$REPO_ROOT" && go run ./tools/helpgen -docs docs -out "$APP/Contents/Resources/help"); then + echo "build-app.sh: helpgen failed — the app would ship without its documentation" >&2 + exit 1 +fi # Brand artifacts (gui/artifacts/png): full-color menubar + Dock state # icons. Optional — a checkout without gui/artifacts/ still builds, and AppDelegate # falls back to SF Symbols / the static app icon when the PNGs are absent. diff --git a/internal/help/bundle.go b/internal/help/bundle.go new file mode 100644 index 0000000..f88eae0 --- /dev/null +++ b/internal/help/bundle.go @@ -0,0 +1,147 @@ +package help + +import ( + "encoding/json" + "fmt" + "html" + "os" + "path/filepath" + "strings" +) + +// IndexEntry is one page in help-index.json, which the app's sidebar and search +// are built from. Keys are lowerCamelCase, the same convention every other +// JSON the app decodes uses. +type IndexEntry struct { + File string `json:"file"` + Source string `json:"source"` + Title string `json:"title"` + Summary string `json:"summary"` + Tutorial int `json:"tutorial,omitempty"` + Headings []Heading `json:"headings"` + // Text is the page stripped to words, so search can run entirely in the app + // with no second pass over the HTML. + Text string `json:"text"` +} + +// Build renders every manifest page from docsDir into outDir, writing the HTML, +// help.css, and help-index.json. It reports the index it wrote. +// +// A missing or renamed source is an error, never a skipped page: a help bundle +// that is quietly missing the troubleshooting page is worse than a build that +// stops. +func Build(docsDir, outDir string) ([]IndexEntry, error) { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", outDir, err) + } + + index := make([]IndexEntry, 0, len(Pages)) + for _, page := range Pages { + src := filepath.Join(docsDir, filepath.FromSlash(page.Source)) + data, err := os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("bundled page %s: %w (it is referenced by internal/help; "+ + "moving or renaming a doc means updating the manifest)", page.Source, err) + } + + rendered := Render(string(data)) + if len(rendered.Unsupported) > 0 { + return nil, fmt.Errorf("%s uses markdown the help renderer cannot show: %s", + page.Source, strings.Join(rendered.Unsupported, ", ")) + } + + name := OutputName(page.Source) + if err := os.WriteFile(filepath.Join(outDir, name), []byte(document(page, rendered)), 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", name, err) + } + + index = append(index, IndexEntry{ + File: name, Source: page.Source, Title: page.Title, Summary: page.Summary, + Tutorial: page.Tutorial, Headings: rendered.Headings, Text: rendered.Text, + }) + } + + if err := os.WriteFile(filepath.Join(outDir, "help.css"), []byte(stylesheet), 0o644); err != nil { + return nil, fmt.Errorf("write help.css: %w", err) + } + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return nil, fmt.Errorf("encode help-index.json: %w", err) + } + if err := os.WriteFile(filepath.Join(outDir, "help-index.json"), data, 0o644); err != nil { + return nil, fmt.Errorf("write help-index.json: %w", err) + } + return index, nil +} + +// document wraps rendered body HTML in a page. The stylesheet is a local file +// and there is nothing else: no script, no font, no image from anywhere but the +// bundle. This has to work with every byte of egress cut, which is exactly when +// it will be opened. +func document(page Page, r Rendered) string { + var b strings.Builder + b.WriteString("\n\n\n") + b.WriteString("\n") + b.WriteString("" + html.EscapeString(page.Title) + "\n") + b.WriteString("\n") + b.WriteString("\n\n") + b.WriteString(r.HTML) + b.WriteString("\n\n") + return b.String() +} + +// stylesheet follows the system appearance in both directions, since the pane +// sits inside a native window and a light page in a dark app reads as a bug. +const stylesheet = ` +:root { + color-scheme: light dark; + --fg: #1d1d1f; + --muted: #6e6e73; + --bg: #ffffff; + --rule: #d2d2d7; + --code-bg: #f5f5f7; + --link: #0066cc; +} +@media (prefers-color-scheme: dark) { + :root { + --fg: #f5f5f7; --muted: #a1a1a6; --bg: #1e1e1e; + --rule: #3a3a3c; --code-bg: #2a2a2c; --link: #4da3ff; + } +} +* { box-sizing: border-box; } +body { + margin: 0; padding: 24px 28px 64px; + font: 14px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + color: var(--fg); background: var(--bg); + overflow-wrap: break-word; +} +h1, h2, h3, h4 { line-height: 1.25; margin: 1.6em 0 0.5em; } +h1 { font-size: 1.9em; margin-top: 0; } +h2 { font-size: 1.4em; border-bottom: 1px solid var(--rule); padding-bottom: 0.25em; } +h3 { font-size: 1.15em; } +h4 { font-size: 1em; color: var(--muted); } +p, li { margin: 0.6em 0; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +code { + font: 12.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace; + background: var(--code-bg); padding: 0.15em 0.35em; border-radius: 4px; +} +pre { + background: var(--code-bg); padding: 12px 14px; border-radius: 8px; + overflow-x: auto; +} +pre code { background: none; padding: 0; } +blockquote { + margin: 1em 0; padding: 0.2em 1em; + border-left: 3px solid var(--rule); color: var(--muted); +} +hr { border: none; border-top: 1px solid var(--rule); margin: 2em 0; } +/* Wide tables scroll inside their own box so the page body never does. */ +.table-scroll { overflow-x: auto; margin: 1em 0; } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th, td { border: 1px solid var(--rule); padding: 6px 10px; text-align: left; vertical-align: top; } +th { background: var(--code-bg); font-weight: 600; } +img { max-width: 100%; } +:target { scroll-margin-top: 12px; background: rgba(255, 214, 10, 0.25); } +` diff --git a/internal/help/help_test.go b/internal/help/help_test.go new file mode 100644 index 0000000..da2dffe --- /dev/null +++ b/internal/help/help_test.go @@ -0,0 +1,235 @@ +package help + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +const docsDir = "../../docs" + +// TestEveryBundledPageExists is the load-bearing-doc rule from CLAUDE.md, +// enforced. A page listed here ships inside the app, so moving or renaming a doc +// without updating the manifest would quietly drop it from the bundle — and the +// page most likely to be missed is the one a locked-out user opens. +// +// It runs in `go test ./...` rather than only in CI, so the failure lands on the +// machine that made the change. +func TestEveryBundledPageExists(t *testing.T) { + for _, page := range Pages { + path := filepath.Join(docsDir, filepath.FromSlash(page.Source)) + if _, err := os.Stat(path); err != nil { + t.Errorf("bundled page %q does not exist: %v", page.Source, err) + } + if page.Title == "" || page.Summary == "" { + t.Errorf("%s: a bundled page needs a title and a one-line summary", page.Source) + } + } +} + +// TestBundleBuilds renders the real docs, which is the only way to know the +// subset renderer copes with what is actually written. +func TestBundleBuilds(t *testing.T) { + index := buildInto(t) + + if len(index) != len(Pages) { + t.Fatalf("index has %d entries, want %d", len(index), len(Pages)) + } + for _, e := range index { + if len(e.Headings) == 0 { + t.Errorf("%s rendered with no headings — it probably did not render at all", e.Source) + } + if len(e.Text) < 200 { + t.Errorf("%s produced %d characters of searchable text, which is implausibly little", + e.Source, len(e.Text)) + } + } +} + +// TestEveryTunableDocAnchorResolves ties the settings schema to the bundle. A +// contextual help link is a promise that the section exists; a stale anchor +// silently lands the reader at the top of a long reference page instead. +func TestEveryTunableDocAnchorResolves(t *testing.T) { + index := buildInto(t) + + anchors := map[string]map[string]bool{} + for _, e := range index { + set := map[string]bool{} + for _, h := range e.Headings { + set[h.Anchor] = true + } + anchors[e.Source] = set + } + + for _, tun := range config.Tunables() { + page, frag, found := strings.Cut(tun.DocAnchor, "#") + if !found { + t.Errorf("%s: DocAnchor %q has no fragment", tun.Key, tun.DocAnchor) + continue + } + set, ok := anchors[page] + if !ok { + t.Errorf("%s: DocAnchor names %q, which is not a bundled page", tun.Key, page) + continue + } + if !set[frag] { + t.Errorf("%s: no heading in %s has the anchor %q", tun.Key, page, frag) + } + } +} + +// TestBundleIsSelfContained — the pane opens when the kill switch has cut every +// byte of egress, so a page that reaches for a CDN would render broken at +// exactly the moment it is needed. +func TestBundleIsSelfContained(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + entries, err := filepath.Glob(filepath.Join(dir, "*.html")) + if err != nil || len(entries) == 0 { + t.Fatalf("no rendered pages: %v", err) + } + for _, path := range entries { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + html := string(data) + for _, forbidden := range []string{" a quote\n\n" + + "| Field | Default |\n|---|---|\n| `a` | `1s` |\n\n" + + "```sh\necho hi\n```\n\n" + + "---\n" + r := Render(md) + + if len(r.Unsupported) != 0 { + t.Fatalf("the subset renderer reported %v on its own subset", r.Unsupported) + } + for _, want := range []string{ + "

Title

", "code", "bold", + "
    ", "
      ", "
      ", "", "", "", + "
      ", "
      ", + } { + if !strings.Contains(r.HTML, want) { + t.Errorf("rendered HTML is missing %q", want) + } + } + // A link to a bundled page is rewritten to the bundled copy; the fragment + // survives, since that is what a contextual deep link depends on. + if !strings.Contains(r.HTML, `href="usage-config.html#fields"`) { + t.Errorf("cross-document link was not rewritten to the bundled page:\n%s", r.HTML) + } +} + +// Content inside a fence is verbatim: a shell example full of pipes and dashes +// must not be read as a table, and markup in it must not be interpreted. +func TestFencedCodeIsVerbatim(t *testing.T) { + r := Render("```sh\n| a | b |\n|---|---|\n**not bold**\n```\n") + if strings.Contains(r.HTML, "
      Field1s
      ") { + t.Error("a table inside a code fence was rendered as a table") + } + if strings.Contains(r.HTML, "") { + t.Error("emphasis inside a code fence was interpreted") + } +} + +// The anchors have to match the ones written in the docs (and in DocAnchor), so +// this pins the derivation rather than trusting it. +func TestAnchorMatchesTheDocsConvention(t *testing.T) { + cases := map[string]string{ + "Fields": "fields", + "`control` block": "control-block", + "`vpn` block": "vpn-block", + // Punctuation is dropped and the surviving words are joined by hyphens, + // so the two words either side of the parenthesis stay separated. + "Advanced tunables (`vpn.advanced`)": "advanced-tunables-vpnadvanced", + "Words we do not use": "words-we-do-not-use", + } + for title, want := range cases { + if got := Anchor(title); got != want { + t.Errorf("Anchor(%q) = %q, want %q", title, got, want) + } + } +} + +// A page name is flattened into one directory, so the bundle stays flat and a +// link can be a bare filename. +func TestOutputName(t *testing.T) { + cases := map[string]string{ + "usage/config.md": "usage-config.html", + "concepts/glossary.md": "concepts-glossary.html", + "usage/getting-started.md": "usage-getting-started.html", + } + for src, want := range cases { + if got := OutputName(src); got != want { + t.Errorf("OutputName(%q) = %q, want %q", src, got, want) + } + } +} + +// The index is what the app decodes, so it must be valid JSON with the fields +// the sidebar and search need. +func TestIndexDecodes(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "help-index.json")) + if err != nil { + t.Fatal(err) + } + var entries []IndexEntry + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("help-index.json does not decode: %v", err) + } + tutorial := 0 + for _, e := range entries { + if e.File == "" || e.Title == "" { + t.Errorf("%s: incomplete index entry", e.Source) + } + if e.Tutorial > 0 { + tutorial++ + } + } + if tutorial == 0 { + t.Error("no page is in the tutorial track, so a first-time reader has no starting point") + } +} diff --git a/internal/help/manifest.go b/internal/help/manifest.go new file mode 100644 index 0000000..aa2f8a8 --- /dev/null +++ b/internal/help/manifest.go @@ -0,0 +1,104 @@ +// Package help turns the repo's documentation into what the macOS app bundles +// and displays. +// +// It exists so the app can answer a question while the kill switch has cut +// traffic — which is exactly when a user most needs to know what is happening +// and cannot reach the web. Pages are rendered at build time and shipped inside +// the app, so the documentation always matches the version that documents it. +// +// Documentation stays single-source: this package RENDERS the repo's markdown, +// it never restates it. A page listed here is load-bearing, the same rule +// CLAUDE.md already applies to doc paths cited from source — a test fails the +// build when one goes missing or is renamed. +// +// Stdlib only, and dev tooling only (invoked by gui/macos/build-app.sh through +// tools/helpgen). Nothing here runs in the daemon. +package help + +// A Page is one bundled document. +type Page struct { + // Source is the path under docs/, which is also its identity: a DocAnchor + // of "usage/config.md#fields" names this page and a heading within it. + Source string + // Title is how the page reads in the browser's sidebar. + Title string + // Summary is one line saying what the page answers, for search results and + // the sidebar. + Summary string + // Tutorial is the position in the first-time reading order, 1-based. Zero + // means the page is reference material — reachable and searchable, but not + // part of the guided track. + Tutorial int +} + +// Pages is what the app ships, in sidebar order. +// +// It is deliberately not "everything under docs/". The ADRs and the contributor +// docs are written for people working ON dezhban and would drown a user looking +// for how to unblock themselves; they stay in the repo. What ships is what +// somebody running dezhban needs, offline, possibly mid-lockout. +var Pages = []Page{ + { + Source: "usage/getting-started.md", Title: "Quick start", Tutorial: 1, + Summary: "Install it, set it up, check it won't lock you out, and arm it.", + }, + { + Source: "concepts/how-it-works.md", Title: "How dezhban works", Tutorial: 2, + Summary: "What the guard does, from launch to teardown.", + }, + { + Source: "concepts/modes.md", Title: "Postures", Tutorial: 3, + Summary: "Every posture the guard can be in, and the exact rules each one installs.", + }, + { + Source: "usage/troubleshooting.md", Title: "Troubleshooting", Tutorial: 4, + Summary: "Locked out, or the guard is not behaving — start here.", + }, + { + Source: "usage/config.md", Title: "Configuration reference", + Summary: "Every setting: what it does, its default, and what it costs.", + }, + { + Source: "usage/cli.md", Title: "Command reference", + Summary: "Every command and flag, and which of them need root.", + }, + { + Source: "concepts/glossary.md", Title: "Glossary", + Summary: "One word per concept — the words this app, the CLI, and the docs all use.", + }, + { + Source: "usage/install.md", Title: "Installing", + Summary: "Every install path, and how to verify a download.", + }, + { + Source: "usage/upgrade.md", Title: "Upgrading", + Summary: "How an upgrade is applied and activated, and how to roll one back.", + }, +} + +// PageBySource finds a page by its docs-relative path. +func PageBySource(source string) (Page, bool) { + for _, p := range Pages { + if p.Source == source { + return p, true + } + } + return Page{}, false +} + +// OutputName is the file a page is rendered to, flattened so the bundle is one +// directory: "usage/config.md" becomes "usage-config.html". +func OutputName(source string) string { + out := make([]rune, 0, len(source)) + for _, r := range source { + if r == '/' { + r = '-' + } + out = append(out, r) + } + name := string(out) + if len(name) > 3 && name[len(name)-3:] == ".md" { + name = name[:len(name)-3] + } + return name + ".html" +} diff --git a/internal/help/markdown.go b/internal/help/markdown.go new file mode 100644 index 0000000..3ffbd18 --- /dev/null +++ b/internal/help/markdown.go @@ -0,0 +1,306 @@ +package help + +import ( + "fmt" + "html" + "regexp" + "strings" +) + +// A deliberately small CommonMark subset: exactly the constructs these docs use +// — headings, paragraphs, fenced code, bullet and numbered lists, tables, block +// quotes, horizontal rules, and inline links/code/emphasis. +// +// Written here rather than pulled in because the deliverable is a +// dependency-light standalone binary (see CLAUDE.md's dependency rule), and a +// markdown library would be a fifth third-party module for a build-time step. +// The subset is enforced rather than assumed: Render reports anything it does +// not understand, and a test fails the build when a bundled page uses it. That +// keeps the renderer honest AND keeps the docs inside a style the renderer can +// actually show — a silently mangled page would be worse than a build failure. + +// Heading is one heading in a rendered page, for search and anchor resolution. +type Heading struct { + Level int `json:"level"` + Text string `json:"text"` + Anchor string `json:"anchor"` +} + +// Rendered is one page's HTML plus what a browser needs to index it. +type Rendered struct { + HTML string + Headings []Heading + // Text is the page with markup stripped, for substring search. + Text string + // Unsupported names constructs the renderer met and could not represent. + // Non-empty means the page would display wrongly. + Unsupported []string +} + +var ( + inlineCode = regexp.MustCompile("`([^`]+)`") + inlineLink = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) + inlineImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + inlineBold = regexp.MustCompile(`\*\*([^*]+)\*\*`) + inlineItalic = regexp.MustCompile(`(^|[^*])\*([^*]+)\*`) + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) + bulletRe = regexp.MustCompile(`^\s*[-*]\s+(.*)$`) + numberedRe = regexp.MustCompile(`^\s*\d+\.\s+(.*)$`) + anchorStrip = regexp.MustCompile(`[^a-z0-9 -]`) +) + +// Render converts one markdown document to HTML. +func Render(markdown string) Rendered { + var out strings.Builder + var text strings.Builder + r := Rendered{} + + lines := strings.Split(markdown, "\n") + inCode, inList, inQuote := false, false, false + listTag := "" + + closeList := func() { + if inList { + out.WriteString("\n") + inList = false + } + } + closeQuote := func() { + if inQuote { + out.WriteString("\n") + inQuote = false + } + } + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + // Fenced code: taken verbatim, so nothing inside is interpreted. + if strings.HasPrefix(trimmed, "```") { + if inCode { + out.WriteString("\n") + inCode = false + continue + } + closeList() + closeQuote() + lang := strings.TrimPrefix(trimmed, "```") + out.WriteString(`
      `)
      +			inCode = true
      +			continue
      +		}
      +		if inCode {
      +			out.WriteString(html.EscapeString(line) + "\n")
      +			continue
      +		}
      +
      +		if trimmed == "" {
      +			closeList()
      +			closeQuote()
      +			continue
      +		}
      +
      +		if m := headingRe.FindStringSubmatch(trimmed); m != nil {
      +			closeList()
      +			closeQuote()
      +			level := len(m[1])
      +			title := stripInline(m[2])
      +			anchor := Anchor(title)
      +			r.Headings = append(r.Headings, Heading{Level: level, Text: title, Anchor: anchor})
      +			fmt.Fprintf(&out, "%s\n", level, anchor, inline(m[2]), level)
      +			text.WriteString(title + "\n")
      +			continue
      +		}
      +
      +		if trimmed == "---" || trimmed == "***" || trimmed == "___" {
      +			closeList()
      +			closeQuote()
      +			out.WriteString("
      \n") + continue + } + + // Tables: a header row followed by a separator row. Detected by looking + // ahead, since a lone pipe-containing line is just a paragraph. + if strings.HasPrefix(trimmed, "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]) { + closeList() + closeQuote() + consumed := renderTable(&out, &text, lines[i:]) + i += consumed - 1 + continue + } + + if strings.HasPrefix(trimmed, ">") { + closeList() + if !inQuote { + out.WriteString("
      \n") + inQuote = true + } + body := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) + out.WriteString("

      " + inline(body) + "

      \n") + text.WriteString(stripInline(body) + "\n") + continue + } + + if m := bulletRe.FindStringSubmatch(line); m != nil { + if inList && listTag != "ul" { + closeList() + } + if !inList { + out.WriteString("
        \n") + inList, listTag = true, "ul" + } + out.WriteString("
      • " + inline(m[1]) + "
      • \n") + text.WriteString(stripInline(m[1]) + "\n") + continue + } + if m := numberedRe.FindStringSubmatch(line); m != nil { + if inList && listTag != "ol" { + closeList() + } + if !inList { + out.WriteString("
          \n") + inList, listTag = true, "ol" + } + out.WriteString("
        1. " + inline(m[1]) + "
        2. \n") + text.WriteString(stripInline(m[1]) + "\n") + continue + } + + // A continuation line inside a list item, rather than a new paragraph. + if inList && strings.HasPrefix(line, " ") { + out.WriteString(" " + inline(trimmed)) + text.WriteString(stripInline(trimmed) + "\n") + continue + } + + closeList() + closeQuote() + out.WriteString("

          " + inline(trimmed) + "

          \n") + text.WriteString(stripInline(trimmed) + "\n") + } + + closeList() + closeQuote() + if inCode { + r.Unsupported = append(r.Unsupported, "an unclosed code fence") + } + + r.HTML = out.String() + r.Text = text.String() + return r +} + +// isTableSeparator matches the |---|---| row that turns the line above it into +// a table header. +func isTableSeparator(line string) bool { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "|") { + return false + } + for _, cell := range splitRow(s) { + if cell == "" || strings.Trim(cell, "-: ") != "" { + return false + } + } + return true +} + +// renderTable emits one table and reports how many lines it consumed. +func renderTable(out *strings.Builder, text *strings.Builder, lines []string) int { + header := splitRow(lines[0]) + out.WriteString("
      \n") + for _, c := range header { + out.WriteString("") + text.WriteString(stripInline(c) + " ") + } + out.WriteString("\n\n") + text.WriteString("\n") + + used := 2 // header + separator + for _, line := range lines[2:] { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "|") { + break + } + out.WriteString("") + for _, c := range splitRow(s) { + out.WriteString("") + text.WriteString(stripInline(c) + " ") + } + out.WriteString("\n") + text.WriteString("\n") + used++ + } + out.WriteString("
      " + inline(c) + "
      " + inline(c) + "
      \n") + return used +} + +func splitRow(line string) []string { + parts := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +// inline renders inline markup. Escaping happens FIRST and the markup is +// substituted into the escaped text, so no document content can inject HTML — +// the pages are ours, but the rule costs nothing and removes the question. +func inline(s string) string { + out := html.EscapeString(s) + // Code first: its contents must not then be read as emphasis. + out = inlineCode.ReplaceAllString(out, "$1") + out = inlineImage.ReplaceAllString(out, `$1`) + out = inlineLink.ReplaceAllStringFunc(out, func(m string) string { + g := inlineLink.FindStringSubmatch(m) + return fmt.Sprintf("%s", rewriteLink(g[2]), g[1]) + }) + out = inlineBold.ReplaceAllString(out, "$1") + out = inlineItalic.ReplaceAllString(out, "$1$2") + return out +} + +// rewriteLink points a cross-document link at the bundled copy of that page. +// A link to something not bundled is left as written; the browser refuses +// anything that is not a local file, so it simply does nothing rather than +// silently leaving the app. +func rewriteLink(href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || + strings.HasPrefix(href, "#") { + return href + } + path, frag, _ := strings.Cut(href, "#") + // Links are written relative to the page they live in ("../adr/0001.md", + // "config.md"); resolve to the docs-relative form the manifest uses. + path = strings.TrimPrefix(path, "./") + for strings.HasPrefix(path, "../") { + path = strings.TrimPrefix(path, "../") + } + for _, p := range Pages { + if p.Source == path || strings.HasSuffix(p.Source, "/"+path) { + if frag != "" { + return OutputName(p.Source) + "#" + frag + } + return OutputName(p.Source) + } + } + return href +} + +// stripInline reduces a line to its words, for the search index. +func stripInline(s string) string { + out := inlineImage.ReplaceAllString(s, "$1") + out = inlineLink.ReplaceAllString(out, "$1") + out = strings.NewReplacer("`", "", "**", "", "*", "", "_", "").Replace(out) + return strings.TrimSpace(out) +} + +// Anchor derives the fragment id a heading gets, matching GitHub's rule so an +// anchor written in the docs (and in a Tunable's DocAnchor) resolves in the +// rendered page too. +func Anchor(title string) string { + s := strings.ToLower(stripInline(title)) + s = anchorStrip.ReplaceAllString(s, "") + return strings.ReplaceAll(strings.TrimSpace(s), " ", "-") +} diff --git a/tools/helpgen/main.go b/tools/helpgen/main.go new file mode 100644 index 0000000..2faa10b --- /dev/null +++ b/tools/helpgen/main.go @@ -0,0 +1,29 @@ +// Command helpgen renders the documentation the macOS app bundles. +// +// Dev tooling only — the same standing as tools/taskmenu. It is invoked by +// gui/macos/build-app.sh at package time and is never installed, never shipped, +// and never on the enforcement path. +// +// go run ./tools/helpgen -docs docs -out dist/help +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/behnam-rk/dezhban/internal/help" +) + +func main() { + docs := flag.String("docs", "docs", "path to the docs/ directory") + out := flag.String("out", "dist/help", "directory to write the rendered bundle into") + flag.Parse() + + index, err := help.Build(*docs, *out) + if err != nil { + fmt.Fprintln(os.Stderr, "helpgen:", err) + os.Exit(1) + } + fmt.Printf("helpgen: %d pages → %s\n", len(index), *out) +} From cd2401abf1d79b19838b5bd72811c3222be4be13 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 23:42:53 +0330 Subject: [PATCH 14/27] feat(gui): read the documentation without leaving the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs have shipped inside the bundle since the previous commit; nothing displayed them. This adds the Help pane: sidebar over the bundled pages with the guided track first, substring search over titles, headings, and body text, and a WKWebView pointed at the rendered HTML. It never touches the network, which is the entire point. The moment someone most needs to know what the guard is doing to their traffic is often the moment it has cut all of it, so read access is granted to the help directory alone and the navigation delegate allows nothing but file: URLs inside it. A link that leaves the bundle is refused and reported with the URL, rather than silently doing nothing and reading as a broken app. Content JavaScript is off and the pane injects none either: a deep link's anchor rides in the URL fragment and WebKit does the scrolling. A link to another bundled page is handed back to the view, which moves its selection, so the sidebar never highlights a page the reader has left. AppState gains `helpTarget` and `openHelp(docAnchor:)` — the channel a contextual link from Settings will use next. A docAnchor whose heading no longer exists still opens the right page: a bad link beats a dead control. The new tests are written with swift-testing, like the rest of the suite. The first draft used XCTest, which split the runner in two — `swift test` then ran those nine and none of the other 78. All 87 run together again. --- CHANGELOG.md | 14 + docs/contribute/testing.md | 16 + docs/usage/getting-started.md | 5 + gui/macos/Sources/DezhbanCore/HelpIndex.swift | 229 +++++++++++++ gui/macos/Sources/DezhbanMenu/AppState.swift | 21 +- gui/macos/Sources/DezhbanMenu/HelpView.swift | 321 ++++++++++++++++++ gui/macos/Sources/DezhbanMenu/MainView.swift | 3 +- .../DezhbanCoreTests/HelpIndexTests.swift | 157 +++++++++ 8 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/HelpIndex.swift create mode 100644 gui/macos/Sources/DezhbanMenu/HelpView.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 926f619..f3adfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,20 @@ current as you land changes. cap as the reason rather than hidden — a cap you cannot see is one you keep bumping into. Any duration up to the cap still works. +- **The macOS app carries dezhban's documentation inside it.** A new Help pane + in the main window shows the same pages as the repository's `docs/` — quick + start, how the guard works, the postures, troubleshooting, and the full + configuration and command references — with a search box and a first-time + reading order. + + It reads them from inside the app bundle and never touches the network, which + is the entire point: the moment you most need to know what the guard is doing + to your traffic is often the moment it has cut all of it, and a help pane that + needed a working connection would be blank exactly then. The pages are + rendered from the repository's markdown when the app is built, so they always + match the version that documents them, and the pane refuses to follow any link + that leaves the bundle. + ### Changed - **A pause longer than `vpn.pauseMax` is now refused and explained, instead of diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 1c73ce8..e44389d 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -620,6 +620,22 @@ end up typing a password. `dezhban doctor` prints in a terminal. - [ ] CLI missing → the guided "dezhban CLI not found" state, not a blank list. +### Help pane + +The pane's whole reason for existing is that it works while the guard has cut +traffic, so the check that matters is the one CI cannot run: with egress gone. + +- [ ] With **FULL BLOCK active** (or the tunnel down and no window open), open + Help: pages render fully, the sidebar and search work, and nothing is + blank or spinning. Confirm no request leaves the machine — Little Snitch, + or `tcpdump`/`pfctl -s state` showing nothing new from `DezhbanMenu`. +- [ ] A link in a page to another bundled page moves the sidebar selection with + it, so the highlighted row is the page being read. +- [ ] A link that points off the bundle (an `https://` one in a doc) is refused + in the pane and reported with a Copy link button — it must not navigate. +- [ ] Built from a checkout whose `docs/` was renamed under it, `task gui:build` + **fails** rather than producing an app whose Help pane is missing a page. + ### Logs pane - [ ] "Show last hour" matches a hand-run `log show --last 1h --predicate diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index c3bdef2..67c3df2 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -188,6 +188,11 @@ you need it — the full runbook is in ## Where to go next +Everything below also ships **inside the macOS app** — open the main window and +pick **Help**. Those pages are rendered into the app when it is built, so they +match the version you are running and they work with every byte of egress cut, +which is exactly when you are most likely to need them. + | If you want to… | Read | |---|---| | Understand how the machine actually works | [how-it-works.md](../concepts/how-it-works.md) | diff --git a/gui/macos/Sources/DezhbanCore/HelpIndex.swift b/gui/macos/Sources/DezhbanCore/HelpIndex.swift new file mode 100644 index 0000000..75baac1 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/HelpIndex.swift @@ -0,0 +1,229 @@ +import Foundation + +/// Mirrors Go's `help.Heading` — one heading in a rendered page. +public struct HelpHeading: Codable, Hashable { + public let level: Int + public let text: String + public let anchor: String + + public init(level: Int, text: String, anchor: String) { + self.level = level + self.text = text + self.anchor = anchor + } +} + +/// Mirrors Go's `help.IndexEntry` — one bundled page, as written to +/// `help-index.json` by `tools/helpgen` at build time. +public struct HelpPage: Codable, Identifiable, Hashable { + /// The docs-relative source path is the identity, because that is what a + /// `Tunable.DocAnchor` names ("usage/config.md#fields"). + public var id: String { source } + public let file: String + public let source: String + public let title: String + public let summary: String + /// Position in the first-time reading order, 1-based; 0 means reference + /// material — reachable and searchable, but not part of the guided track. + public let tutorial: Int + public let headings: [HelpHeading] + /// The page stripped to words, so search runs in the app with no second + /// pass over the HTML. + public let text: String + + private enum CodingKeys: String, CodingKey { + case file, source, title, summary, tutorial, headings, text + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + file = try c.decode(String.self, forKey: .file) + source = try c.decode(String.self, forKey: .source) + title = try c.decode(String.self, forKey: .title) + summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" + // Go omits `tutorial` when it is zero (`omitempty`), which is the + // common case: most pages are reference, not tutorial. + tutorial = try c.decodeIfPresent(Int.self, forKey: .tutorial) ?? 0 + headings = try c.decodeIfPresent([HelpHeading].self, forKey: .headings) ?? [] + text = try c.decodeIfPresent(String.self, forKey: .text) ?? "" + } + + public init(file: String, source: String, title: String, summary: String, + tutorial: Int = 0, headings: [HelpHeading] = [], text: String = "") { + self.file = file + self.source = source + self.title = title + self.summary = summary + self.tutorial = tutorial + self.headings = headings + self.text = text + } + + /// True when this page has a heading with that fragment id — what a + /// contextual deep link depends on. + public func hasAnchor(_ anchor: String) -> Bool { + headings.contains { $0.anchor == anchor } + } +} + +/// One search result: the page, optionally a heading to land on, and a line +/// saying why it matched. +public struct HelpHit: Identifiable, Hashable { + public var id: String { page.source + "#" + (anchor ?? "") } + public let page: HelpPage + public let anchor: String? + public let context: String + + public init(page: HelpPage, anchor: String?, context: String) { + self.page = page + self.anchor = anchor + self.context = context + } +} + +/// Where a deep link points: a page, and optionally a heading within it. +/// Written as `"usage/config.md#fields"` in a `Tunable`'s `docAnchor`. +public struct HelpTarget: Hashable, Identifiable { + public var id: String { source + "#" + (anchor ?? "") } + public let source: String + public let anchor: String? + + public init(source: String, anchor: String? = nil) { + self.source = source + self.anchor = anchor + } + + /// Parses the `page#fragment` form a `Tunable.docAnchor` is written in. + public init?(docAnchor: String) { + let parts = docAnchor.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + guard let first = parts.first, !first.isEmpty else { return nil } + source = String(first) + anchor = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil + } +} + +/// The documentation shipped inside the app: the rendered pages on disk plus +/// the index the sidebar and search are built from. +/// +/// It is deliberately read from the bundle rather than the network. The pane is +/// opened when someone needs to know what the guard is doing to their traffic, +/// which is often while it has cut all of it. +public struct HelpBundle { + /// The directory holding the HTML, help.css, and help-index.json. + public let directory: URL + public let pages: [HelpPage] + + public init(directory: URL, pages: [HelpPage]) { + self.directory = directory + self.pages = pages + } + + /// Loads a bundle from a directory containing help-index.json. + public static func load(directory: URL) -> HelpBundle? { + guard let data = try? Data(contentsOf: directory.appendingPathComponent("help-index.json")), + let pages = try? JSONDecoder().decode([HelpPage].self, from: data), + !pages.isEmpty + else { return nil } + return HelpBundle(directory: directory, pages: pages) + } + + /// Loads the copy inside Dezhban.app (`Contents/Resources/help`). Returns + /// nil when running from `swift run` outside a bundle, or against an app + /// built before the docs were bundled — callers show a short explanation + /// rather than an empty pane. + public static func bundled() -> HelpBundle? { + guard let resources = Bundle.main.resourceURL else { return nil } + return load(directory: resources.appendingPathComponent("help", isDirectory: true)) + } + + /// The guided reading order, first. + public var tutorial: [HelpPage] { + pages.filter { $0.tutorial > 0 }.sorted { $0.tutorial < $1.tutorial } + } + + /// Everything else, in manifest order — reference material a reader looks + /// something up in rather than reads through. + public var reference: [HelpPage] { + pages.filter { $0.tutorial == 0 } + } + + public func page(source: String) -> HelpPage? { + pages.first { $0.source == source } + } + + /// The on-disk file for a page. `WKWebView.loadFileURL` needs a real URL, + /// and this is the only place one is constructed. + public func url(for page: HelpPage) -> URL { + directory.appendingPathComponent(page.file) + } + + /// Resolves a deep link. Reports the anchor only when a heading actually + /// carries it — a stale anchor lands the reader at the top of the right + /// page instead of nowhere. + public func resolve(_ target: HelpTarget) -> (page: HelpPage, anchor: String?)? { + guard let page = page(source: target.source) else { return nil } + if let anchor = target.anchor, page.hasAnchor(anchor) { + return (page, anchor) + } + return (page, nil) + } + + /// Substring search over titles, summaries, headings, and body text, in + /// that order of confidence. Case-insensitive, no stemming: the corpus is + /// nine pages, and a reader typing "pause" wants the pause section, not a + /// ranked relevance model. + public func search(_ query: String) -> [HelpHit] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return [] } + + var hits: [HelpHit] = [] + var ranks: [String: Int] = [:] + + for page in pages { + if page.title.lowercased().contains(q) { + hits.append(HelpHit(page: page, anchor: nil, context: page.summary)) + ranks[page.source + "#"] = 0 + continue + } + if page.summary.lowercased().contains(q) { + hits.append(HelpHit(page: page, anchor: nil, context: page.summary)) + ranks[page.source + "#"] = 1 + continue + } + if let heading = page.headings.first(where: { $0.text.lowercased().contains(q) }) { + let hit = HelpHit(page: page, anchor: heading.anchor, context: heading.text) + hits.append(hit) + ranks[hit.id] = 2 + continue + } + if let snippet = Self.snippet(in: page.text, around: q) { + hits.append(HelpHit(page: page, anchor: nil, context: snippet)) + ranks[page.source + "#"] = 3 + } + } + + return hits.sorted { a, b in + let ra = ranks[a.id] ?? 9, rb = ranks[b.id] ?? 9 + if ra != rb { return ra < rb } + // Within a rank, the guided track comes first: a reader who does + // not know the vocabulary yet is better served by "How it works" + // than by the configuration reference. + let ta = a.page.tutorial == 0 ? Int.max : a.page.tutorial + let tb = b.page.tutorial == 0 ? Int.max : b.page.tutorial + if ta != tb { return ta < tb } + return a.page.title < b.page.title + } + } + + /// One line of body text around the match, so a result says what it found + /// rather than just where. + static func snippet(in text: String, around query: String) -> String? { + for line in text.split(separator: "\n") { + if line.lowercased().contains(query) { + let s = line.trimmingCharacters(in: .whitespaces) + return s.count > 140 ? String(s.prefix(140)) + "…" : s + } + } + return nil + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index 4ba35b8..f851174 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -4,7 +4,7 @@ import DezhbanCore /// The main window's sidebar sections. enum SidebarSection: String, CaseIterable, Identifiable { - case overview, diagnostics, settings, logs, about + case overview, diagnostics, settings, help, logs, about var id: String { rawValue } @@ -13,6 +13,10 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .overview: return "Overview" case .diagnostics: return "Diagnostics" case .settings: return "Settings" + // The repo's own docs, rendered into the app at build time — see + // internal/help. Sits next to Settings because that is where a reader + // asking "what does this setting cost me?" is standing. + case .help: return "Help" // Structured findings moved to the Diagnostics pane above; this pane // is transcripts only now (panic, install, config apply, `log` output). case .logs: return "Logs" @@ -25,6 +29,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .overview: return "shield.lefthalf.filled" case .diagnostics: return "stethoscope" case .settings: return "gearshape" + case .help: return "book" case .logs: return "text.alignleft" case .about: return "info.circle" } @@ -125,6 +130,11 @@ final class AppState: ObservableObject { /// Lives in config, not the daemon's Snapshot — see DezhbanCLI.readProfiles. @Published var profiles: ProfilesInfo? @Published var selectedSection: SidebarSection? = .overview + /// A page (and optionally a heading) the Help pane should open next — + /// how a contextual link from Settings names where it wants to land. The + /// pane consumes it and sets it back to nil, so selecting Help again later + /// does not jump back to a link the reader has moved on from. + @Published var helpTarget: HelpTarget? /// Last update check result (nil: none run yet, or the last one found /// nothing worth reporting — see UpdateChecker.check's doc comment on why /// a failure never surfaces as an error here). @@ -134,6 +144,15 @@ final class AppState: ObservableObject { var isLive: Bool { PostureUI.isLive(snapshot) } + /// Opens the Help pane at a specific place in the documentation, named the + /// way a `Tunable`'s docAnchor writes it ("usage/config.md#fields"). + /// A docAnchor that names nothing bundled still opens the pane — better a + /// reader lands in the documentation than on a dead control. + func openHelp(docAnchor: String) { + helpTarget = HelpTarget(docAnchor: docAnchor) + selectedSection = .help + } + /// Routes a finished transcript into the Logs pane and /// navigates there — the window-side output surface for long actions. func showInLogs(title: String, text: String) { diff --git a/gui/macos/Sources/DezhbanMenu/HelpView.swift b/gui/macos/Sources/DezhbanMenu/HelpView.swift new file mode 100644 index 0000000..4a40556 --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/HelpView.swift @@ -0,0 +1,321 @@ +import AppKit +import SwiftUI +import WebKit +import DezhbanCore + +/// The Help pane: dezhban's own documentation, rendered at build time by +/// `tools/helpgen` and read from inside the app bundle. +/// +/// It never touches the network. That is the whole point — the moment someone +/// most needs to know what the guard is doing to their traffic is often the +/// moment it has cut all of it, and a help pane that needs a working connection +/// would be blank exactly then. +struct HelpView: View { + @EnvironmentObject var state: AppState + + @State private var bundle: HelpBundle? = HelpBundle.bundled() + @State private var query = "" + @State private var selection: HelpPage.ID? + /// The heading to land on after the next load, from a contextual deep link. + @State private var pendingAnchor: String? + /// A link the page tried to follow that leaves the bundle. Shown rather + /// than silently dropped, so a dead-feeling click has an explanation. + @State private var blockedLink: URL? + + var body: some View { + Group { + if let bundle { + browser(bundle) + } else { + unavailable + } + } + .navigationTitle("Help") + .onAppear { openPendingTarget() } + .onChange(of: state.helpTarget) { _ in openPendingTarget() } + } + + // MARK: - Browser + + private func browser(_ bundle: HelpBundle) -> some View { + HSplitView { + sidebar(bundle) + .frame(minWidth: 200, idealWidth: 240, maxWidth: 340) + VStack(spacing: 0) { + if let page = current(bundle) { + HelpWebView(url: bundle.url(for: page), + readAccess: bundle.directory, + anchor: $pendingAnchor, + blockedLink: $blockedLink, + follow: { follow($0, in: bundle) }) + } else { + placeholder + } + if let blockedLink { + externalLinkNote(blockedLink) + } + } + .frame(minWidth: 420) + } + } + + private func sidebar(_ bundle: HelpBundle) -> some View { + VStack(spacing: 0) { + TextField("Search the documentation", text: $query) + .textFieldStyle(.roundedBorder) + .padding(10) + Divider() + List(selection: $selection) { + if query.isEmpty { + // The guided track first, in reading order: someone opening + // Help for the first time needs a starting point, not an + // alphabetical index. + Section("Start here") { + ForEach(bundle.tutorial) { row($0) } + } + Section("Reference") { + ForEach(bundle.reference) { row($0) } + } + } else { + let hits = bundle.search(query) + if hits.isEmpty { + Text("Nothing matched “\(query)”.") + .foregroundStyle(.secondary) + .font(.callout) + } else { + ForEach(hits) { hit in + hitRow(hit) + } + } + } + } + .listStyle(.sidebar) + } + } + + private func row(_ page: HelpPage) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(page.title).font(.body) + Text(page.summary).font(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .tag(page.id) + } + + /// A search result is a button rather than a selectable row: choosing one + /// carries an anchor as well as a page, and List selection only carries the + /// page. + private func hitRow(_ hit: HelpHit) -> some View { + Button { + pendingAnchor = hit.anchor + selection = hit.page.id + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(hit.page.title).font(.body) + if !hit.context.isEmpty { + Text(hit.context).font(.caption).foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 2) + } + + private var placeholder: some View { + VStack(spacing: 12) { + Image(systemName: "book").font(.system(size: 40)).foregroundStyle(.secondary) + Text("Pick a page").font(.title3.weight(.semibold)) + Text("Everything here ships inside the app, so it works even while dezhban has cut your traffic.") + .multilineTextAlignment(.center).foregroundStyle(.secondary).frame(maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + /// Shown when the app was built without its documentation — a `swift run` + /// outside a bundle, or an app from before the docs were bundled. Says so + /// plainly instead of showing an empty pane. + private var unavailable: some View { + VStack(spacing: 12) { + Image(systemName: "book.closed").font(.system(size: 40)).foregroundStyle(.secondary) + Text("No documentation in this build").font(.title3.weight(.semibold)) + Text("This copy of Dezhban was built without its bundled help. The same pages are in the repository under docs/.") + .multilineTextAlignment(.center).foregroundStyle(.secondary).frame(maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + private func externalLinkNote(_ url: URL) -> some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.forward.square").foregroundStyle(.secondary) + Text("That link points outside the app: \(url.absoluteString)") + .font(.callout).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) + Spacer() + Button("Copy link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + Button("Dismiss") { blockedLink = nil } + } + .padding(10) + .background(.quaternary.opacity(0.4)) + } + + // MARK: - Navigation + + private func current(_ bundle: HelpBundle) -> HelpPage? { + guard let selection else { return nil } + return bundle.page(source: selection) + } + + /// A cross-page link inside the pane moves the sidebar selection rather + /// than letting the web view navigate on its own — otherwise the sidebar + /// would keep highlighting a page the reader had already left. + private func follow(_ url: URL, in bundle: HelpBundle) { + let file = url.lastPathComponent + guard let page = bundle.pages.first(where: { $0.file == file }) else { return } + pendingAnchor = url.fragment + selection = page.id + } + + /// Consumes a deep link left by another pane (see `AppState.openHelp`). + /// Cleared once acted on, so re-selecting the Help pane later does not jump + /// back to a link the reader has moved on from. + private func openPendingTarget() { + guard let bundle else { return } + guard let target = state.helpTarget, let (page, anchor) = bundle.resolve(target) else { + if selection == nil { selection = bundle.tutorial.first?.id ?? bundle.pages.first?.id } + return + } + pendingAnchor = anchor + selection = page.id + state.helpTarget = nil + } +} + +private extension URL { + /// The same URL without its fragment — what tells "scroll within this page" + /// apart from "go to another page". + var deletingFragment: URL { + var c = URLComponents(url: self, resolvingAgainstBaseURL: false) + c?.fragment = nil + return c?.url ?? self + } + + /// The same URL pointing at a heading within the page. WebKit scrolls to it + /// on load, which is why the pane needs no script of its own. + func appendingFragment(_ fragment: String) -> URL { + var c = URLComponents(url: self, resolvingAgainstBaseURL: false) + c?.fragment = fragment + return c?.url ?? self + } +} + +/// A `WKWebView` restricted to the bundled documentation. +/// +/// Two rules make this safe to point at rendered HTML: read access is granted +/// only to the help directory, and the navigation delegate allows nothing but +/// `file:` URLs inside it. A page therefore cannot reach the network even if a +/// doc gains an external link — which matters because this pane exists to work +/// while the guard has cut traffic, and a silently hanging request would read +/// as the app being broken. +struct HelpWebView: NSViewRepresentable { + let url: URL + let readAccess: URL + @Binding var anchor: String? + @Binding var blockedLink: URL? + /// Called for a link to another bundled page, so the owning view can move + /// its selection instead of the web view navigating behind the sidebar. + let follow: (URL) -> Void + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + // Nothing bundled runs script; the pane only ever scrolls to an anchor, + // which the coordinator does itself via evaluateJavaScript. + config.defaultWebpagePreferences.allowsContentJavaScript = false + let web = WKWebView(frame: .zero, configuration: config) + web.navigationDelegate = context.coordinator + // The bundled stylesheet follows the system appearance; matching the + // area behind it stops a white flash in dark mode between loads. + web.underPageBackgroundColor = .textBackgroundColor + context.coordinator.load(web, url: url, readAccess: readAccess, anchor: anchor) + return web + } + + func updateNSView(_ web: WKWebView, context: Context) { + context.coordinator.parent = self + context.coordinator.load(web, url: url, readAccess: readAccess, anchor: anchor) + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, WKNavigationDelegate { + var parent: HelpWebView + /// What was last handed to the web view, fragment included. + private var loaded: URL? + + init(_ parent: HelpWebView) { + self.parent = parent + } + + /// Loads a page, landing on a heading when one was asked for. + /// + /// The anchor rides in the URL's fragment and WebKit does the scrolling, + /// rather than the app injecting a `scrollIntoView` — which would not + /// run anyway with content JavaScript off, and which would put script + /// into a pane that has none. + func load(_ web: WKWebView, url: URL, readAccess: URL, anchor: String?) { + let target = anchor.map { url.appendingFragment($0) } ?? url + defer { clearAnchor() } + guard target != loaded else { return } + loaded = target + web.loadFileURL(target, allowingReadAccessTo: readAccess) + } + + /// The anchor is one-shot: it is spent by the navigation it triggered. + /// Written back on the next runloop turn because clearing a `@Binding` + /// from inside `updateNSView` would mutate state during a view update. + private func clearAnchor() { + guard parent.anchor != nil else { return } + DispatchQueue.main.async { [parent] in parent.anchor = nil } + } + + func webView(_ web: WKWebView, + decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let target = action.request.url else { + decisionHandler(.cancel) + return + } + // Anything that is not a local file in the help directory is + // refused — no http(s), no custom scheme, no path outside the + // bundle. A refused link is reported so the pane can say what + // happened rather than appearing to ignore the click. + guard target.isFileURL, + target.standardizedFileURL.path.hasPrefix(parent.readAccess.standardizedFileURL.path) + else { + let blocked = target + DispatchQueue.main.async { [parent] in parent.blockedLink = blocked } + decisionHandler(.cancel) + return + } + // A link to a DIFFERENT bundled page is handed back to the owning + // view, which moves its selection — the web view following it on + // its own would leave the sidebar highlighting a page the reader + // has left. A link within the page (a bare fragment) is allowed + // straight through: it is a scroll, not a navigation. + if action.navigationType == .linkActivated, + target.deletingFragment != loaded?.deletingFragment { + let followed = target + DispatchQueue.main.async { [parent] in parent.follow(followed) } + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + } +} diff --git a/gui/macos/Sources/DezhbanMenu/MainView.swift b/gui/macos/Sources/DezhbanMenu/MainView.swift index 9a671ae..056d886 100644 --- a/gui/macos/Sources/DezhbanMenu/MainView.swift +++ b/gui/macos/Sources/DezhbanMenu/MainView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The main window's root: sidebar navigation over the five sections. The +/// The main window's root: sidebar navigation over the window's sections. The /// selection lives in AppState so actions elsewhere (e.g. a window-triggered /// panic) can navigate to the Logs pane programmatically. struct MainView: View { @@ -20,6 +20,7 @@ struct MainView: View { case .overview: OverviewView() case .diagnostics: DiagnosticsView() case .settings: SettingsView() + case .help: HelpView() case .logs: LogsView(console: state.console) case .about: AboutView() } diff --git a/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift b/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift new file mode 100644 index 0000000..2380667 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// The producer side — that help-index.json is written with these keys, and +/// that every page in it has headings and text — is pinned by Go's +/// internal/help TestIndexDecodes. This is the consumer side: that the app +/// decodes that exact shape, and that the sidebar and search built on it behave. +struct HelpIndexTests { + /// The shape `help.Build` writes, including `tutorial` absent on a + /// reference page (Go's `omitempty`) — the field most likely to be missing, + /// and the one whose absence would break decoding hardest. + static let indexJSON = """ + [ + { + "file": "usage-getting-started.html", + "source": "usage/getting-started.md", + "title": "Quick start", + "summary": "Install it, set it up, and arm it.", + "tutorial": 1, + "headings": [ + {"level": 1, "text": "Getting started", "anchor": "getting-started"}, + {"level": 2, "text": "Install", "anchor": "install"} + ], + "text": "Getting started\\nInstall dezhban and check it will not cut your traffic by surprise.\\n" + }, + { + "file": "usage-config.html", + "source": "usage/config.md", + "title": "Configuration reference", + "summary": "Every setting: what it does and what it costs.", + "headings": [ + {"level": 2, "text": "Fields", "anchor": "fields"}, + {"level": 2, "text": "vpn block", "anchor": "vpn-block"} + ], + "text": "Fields\\nvpn.pauseMax bounds a pause, and how long traffic stays uncut.\\n" + }, + { + "file": "concepts-modes.html", + "source": "concepts/modes.md", + "title": "Postures", + "summary": "Every posture the guard can be in.", + "tutorial": 2, + "headings": [{"level": 2, "text": "FULL BLOCK", "anchor": "full-block"}], + "text": "FULL BLOCK cuts everything but the geo lookup.\\n" + } + ] + """ + + /// Writes the fixture where `HelpBundle.load` expects it, the same way the + /// app reads it out of its own bundle. + static func fixture() throws -> HelpBundle { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("help-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data(indexJSON.utf8).write(to: dir.appendingPathComponent("help-index.json")) + return try #require(HelpBundle.load(directory: dir)) + } + + @Test func decodesTheShapeHelpgenWrites() throws { + let bundle = try Self.fixture() + #expect(bundle.pages.count == 3) + let config = try #require(bundle.page(source: "usage/config.md")) + // The omitted `tutorial` reads as reference material, not as a decode + // failure that would drop the page from the sidebar entirely. + #expect(config.tutorial == 0) + #expect(config.file == "usage-config.html") + #expect(config.hasAnchor("fields")) + #expect(!config.hasAnchor("no-such-heading")) + } + + /// A missing index has to be nil rather than an empty bundle, so the pane + /// can say this build shipped without its docs instead of showing an empty + /// sidebar and looking broken. + @Test func aMissingIndexIsNotAnEmptyBundle() { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("help-missing-\(UUID().uuidString)") + #expect(HelpBundle.load(directory: dir) == nil) + } + + @Test func tutorialComesFirstAndInOrder() throws { + let bundle = try Self.fixture() + #expect(bundle.tutorial.map(\.title) == ["Quick start", "Postures"]) + #expect(bundle.reference.map(\.title) == ["Configuration reference"]) + } + + // MARK: - Deep links + + @Test func docAnchorParses() { + #expect(HelpTarget(docAnchor: "usage/config.md#fields")?.source == "usage/config.md") + #expect(HelpTarget(docAnchor: "usage/config.md#fields")?.anchor == "fields") + // A page with no fragment is a whole-page link, not a broken one. + #expect(HelpTarget(docAnchor: "usage/cli.md")?.anchor == nil) + #expect(HelpTarget(docAnchor: "usage/cli.md#")?.anchor == nil) + #expect(HelpTarget(docAnchor: "#fields") == nil) + } + + /// A stale anchor must still open the right page. Landing at the top of the + /// configuration reference is a bad link; landing nowhere is a dead control. + @Test func aStaleAnchorStillOpensThePage() throws { + let bundle = try Self.fixture() + let good = try #require(bundle.resolve(HelpTarget(source: "usage/config.md", anchor: "fields"))) + #expect(good.anchor == "fields") + + let stale = try #require(bundle.resolve(HelpTarget(source: "usage/config.md", anchor: "gone"))) + #expect(stale.page.title == "Configuration reference") + #expect(stale.anchor == nil) + + #expect(bundle.resolve(HelpTarget(source: "docs/nope.md", anchor: nil)) == nil) + } + + // MARK: - Search + + @Test func searchPrefersTitleThenHeadingThenBody() throws { + let bundle = try Self.fixture() + + // A title match wins outright. + #expect(bundle.search("postures").first?.page.title == "Postures") + + // A heading match carries the anchor, so the result lands on the + // section rather than the top of a long reference page. + let heading = try #require(bundle.search("full block").first) + #expect(heading.page.title == "Postures") + #expect(heading.anchor == "full-block") + + // A body-only match still finds the page, and reports the line it + // matched so the result says what it found, not just where. + let body = try #require(bundle.search("pauseMax").first) + #expect(body.page.title == "Configuration reference") + #expect(body.anchor == nil) + #expect(body.context.contains("pauseMax")) + } + + @Test func searchIsCaseInsensitiveAndTrimmed() throws { + let bundle = try Self.fixture() + #expect(bundle.search(" QUICK ").first?.page.title == "Quick start") + #expect(bundle.search(" ").isEmpty) + #expect(bundle.search("wireguard").isEmpty) + } + + /// Within one rank the guided track comes first: a reader who does not know + /// the vocabulary yet is better served by the tutorial than the reference. + /// "traffic" is in the body of one of each and in no title, summary, or + /// heading — so only the tutorial ordering separates them. + @Test func tiedResultsPutTheTutorialFirst() throws { + let bundle = try Self.fixture() + #expect(bundle.search("traffic").map(\.page.title) == ["Quick start", "Configuration reference"]) + } + + @Test func snippetIsOneLineAndBounded() throws { + let long = String(repeating: "pause ", count: 60) + let snippet = try #require(HelpBundle.snippet(in: "unrelated\n\(long)\nmore", around: "pause")) + #expect(snippet.count <= 141) + #expect(!snippet.contains("\n")) + #expect(HelpBundle.snippet(in: "nothing here", around: "pause") == nil) + } +} From 22c5e1b1da39c63536b346fd329cf8047ce0ea75 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 23:46:48 +0330 Subject: [PATCH 15/27] feat(gui): every setting links to what it means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each schema-driven control in Settings gains a ? that opens the Help pane at that key's own section, using the docAnchor the schema already carries. A tooltip holds one sentence; why a setting exists, what it costs, and what turning it off does often needs a page. The link lands on the heading rather than the top of a long reference, and a key whose anchor no longer resolves still opens the right page — Go's TestEveryTunableDocAnchorResolves is what keeps a stale anchor from shipping in the first place. With no schema (a CLI too old to know `config schema`) the button is absent rather than present and inert. --- CHANGELOG.md | 6 ++ docs/contribute/testing.md | 5 ++ .../Sources/DezhbanCore/ConfigSchema.swift | 6 ++ .../Sources/DezhbanMenu/SettingsView.swift | 55 +++++++++++++++---- .../SettingsFieldsTests.swift | 11 ++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3adfa4..a34a3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,12 @@ current as you land changes. match the version that documents them, and the pane refuses to follow any link that leaves the bundle. + Every setting in the Settings pane also carries a **?** button that opens Help + at that setting's own section. A tooltip has room for one sentence; why a + setting exists, what it costs, and what turning it off actually does often + needs a page — and each link lands on the heading, not the top of a long + reference. + ### Changed - **A pause longer than `vpn.pauseMax` is now refused and explained, instead of diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index e44389d..3020a89 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -635,6 +635,11 @@ traffic, so the check that matters is the one CI cannot run: with egress gone. in the pane and reported with a Copy link button — it must not navigate. - [ ] Built from a checkout whose `docs/` was renamed under it, `task gui:build` **fails** rather than producing an app whose Help pane is missing a page. +- [ ] The **?** beside a Settings field opens Help scrolled to that key's own + heading — not the top of the configuration reference. Spot-check one field + per section, including one under Advanced. +- [ ] Against a CLI too old to know `config schema`, the **?** buttons are absent + rather than present and inert. ### Logs pane diff --git a/gui/macos/Sources/DezhbanCore/ConfigSchema.swift b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift index 8fe7612..50ef75c 100644 --- a/gui/macos/Sources/DezhbanCore/ConfigSchema.swift +++ b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift @@ -52,6 +52,12 @@ public struct ConfigTunable: Codable, Identifiable, Hashable { /// True when a change to this key takes effect without restarting the daemon. public var appliesLive: Bool { (restartReason ?? "").isEmpty } + /// Where the Help pane should land for this key. Nil only if the schema + /// carried no anchor at all — Go's TestEveryTunableDocAnchorResolves keeps + /// that from shipping, and a control simply offers no link rather than a + /// dead one. + public var docTarget: HelpTarget? { HelpTarget(docAnchor: docAnchor) } + /// Placeholder text for a text field: the label, then the real default. /// /// It says "default", not "e.g.", because it now IS the default rather than diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 6c924b6..6ed4939 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -410,8 +410,11 @@ struct SettingsView: View { /// the schema, and the cap is resolved against the values this pane is /// actually holding, so lowering a cap by hand narrows the menu. private func durationField(_ key: String, _ fallback: String, text: Binding) -> some View { - DurationField(key: key, fallbackLabel: fallback, schema: schema, - values: fields.currentValues, text: text, enabled: canApply) + HStack(spacing: 6) { + DurationField(key: key, fallbackLabel: fallback, schema: schema, + values: fields.currentValues, text: text, enabled: canApply) + docLink(key) + } } /// A text field for one config key, labelled and explained from the schema. @@ -422,10 +425,13 @@ struct SettingsView: View { @ViewBuilder private func schemaField(_ key: String, _ fallback: String, text: Binding) -> some View { let field = TextField(hint(key, fallback), text: text).disabled(!canApply) - if let help = helpText(key) { - field.help(help) - } else { - field + HStack(spacing: 6) { + if let help = helpText(key) { + field.help(help) + } else { + field + } + docLink(key) } } @@ -433,10 +439,39 @@ struct SettingsView: View { @ViewBuilder private func schemaToggle(_ key: String, _ fallback: String, isOn: Binding) -> some View { let toggle = Toggle(schema?[key]?.label ?? fallback, isOn: isOn).disabled(!canApply) - if let help = helpText(key) { - toggle.help(help) - } else { - toggle + HStack(spacing: 6) { + if let help = helpText(key) { + toggle.help(help) + } else { + toggle + } + docLink(key) + } + } + + /// Opens the Help pane at the section of the documentation that describes + /// this key. + /// + /// A tooltip has room for one sentence; the reason a setting exists, what it + /// costs, and what happens when it is off often needs a page. This is the + /// bridge between the two — and it lands on the *heading*, from the key's + /// own `docAnchor`, so the answer is on screen rather than somewhere in a + /// long reference page. + /// + /// Absent when the schema is unavailable (a CLI too old to know + /// `config schema`): a button that could only apologise is worse than none. + @ViewBuilder + private func docLink(_ key: String) -> some View { + if let tunable = schema?[key], !tunable.docAnchor.isEmpty { + Button { + state.openHelp(docAnchor: tunable.docAnchor) + } label: { + Image(systemName: "questionmark.circle") + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Read about \(tunable.label) in the documentation") + .accessibilityLabel("Documentation for \(tunable.label)") } } diff --git a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift index 178a8ea..4f57ce0 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift @@ -207,6 +207,17 @@ struct ConfigSchemaTests { #expect(schema.restartRequired(among: ["logLevel", "unknown"]) == ["logLevel"]) } + /// Every control's help link is only as good as the anchor it carries. Go's + /// TestEveryTunableDocAnchorResolves proves the anchors exist in the + /// bundled pages; this proves the app turns them into a page and a heading + /// rather than dropping the fragment and landing at the top. + @Test func docAnchorBecomesADeepLink() { + let schema = testSchema() + let target = try! #require(schema["vpn.endpointRefresh"]?.docTarget) + #expect(target.source == "usage/config.md") + #expect(target.anchor == "fields") + } + /// The placeholder states the real default, which is the whole point: the /// pane used to say "e.g. 30s" for a key whose default was 1m. @Test func placeholderStatesTheRealDefault() { From 1b38afff27cdaad670e35cf451db6a2dbf735c9d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sun, 26 Jul 2026 23:58:05 +0330 Subject: [PATCH 16/27] refactor(setup): the wizard's questions become data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/dezhban/setup.go held what the wizard asks, in what order, which answers unlock which follow-ups, and how answers become a config — all tangled into huh form construction, and reachable only through a TTY. internal/setup now owns the decisions; the CLI keeps the presentation and renders fields from the question set. That makes the wizard testable for the first time: pressing Enter through every question now provably lands on the config you started with, gating is checked without a terminal, and declining the VPN branch is pinned not to touch a VPN somebody already configured. It also exposes `dezhban setup --questions [--json]` — read-only, no root, no TTY — so a second wizard can render the same questions instead of keeping its own copy of them. That is what the macOS first-run wizard will read. Fixes a data-loss bug the extraction made obvious: applying the wizard ASSIGNED the imported profile list over the configured one, so re-running setup to change a log level, without naming those files again, silently deleted every profile a user had imported. Imported profiles now merge into the saved ones, replacing by name. --- CHANGELOG.md | 18 ++ CLAUDE.md | 3 +- cmd/dezhban/setup.go | 360 +++++++++++++---------------------- cmd/dezhban/setup_test.go | 54 ------ docs/contribute/testing.md | 9 + docs/usage/cli.md | 13 +- internal/setup/answers.go | 259 +++++++++++++++++++++++++ internal/setup/lockout.go | 37 ++++ internal/setup/questions.go | 269 ++++++++++++++++++++++++++ internal/setup/setup_test.go | 270 ++++++++++++++++++++++++++ 10 files changed, 1007 insertions(+), 285 deletions(-) delete mode 100644 cmd/dezhban/setup_test.go create mode 100644 internal/setup/answers.go create mode 100644 internal/setup/lockout.go create mode 100644 internal/setup/questions.go create mode 100644 internal/setup/setup_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a34a3e5..acdbfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,17 @@ current as you land changes. needs a page — and each link lands on the heading, not the top of a long reference. +- **`dezhban setup --questions` says what the wizard would ask** — each question, + what it writes, its seeded answer, and which earlier answer unlocks it — + without asking anything. Read-only, no root, no terminal needed; `--json` is + the machine form. + + Behind it, the wizard's decisions moved out of the CLI into `internal/setup`, + which now owns the question set, the branching, and how answers become a + config. The CLI keeps only the presentation. A second wizard therefore cannot + ask different questions or apply the same answer differently — the same reason + the settings schema lives in one place. + ### Changed - **A pause longer than `vpn.pauseMax` is now refused and explained, instead of @@ -125,6 +136,13 @@ current as you land changes. ### Fixed +- **Re-running `dezhban setup` no longer deletes your saved VPN profiles.** The + wizard collects profiles by importing config files you name, and it wrote that + list over the configured one — so running setup again to change, say, your log + level, and not naming those files a second time, silently dropped every + profile you had imported. Imported profiles are now merged into the saved + ones, replacing by name. + - **The macOS app's Settings pane no longer advertises wrong defaults.** Its field hints were literal strings that had drifted from the shipped values — it suggested a 30s endpoint refresh and a 5s tunnel watch when the defaults are diff --git a/CLAUDE.md b/CLAUDE.md index df592dc..1031ac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ plus three globals: `-v`/`--verbose`, `--no-sudo` (skip auto-elevation), The **privileged set** — requires root/admin — is exactly: `run`, `block`, `unblock`, `panic`, `install`, `uninstall`, `start`, `stop`, `restart`, `switch`, `pause`, `resume`, `vpn add`/`remove`/`promote`/`forget`/`import` (but not -`vpn list`/`show`), `setup`, `config set`/`edit`/`preset apply`, `token enroll`/`forget` (but not +`vpn list`/`show`), `setup` (but not `setup --questions`, which asks nothing and only reports what +the wizard would ask), `config set`/`edit`/`preset apply`, `token enroll`/`forget` (but not `token status`), and `upgrade download`/`upgrade apply` (macOS only — `download`'s staging directory is root-owned so a local user can't swap the verified `.pkg` before `apply` installs it). `switch`, `pause`, and `resume` are usually passwordless in practice: they ask the running daemon diff --git a/cmd/dezhban/setup.go b/cmd/dezhban/setup.go index 857d37c..499c244 100644 --- a/cmd/dezhban/setup.go +++ b/cmd/dezhban/setup.go @@ -1,15 +1,14 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" - "net/netip" "os" "runtime" "strconv" "strings" - "time" "github.com/charmbracelet/huh" "github.com/charmbracelet/x/term" @@ -17,35 +16,26 @@ import ( "github.com/behnam-rk/dezhban/internal/config" "github.com/behnam-rk/dezhban/internal/firewall" "github.com/behnam-rk/dezhban/internal/netdetect" + "github.com/behnam-rk/dezhban/internal/setup" "github.com/behnam-rk/dezhban/internal/vpnimport" ) -// commonBlocked are the codes offered as checkboxes in the wizard; any others can -// be typed in the free-text field. -var commonBlocked = []struct{ label, code string }{ - {"Iran (IR)", "IR"}, - {"Russia (RU)", "RU"}, - {"China (CN)", "CN"}, - {"North Korea (KP)", "KP"}, - {"Syria (SY)", "SY"}, - {"Cuba (CU)", "CU"}, - {"Belarus (BY)", "BY"}, -} - // cmdSetup runs an interactive wizard that builds a config and writes it, so the // user never hand-edits JSON. It reuses the same detection/validation/preview // helpers as detect-vpn, validate and print-rules. Requires a TTY. +// +// WHAT it asks — the questions, their order, and which answers unlock which +// follow-ups — lives in internal/setup, not here. This file is presentation: +// huh fields, the ruleset preview, and the save/install prompts. The macOS +// app's own first-run wizard reads the same question set over +// `setup --questions --json`, so the two cannot drift apart. func cmdSetup(args []string) int { fs := flag.NewFlagSet("setup", flag.ExitOnError) cfgPath := fs.String("config", "", "path to write the config (default: canonical system path)") + questions := fs.Bool("questions", false, "print the wizard's questions and exit (read-only, no TTY needed)") + asJSON := fs.Bool("json", false, "with --questions, print them as JSON") _ = fs.Parse(args) - if !isInteractive() { - fmt.Fprintln(os.Stderr, "dezhban setup needs an interactive terminal.") - fmt.Fprintln(os.Stderr, "Non-interactive? Use 'dezhban config set ' or edit the file directly.") - return 1 - } - // Seed from the current config so setup edits rather than clobbers; fall back // to defaults if none exists or the current file is unreadable/invalid. cfg, err := loadConfig(*cfgPath) @@ -55,121 +45,50 @@ func cmdSetup(args []string) int { cfg = &d } - // --- wizard state (huh binds to string/bool/[]string) --- - pollInterval := cfg.PollInterval.String() - hysteresis := strconv.Itoa(cfg.Hysteresis) - logLevel := cfg.LogLevel - quorum := cfg.ProviderQuorum - configureVPN := true + detected, _ := netdetect.TunnelInterfaces() + qs := setup.Questions(setup.Options{ + Config: cfg, ConfigExisted: configExisted, + GOOS: runtime.GOOS, DetectedTunnels: detected, + }) - blockedSet := map[string]bool{} - for _, c := range cfg.BlockedCountries { - blockedSet[strings.ToUpper(c)] = true - } - var checkedCountries []string - countryOpts := make([]huh.Option[string], 0, len(commonBlocked)) - for _, cc := range commonBlocked { - opt := huh.NewOption(cc.label, cc.code) - if blockedSet[cc.code] { - opt = opt.Selected(true) - delete(blockedSet, cc.code) - } - countryOpts = append(countryOpts, opt) - } - // Any configured codes not in the common list seed the free-text field. - var extraCodes []string - for code := range blockedSet { - extraCodes = append(extraCodes, code) + // --questions is read-only and needs no terminal: it is how another surface + // (the macOS first-run wizard) asks what this wizard would ask. + if *questions { + return printQuestions(qs, *asJSON) } - otherCountries := strings.Join(extraCodes, ",") - basics := huh.NewForm(huh.NewGroup( - huh.NewInput().Title("Poll interval").Description("How often the country is checked, e.g. 30s."). - Value(&pollInterval).Validate(validDuration), - huh.NewMultiSelect[string]().Title("Blocked countries"). - Description("Traffic is cut when the detected country matches."). - Options(countryOpts...).Value(&checkedCountries), - huh.NewInput().Title("Other country codes").Description("Comma-separated ISO codes not listed above (optional)."). - Value(&otherCountries), - huh.NewSelect[string]().Title("Log level"). - Options(huh.NewOptions("debug", "info", "warn", "error")...).Value(&logLevel), - huh.NewConfirm().Title("Require provider quorum?").Description("Only act when a majority of providers agree."). - Value(&quorum), - huh.NewConfirm().Title("Configure your VPN now?"). - Description("dezhban only enforces once it knows your VPN's tunnel and server. Say no and it starts in standby — fully open, nothing blocked — until you run 'dezhban setup' again or edit the config."). - Value(&configureVPN), - )) - if err := runForm(basics); err != nil { - return formExit(err) + if !isInteractive() { + fmt.Fprintln(os.Stderr, "dezhban setup needs an interactive terminal.") + fmt.Fprintln(os.Stderr, "Non-interactive? Use 'dezhban config set ' or edit the file directly.") + return 1 } - // --- VPN branch --- - var tunnels []string - autoMode := true - endpoints := strings.Join(cfg.VPN.Endpoints, ",") - profileFiles := "" - macOS := runtime.GOOS == "darwin" - // Preserve an existing config's choice; only default ON for a brand-new macOS - // config (the recommended path). Re-running setup must never silently flip a - // user's explicit autoDiscoverEndpoints=false back to true — the confirm below - // (macOS only) makes it an explicit, seeded decision. - autoDiscover := cfg.VPN.AutoDiscoverEndpoints - if macOS && !configExisted { - autoDiscover = true - } - allowPhysicalDNS := cfg.VPN.AllowPhysicalDNS - var profiles []config.Profile - if configureVPN { - // Recommended path: automatic detection (no pinned interface names that go - // stale across redials). The old pin-specific-interfaces flow survives - // behind an advanced opt-out. - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title("Use automatic VPN detection? (recommended)"). - Description("dezhban finds your tunnel and, on macOS, learns the server address itself — works with any VPN and survives redials."). - Value(&autoMode), - ))); err != nil { - return formExit(err) - } - if !autoMode { - detected, _ := netdetect.TunnelInterfaces() - if err := runForm(huh.NewForm(huh.NewGroup( - tunnelSelector(detected, cfg.VPN.TunnelInterfaces, &tunnels), - ))); err != nil { - return formExit(err) + answers := setup.NewAnswers(qs) + // Asked a screenful at a time, in Group order, so a gate can be evaluated + // against answers already given — which is exactly what makes the VPN + // branch a branch. + for _, group := range groupsOf(qs) { + var fields []huh.Field + for _, q := range qs { + if q.Group != group || !answers.ShouldAsk(q) { + continue } + fields = append(fields, field(q, answers)) } - // Profiles + endpoints + redial DNS. On Linux/Windows (no live - // discovery) at least one profile/endpoint is the reliable path. - epTitle := "VPN endpoint(s)" - epDesc := "Server IP(s)/hostname(s), comma-separated. Optional on macOS (auto-discovered); needed elsewhere." - if !macOS { - epDesc = "Server IP(s)/hostname(s), comma-separated. Required on this platform (no live discovery)." + if len(fields) == 0 { + continue } - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewInput().Title("Self-hosted VPN config files"). - Description("Comma-separated paths to WireGuard/.conf, OpenVPN/.ovpn, or V2Ray JSON to import as profiles (optional)."). - Value(&profileFiles), - huh.NewInput().Title(epTitle).Description(epDesc).Value(&endpoints), - huh.NewConfirm().Title("Allow DNS on the physical link while the tunnel is down?"). - Description("Lets a VPN client re-resolve its server hostname to redial. Leaks only DNS-query metadata; your traffic stays blocked. Recommended if any endpoint is a hostname."). - Value(&allowPhysicalDNS), - ))); err != nil { + if err := runForm(huh.NewForm(huh.NewGroup(fields...))); err != nil { return formExit(err) } - // Live endpoint discovery is macOS-only. Make it an explicit, seeded choice - // so re-running setup never silently flips an existing preference. - if macOS { - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title("Auto-discover the VPN server address? (recommended)"). - Description("dezhban watches the live tunnel socket to learn the server IP, so you don't pin one that changes. macOS only."). - Value(&autoDiscover), - ))); err != nil { - return formExit(err) - } - } - // Import any named config files into profiles (best-effort; a bad file is - // reported but doesn't abort the wizard). - for _, f := range splitList(profileFiles) { + } + + // Import any named config files into profiles (best-effort; a bad file is + // reported but doesn't abort the wizard). Reading files is the caller's job, + // not internal/setup's. + var profiles []config.Profile + if answers.Bool("configureVPN") { + for _, f := range setup.SplitList(answers.Text("profileFiles")) { eps, format, ierr := vpnimport.Extract(f) if ierr != nil { fmt.Fprintf(os.Stderr, " skipping %s: %v\n", f, ierr) @@ -181,15 +100,7 @@ func cmdSetup(args []string) int { } } - // --- assemble into the config --- - applyWizard(cfg, wizardInput{ - pollInterval: pollInterval, hysteresis: hysteresis, logLevel: logLevel, - quorum: quorum, - countries: append(checkedCountries, splitList(otherCountries)...), - configureVPN: configureVPN, autoMode: autoMode, tunnels: tunnels, - endpoints: splitList(endpoints), profiles: profiles, - autoDiscover: autoDiscover && macOS, allowPhysicalDNS: allowPhysicalDNS, - }) + setup.Apply(cfg, answers.Input(strconv.Itoa(cfg.Hysteresis), profiles)) config.Normalize(cfg) if err := cfg.Validate(); err != nil { @@ -199,8 +110,8 @@ func cmdSetup(args []string) int { } // --- lockout guard: warn if an endpoint sits inside a tunnel subnet --- - if configureVPN { - if warn := endpointLockoutWarning(cfg); warn != "" { + if answers.Bool("configureVPN") { + if warn := setup.EndpointLockoutWarning(cfg); warn != "" { var proceed bool fmt.Fprintln(os.Stderr, warn) if err := runForm(huh.NewForm(huh.NewGroup( @@ -263,104 +174,107 @@ func cmdSetup(args []string) int { } else { fmt.Println("later, enable it with: sudo dezhban install && sudo dezhban start") } - if configureVPN { + if answers.Bool("configureVPN") { fmt.Println("to connect a brand-new VPN whose server isn't known yet: sudo dezhban switch, then connect it.") } return 0 } -// tunnelSelector returns a MultiSelect over detected tunnels (preselecting the -// configured ones), or a free-text Input when nothing was detected. -func tunnelSelector(detected, configured []string, dst *[]string) huh.Field { - if len(detected) == 0 { - // No live tunnels — fall back to comma-separated entry via a shim. - joined := strings.Join(configured, ",") - return huh.NewInput().Title("Tunnel interface(s)"). - Description("None detected. Enter comma-separated names (e.g. utun4)."). - Value(&joined).Validate(func(string) error { - *dst = splitList(joined) - return nil - }) - } - cfgSet := map[string]bool{} - for _, t := range configured { - cfgSet[t] = true - } - opts := make([]huh.Option[string], 0, len(detected)) - for _, t := range detected { - opt := huh.NewOption(t, t) - if cfgSet[t] { - opt = opt.Selected(true) +// field renders one question as a huh field bound to its answer. +// +// The mapping is deliberately total over setup's kinds: a kind this does not +// know falls through to a text input, which is wrong-looking but still +// answerable — better than a question silently disappearing from the wizard. +func field(q setup.Question, a *setup.Answers) huh.Field { + switch q.Kind { + case setup.KindBool: + return huh.NewConfirm().Title(q.Title).Description(q.Description).Value(a.BoolPtr(q.ID)) + case setup.KindSelect: + opts := make([]huh.Option[string], 0, len(q.Options)) + for _, o := range q.Options { + opts = append(opts, huh.NewOption(o.Label, o.Value)) } - opts = append(opts, opt) + return huh.NewSelect[string]().Title(q.Title).Description(q.Description). + Options(opts...).Value(a.TextPtr(q.ID)) + case setup.KindMultiSelect: + selected := map[string]bool{} + for _, v := range q.Selected { + selected[v] = true + } + opts := make([]huh.Option[string], 0, len(q.Options)) + for _, o := range q.Options { + opt := huh.NewOption(o.Label, o.Value) + if selected[o.Value] { + opt = opt.Selected(true) + } + opts = append(opts, opt) + } + return huh.NewMultiSelect[string]().Title(q.Title).Description(q.Description). + Options(opts...).Value(a.ListPtr(q.ID)) + case setup.KindDuration: + return huh.NewInput().Title(q.Title).Description(q.Description). + Value(a.TextPtr(q.ID)).Validate(setup.ValidDuration) + default: + return huh.NewInput().Title(q.Title).Description(q.Description).Value(a.TextPtr(q.ID)) } - return huh.NewMultiSelect[string]().Title("Tunnel interface(s)"). - Description("Detected tunnels — pick the VPN's.").Options(opts...).Value(dst) -} - -// wizardInput carries the collected answers into the config. -type wizardInput struct { - pollInterval, hysteresis, logLevel string - quorum bool - countries []string - configureVPN bool - autoMode bool // automatic tunnel detection (no pinned interfaces) - tunnels, endpoints []string - profiles []config.Profile - autoDiscover bool - allowPhysicalDNS bool } -// applyWizard writes collected answers onto cfg. Validation happens after. -func applyWizard(cfg *config.Config, in wizardInput) { - if d, err := time.ParseDuration(in.pollInterval); err == nil { - cfg.PollInterval = d - } - if n, err := strconv.Atoi(strings.TrimSpace(in.hysteresis)); err == nil { - cfg.Hysteresis = n +// groupsOf lists the question groups in ascending order. +func groupsOf(qs []setup.Question) []int { + var out []int + seen := map[int]bool{} + for _, q := range qs { + if !seen[q.Group] { + seen[q.Group] = true + out = append(out, q.Group) + } } - cfg.LogLevel = in.logLevel - cfg.ProviderQuorum = in.quorum - cfg.BlockedCountries = in.countries // config.Normalize upper-cases + de-dupes on save - - if in.configureVPN { - if in.autoMode { - // Automatic detection: no pinned interface names (Normalize implies - // autodetect), plus live discovery where supported. - cfg.VPN.TunnelInterfaces = nil - } else { - cfg.VPN.TunnelInterfaces = in.tunnels + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] } - cfg.VPN.Endpoints = in.endpoints - cfg.VPN.Profiles = in.profiles - cfg.VPN.AutoDiscoverEndpoints = in.autoDiscover - cfg.VPN.AllowPhysicalDNS = in.allowPhysicalDNS } + return out } -// endpointLockoutWarning returns a non-empty message if any IP endpoint sits -// inside a tunnel's own subnet — the #1 lockout cause. -func endpointLockoutWarning(cfg *config.Config) string { - var addrs []netip.Addr - for _, ep := range config.EffectiveEndpoints(cfg, nil) { - if a, err := netip.ParseAddr(ep); err == nil { - addrs = append(addrs, a) +// printQuestions answers "what would setup ask me?" without asking anything — +// read-only, no root, no terminal needed. `--json` is what another wizard reads. +func printQuestions(qs []setup.Question, asJSON bool) int { + if asJSON { + out, err := json.MarshalIndent(qs, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "encode questions:", err) + return 1 } + fmt.Println(string(out)) + return 0 } - if len(addrs) == 0 { - return "" - } - bad, err := netdetect.CheckEndpointRouting(addrs, cfg.VPN.TunnelInterfaces) - if err != nil || len(bad) == 0 { - return "" - } - var b strings.Builder - b.WriteString("\n⚠ WARNING: endpoint(s) sit inside a tunnel subnet — this will likely lock you out:\n") - for _, r := range bad { - fmt.Fprintf(&b, " %s is within %s (%s)\n", r.Endpoint, r.Subnet, r.Iface) + for _, q := range qs { + fmt.Printf("%s (%s)\n", q.Title, q.Kind) + if q.Description != "" { + fmt.Printf(" %s\n", q.Description) + } + if q.Key != "" { + fmt.Printf(" writes: %s\n", q.Key) + } + if q.Default != "" { + fmt.Printf(" default: %s\n", q.Default) + } + if len(q.Selected) > 0 { + fmt.Printf(" selected: %s\n", strings.Join(q.Selected, ", ")) + } + if len(q.Options) > 0 { + vals := make([]string, 0, len(q.Options)) + for _, o := range q.Options { + vals = append(vals, o.Value) + } + fmt.Printf(" options: %s\n", strings.Join(vals, ", ")) + } + if q.Gated() { + fmt.Printf(" asked only when %s is %s\n", q.RequiresID, q.RequiresValue) + } } - b.WriteString(" Set the VPN server's PHYSICAL (public) address instead — see 'dezhban doctor --discover'.") - return b.String() + return 0 } // runForm runs a huh form with a consistent theme. @@ -378,18 +292,6 @@ func formExit(err error) int { return 1 } -// validDuration validates a huh Input holding a positive Go duration. -func validDuration(s string) error { - d, err := time.ParseDuration(strings.TrimSpace(s)) - if err != nil { - return errors.New("enter a duration like 30s or 5m") - } - if d <= 0 { - return errors.New("must be greater than zero") - } - return nil -} - // isInteractive reports whether both stdin and stdout are terminals. func isInteractive() bool { return isTerminal(os.Stdin) && isTerminal(os.Stdout) diff --git a/cmd/dezhban/setup_test.go b/cmd/dezhban/setup_test.go deleted file mode 100644 index 3292091..0000000 --- a/cmd/dezhban/setup_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "testing" - - "github.com/behnam-rk/dezhban/internal/config" -) - -// applyWizard in auto mode must produce a "connect any VPN" config: no pinned -// interfaces (autodetect implied on Normalize), profiles carried, and -// allowPhysicalDNS honored. -func TestApplyWizardAutoMode(t *testing.T) { - cfg := config.Default() - applyWizard(&cfg, wizardInput{ - pollInterval: "30s", hysteresis: "3", logLevel: "info", - configureVPN: true, autoMode: true, - tunnels: []string{"utun9"}, // must be ignored in auto mode - endpoints: []string{"vpn.example.com"}, - profiles: []config.Profile{{Name: "home", Endpoints: []string{"203.0.113.7"}}}, - autoDiscover: true, - allowPhysicalDNS: true, - }) - if len(cfg.VPN.TunnelInterfaces) != 0 { - t.Errorf("auto mode must not pin interfaces, got %v", cfg.VPN.TunnelInterfaces) - } - if !cfg.VPN.AllowPhysicalDNS { - t.Error("allowPhysicalDNS should be set") - } - if len(cfg.VPN.Profiles) != 1 || cfg.VPN.Profiles[0].Name != "home" { - t.Errorf("profiles not carried: %+v", cfg.VPN.Profiles) - } - // Normalize (run on save) implies autodetect when no interfaces are pinned. - config.Normalize(&cfg) - if !cfg.VPN.AutoDetect { - t.Error("autodetect should be implied for an auto-mode config") - } - if err := cfg.Validate(); err != nil { - t.Errorf("auto-mode config should validate: %v", err) - } -} - -// Advanced mode pins the chosen interfaces. -func TestApplyWizardAdvancedPin(t *testing.T) { - cfg := config.Default() - applyWizard(&cfg, wizardInput{ - pollInterval: "30s", hysteresis: "3", logLevel: "info", - configureVPN: true, autoMode: false, - tunnels: []string{"utun4"}, - endpoints: []string{"203.0.113.7"}, - }) - if len(cfg.VPN.TunnelInterfaces) != 1 || cfg.VPN.TunnelInterfaces[0] != "utun4" { - t.Errorf("advanced mode should pin utun4, got %v", cfg.VPN.TunnelInterfaces) - } -} diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 3020a89..04efbe3 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -451,6 +451,15 @@ macOS only, privileged (`dezhban upgrade download`/`apply`). See - [ ] A fresh `dezhban setup` on macOS produces an autodetect + auto-discovery config with **zero** concrete interface names, and offers to install and start the service. +- [ ] Re-running it on a configured host seeds every question with what the + config already says, and pressing Enter through the whole wizard writes a + config identical to the one you started from (`dezhban config show` before + and after). `internal/setup` pins this, but only the real run proves the + forms are bound to the same answers. +- [ ] Re-running it **without** naming profile files keeps the profiles already + imported (`dezhban vpn list`). +- [ ] `dezhban setup --questions --json` runs with no TTY, no root, and no + config file present, and lists the same questions the wizard asks. ## macOS app diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 8941384..377dc52 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -54,7 +54,7 @@ daemon** over its control socket and need no password at all: | `panic` | Yes — deliberately independent of the daemon, so the lockout escape hatch works when nothing else does. | | `run` | Yes — it *is* the daemon. | | `setup`, `config set`/`edit`, `config preset apply` | Yes, but only for the config write itself. `preset apply` is a write like any other — see [Presets](#presets). | -| `config show`/`path`/`schema`, `config preset list`/`show`/`diff` | **No** — read-only; they report the config, they don't change it. | +| `config show`/`path`/`schema`, `config preset list`/`show`/`diff`, `setup --questions` | **No** — read-only; they report the config (or what the wizard would ask), they don't change it. | | `token status` | **No** — reports whether a control token is enrolled; the answer is not itself a secret. | | `token enroll`, `token forget` | Yes — the token's hash lives in the daemon's root-owned state dir, because anything that could rewrite it could nominate its own token. Once, at setup. | | `upgrade check` | **No** — read-only, no root. | @@ -200,6 +200,17 @@ succeeds and says so; the new values are read the next time it starts. validation, and ruleset preview as `detect-vpn`/`validate`/`print-rules`. Writes to the system path need root (hence `sudo`); a permission error prints a `sudo` hint. +`setup --questions` is the exception: it prints what the wizard *would* ask — +each question, what it writes, its seeded answer, and which earlier answer +unlocks it — and asks nothing. Read-only, no root, no terminal needed. +`--json` is the machine form, and is how the macOS app's own first-run wizard +gets the question set instead of keeping a second copy of it. + +```sh +dezhban setup --questions # what would you ask me? +dezhban setup --questions --json # the same, for another surface to render +``` + ### Asking what a key is `config schema` describes the keys themselves rather than your values: for each diff --git a/internal/setup/answers.go b/internal/setup/answers.go new file mode 100644 index 0000000..d6749e2 --- /dev/null +++ b/internal/setup/answers.go @@ -0,0 +1,259 @@ +package setup + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Answers holds one binding per question, seeded with that question's default. +// +// The bindings are pointers because that is what huh writes through: the CLI +// hands `TextPtr`/`BoolPtr`/`ListPtr` straight to a form field, the form fills +// them in, and Input reads the result. A native wizard sets them with Set +// instead. Both then produce the same Input, which is the point. +type Answers struct { + text map[string]*string + flag map[string]*bool + list map[string]*[]string + // asked remembers the question set, so gating can be evaluated against the + // answers actually collected rather than re-derived by each caller. + asked []Question +} + +// NewAnswers allocates a binding per question, seeded from its default. +func NewAnswers(qs []Question) *Answers { + a := &Answers{ + text: map[string]*string{}, flag: map[string]*bool{}, list: map[string]*[]string{}, + asked: qs, + } + for _, q := range qs { + switch q.Kind { + case KindBool: + v := q.Default == "true" + a.flag[q.ID] = &v + case KindMultiSelect: + v := append([]string(nil), q.Selected...) + a.list[q.ID] = &v + default: + v := q.Default + a.text[q.ID] = &v + } + } + return a +} + +// TextPtr, BoolPtr, and ListPtr are the bindings a form writes through. An +// unknown ID gets a throwaway binding rather than a nil dereference: a wizard +// asking for a question that does not exist is a bug, but not one worth +// crashing a user's setup over. +func (a *Answers) TextPtr(id string) *string { + if p, ok := a.text[id]; ok { + return p + } + var v string + a.text[id] = &v + return &v +} + +func (a *Answers) BoolPtr(id string) *bool { + if p, ok := a.flag[id]; ok { + return p + } + var v bool + a.flag[id] = &v + return &v +} + +func (a *Answers) ListPtr(id string) *[]string { + if p, ok := a.list[id]; ok { + return p + } + var v []string + a.list[id] = &v + return &v +} + +// Text, Bool, and List read an answer back. +func (a *Answers) Text(id string) string { + if p, ok := a.text[id]; ok { + return *p + } + return "" +} + +func (a *Answers) Bool(id string) bool { + if p, ok := a.flag[id]; ok { + return *p + } + return false +} + +func (a *Answers) List(id string) []string { + if p, ok := a.list[id]; ok { + return append([]string(nil), *p...) + } + return nil +} + +// Set writes an answer as a string, which is how a non-Go wizard delivers one: +// "true"/"false" for a bool, comma-separated for a list. +func (a *Answers) Set(id, value string) { + switch { + case a.flag[id] != nil: + v := value == "true" + *a.flag[id] = v + case a.list[id] != nil: + *a.list[id] = SplitList(value) + default: + v := value + a.text[id] = &v + } +} + +// ShouldAsk reports whether a question's gate is satisfied by the answers so +// far. A question whose gate is not met is skipped, and its seeded default is +// what Input sees — which is why gating and application live together here +// rather than in each wizard. +func (a *Answers) ShouldAsk(q Question) bool { + if !q.Gated() { + return true + } + if p, ok := a.flag[q.RequiresID]; ok { + return strconv.FormatBool(*p) == q.RequiresValue + } + return a.Text(q.RequiresID) == q.RequiresValue +} + +// Input is the collected answers, in the shape the config wants them. +type Input struct { + PollInterval, Hysteresis, LogLevel string + Quorum bool + Countries []string + ConfigureVPN bool + // AutoMode is automatic tunnel detection: no pinned interface names. + AutoMode bool + Tunnels, Endpoints []string + Profiles []config.Profile + AutoDiscover bool + AllowPhysicalDNS bool +} + +// Input folds the answers into the shape Apply writes. +// +// hysteresis and profiles are not questions: hysteresis is carried through from +// the existing config untouched, and profiles are the result of importing the +// files named by the "profileFiles" answer — which the caller does, because +// reading a file is not this package's job. +func (a *Answers) Input(hysteresis string, profiles []config.Profile) Input { + countries := append(a.List("blockedCountries"), SplitList(a.Text("otherCountries"))...) + tunnels := a.List("tunnels") + if len(tunnels) == 0 { + // The no-tunnels-detected form of the question is free text. + tunnels = SplitList(a.Text("tunnels")) + } + return Input{ + PollInterval: a.Text("pollInterval"), + Hysteresis: hysteresis, + LogLevel: a.Text("logLevel"), + Quorum: a.Bool("providerQuorum"), + Countries: countries, + ConfigureVPN: a.Bool("configureVPN"), + AutoMode: a.Bool("autoMode"), + Tunnels: tunnels, + Endpoints: SplitList(a.Text("endpoints")), + Profiles: profiles, + // Absent on every platform but macOS, where the question is not asked — + // so the binding is missing and this reads false, which is the truth. + AutoDiscover: a.Bool("autoDiscover"), + AllowPhysicalDNS: a.Bool("allowPhysicalDNS"), + } +} + +// Apply writes collected answers onto cfg. Validation happens after, by the +// caller: this only assembles. +// +// A question the user never reached leaves its part of the config alone. That +// is why the VPN keys are written only when ConfigureVPN is true — answering +// "no" must not blank out a tunnel someone configured earlier. +func Apply(cfg *config.Config, in Input) { + if d, err := time.ParseDuration(strings.TrimSpace(in.PollInterval)); err == nil { + cfg.PollInterval = d + } + if n, err := strconv.Atoi(strings.TrimSpace(in.Hysteresis)); err == nil { + cfg.Hysteresis = n + } + cfg.LogLevel = in.LogLevel + cfg.ProviderQuorum = in.Quorum + cfg.BlockedCountries = in.Countries // config.Normalize upper-cases and de-dupes on save + + if !in.ConfigureVPN { + return + } + if in.AutoMode { + // Automatic detection: no pinned interface names (Normalize implies + // autodetect), plus live discovery where supported. + cfg.VPN.TunnelInterfaces = nil + } else { + cfg.VPN.TunnelInterfaces = in.Tunnels + } + cfg.VPN.Endpoints = in.Endpoints + cfg.VPN.Profiles = mergeProfiles(cfg.VPN.Profiles, in.Profiles) + cfg.VPN.AutoDiscoverEndpoints = in.AutoDiscover + cfg.VPN.AllowPhysicalDNS = in.AllowPhysicalDNS +} + +// mergeProfiles adds newly imported profiles to the ones already configured, +// replacing by name. +// +// It merges rather than assigns because imported profiles are the only ones the +// wizard collects: assigning would mean that re-running setup and not naming a +// file again silently deleted every saved profile — losing the endpoints a user +// imported months ago, in a wizard they ran to change their log level. +func mergeProfiles(existing, imported []config.Profile) []config.Profile { + if len(imported) == 0 { + return existing + } + out := append([]config.Profile(nil), existing...) + for _, p := range imported { + replaced := false + for i := range out { + if out[i].Name == p.Name { + out[i] = p + replaced = true + break + } + } + if !replaced { + out = append(out, p) + } + } + return out +} + +// ValidDuration validates an answer holding a positive Go duration. +func ValidDuration(s string) error { + d, err := time.ParseDuration(strings.TrimSpace(s)) + if err != nil { + return errors.New("enter a duration like 30s or 5m") + } + if d <= 0 { + return errors.New("must be greater than zero") + } + return nil +} + +// SplitList parses comma-separated input, dropping blanks. +func SplitList(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/setup/lockout.go b/internal/setup/lockout.go new file mode 100644 index 0000000..9640909 --- /dev/null +++ b/internal/setup/lockout.go @@ -0,0 +1,37 @@ +package setup + +import ( + "fmt" + "net/netip" + "strings" + + "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/netdetect" +) + +// EndpointLockoutWarning returns a non-empty message when an IP endpoint sits +// inside a tunnel's own subnet — the single most common way to lock yourself +// out, because the pass that is supposed to let the VPN redial points at an +// address only reachable through the tunnel that is down. +func EndpointLockoutWarning(cfg *config.Config) string { + var addrs []netip.Addr + for _, ep := range config.EffectiveEndpoints(cfg, nil) { + if a, err := netip.ParseAddr(ep); err == nil { + addrs = append(addrs, a) + } + } + if len(addrs) == 0 { + return "" + } + bad, err := netdetect.CheckEndpointRouting(addrs, cfg.VPN.TunnelInterfaces) + if err != nil || len(bad) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n⚠ WARNING: endpoint(s) sit inside a tunnel subnet — this will likely lock you out:\n") + for _, r := range bad { + fmt.Fprintf(&b, " %s is within %s (%s)\n", r.Endpoint, r.Subnet, r.Iface) + } + b.WriteString(" Set the VPN server's PHYSICAL (public) address instead — see 'dezhban doctor --discover'.") + return b.String() +} diff --git a/internal/setup/questions.go b/internal/setup/questions.go new file mode 100644 index 0000000..c4b5e1d --- /dev/null +++ b/internal/setup/questions.go @@ -0,0 +1,269 @@ +// Package setup holds the first-run wizard's decisions: what it asks, in what +// order, which answers unlock which follow-ups, and how the answers become a +// config. +// +// It is deliberately free of any presentation. The CLI renders these questions +// with huh; the macOS app renders the same list natively from +// `dezhban setup --questions --json`. Neither owns the question set, so the two +// wizards cannot ask different things or apply the same answer differently — +// the same reason internal/config owns the tunable schema rather than each +// surface holding its own copy. +// +// Nothing here touches the firewall, the daemon, or the filesystem. It reads a +// config, and reports questions and the config the answers imply. +package setup + +import ( + "sort" + "strconv" + "strings" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Question kinds. The set is small on purpose: every kind must be renderable +// both as a huh field and as a native control, so adding one is a change in +// two places and should be rare. +const ( + // KindDuration is a Go duration string, validated by ValidDuration. + KindDuration = "duration" + // KindText is free text. + KindText = "text" + // KindList is comma-separated free text that becomes a list. + KindList = "list" + // KindSelect is one value from Options. + KindSelect = "select" + // KindMultiSelect is any number of values from Options. + KindMultiSelect = "multiselect" + // KindBool is a yes/no. + KindBool = "bool" +) + +// An Option is one choice in a select or multi-select question. +type Option struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// A Question is one decision the wizard asks the user to make. +type Question struct { + // ID is the stable identifier answers are keyed by. Both wizards and the + // JSON use it, so it is a compatibility surface: do not rename one. + ID string `json:"id"` + // Key is the dotted config key this answer writes, empty when the question + // only steers the flow (ConfigureVPN, AutoMode) or is folded into another + // key's value (OtherCountries). + Key string `json:"key,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Description string `json:"description"` + // Options is present for select and multiselect. + Options []Option `json:"options,omitempty"` + // Default is the seeded answer for every kind but multiselect, rendered the + // way the config renders it. + Default string `json:"default,omitempty"` + // Selected is the seeded answer for multiselect. + Selected []string `json:"selected,omitempty"` + // Group is the screenful this question belongs to. Questions with the same + // Group are asked together; the numbers are ordering only. + Group int `json:"group"` + // RequiresID and RequiresValue gate this question on an earlier answer — + // "tunnels" is only asked when "autoMode" answered "false". Empty means + // always asked. + RequiresID string `json:"requiresId,omitempty"` + RequiresValue string `json:"requiresValue,omitempty"` +} + +// Gated reports whether this question depends on an earlier answer. +func (q Question) Gated() bool { return q.RequiresID != "" } + +// CommonBlocked are the countries offered as checkboxes. Any other ISO code can +// be typed into the free-text question that follows. +var CommonBlocked = []Option{ + {Label: "Iran (IR)", Value: "IR"}, + {Label: "Russia (RU)", Value: "RU"}, + {Label: "China (CN)", Value: "CN"}, + {Label: "North Korea (KP)", Value: "KP"}, + {Label: "Syria (SY)", Value: "SY"}, + {Label: "Cuba (CU)", Value: "CU"}, + {Label: "Belarus (BY)", Value: "BY"}, +} + +// Options is what the question set is seeded from. +type Options struct { + // Config is what the answers start at, so re-running the wizard edits + // rather than clobbers. Nil means the shipped defaults. + Config *config.Config + // ConfigExisted distinguishes "the user has a config" from "we fell back to + // defaults". Only a brand-new macOS config defaults endpoint discovery on: + // re-running setup must never silently flip an explicit `false` back. + ConfigExisted bool + // GOOS is the platform the config is FOR, not necessarily the one asking — + // live endpoint discovery is macOS-only. + GOOS string + // DetectedTunnels turns the tunnel question from free text into a pick + // list. Supplied by the caller (netdetect in the CLI, `detect-vpn --json` + // in the app) so this package stays free of platform probes. + DetectedTunnels []string +} + +// Questions returns the wizard's questions in the order they are asked, seeded +// from the given config. +func Questions(opts Options) []Question { + cfg := opts.Config + if cfg == nil { + d := config.Default() + cfg = &d + } + macOS := opts.GOOS == "darwin" + + blocked := map[string]bool{} + for _, c := range cfg.BlockedCountries { + blocked[strings.ToUpper(strings.TrimSpace(c))] = true + } + var selected []string + for _, opt := range CommonBlocked { + if blocked[opt.Value] { + selected = append(selected, opt.Value) + delete(blocked, opt.Value) + } + } + // Whatever is configured but not on the common list seeds the free-text + // question, so a code the wizard does not offer survives a re-run. + var extra []string + for code := range blocked { + extra = append(extra, code) + } + // Stable order, so a re-run does not reshuffle a field nobody touched. + sort.Strings(extra) + + // Endpoint discovery is macOS-only and defaults on ONLY for a brand-new + // config there, for the reason in Options.ConfigExisted. + autoDiscover := cfg.VPN.AutoDiscoverEndpoints + if macOS && !opts.ConfigExisted { + autoDiscover = true + } + + endpointDesc := "Server IP(s)/hostname(s), comma-separated. Optional on macOS (auto-discovered); needed elsewhere." + if !macOS { + endpointDesc = "Server IP(s)/hostname(s), comma-separated. Required on this platform (no live discovery)." + } + + qs := []Question{ + { + ID: "pollInterval", Key: "pollInterval", Kind: KindDuration, Group: 1, + Title: "Poll interval", + Description: "How often the exit country is checked, e.g. 30s.", + Default: cfg.PollInterval.String(), + }, + { + ID: "blockedCountries", Key: "blockedCountries", Kind: KindMultiSelect, Group: 1, + Title: "Blocked countries", + Description: "Traffic is cut when the VPN's exit lands in one of these.", + Options: CommonBlocked, + Selected: selected, + }, + { + ID: "otherCountries", Kind: KindList, Group: 1, + Title: "Other country codes", + Description: "Comma-separated ISO codes not listed above (optional).", + Default: strings.Join(extra, ","), + }, + { + ID: "logLevel", Key: "logLevel", Kind: KindSelect, Group: 1, + Title: "Log level", + Options: []Option{{"Debug", "debug"}, {"Info", "info"}, {"Warn", "warn"}, {"Error", "error"}}, + Default: cfg.LogLevel, + }, + { + ID: "providerQuorum", Key: "providerQuorum", Kind: KindBool, Group: 1, + Title: "Require provider quorum?", + Description: "Only act when a majority of the geo providers agree.", + Default: strconv.FormatBool(cfg.ProviderQuorum), + }, + { + ID: "configureVPN", Kind: KindBool, Group: 1, + Title: "Configure your VPN now?", + Description: "dezhban only enforces once it knows your VPN's tunnel and server. " + + "Say no and it starts in standby — fully open, nothing blocked — until you " + + "run 'dezhban setup' again or edit the config.", + Default: "true", + }, + { + ID: "autoMode", Kind: KindBool, Group: 2, + Title: "Use automatic VPN detection? (recommended)", + Description: "dezhban finds your tunnel and, on macOS, learns the server address " + + "itself — works with any VPN and survives redials.", + Default: "true", + RequiresID: "configureVPN", + RequiresValue: "true", + }, + tunnelQuestion(opts.DetectedTunnels, cfg.VPN.TunnelInterfaces), + { + ID: "profileFiles", Kind: KindList, Group: 4, + Title: "Self-hosted VPN config files", + Description: "Comma-separated paths to WireGuard/.conf, OpenVPN/.ovpn, or V2Ray " + + "JSON to import as profiles (optional).", + RequiresID: "configureVPN", RequiresValue: "true", + }, + { + ID: "endpoints", Key: "vpn.endpoints", Kind: KindList, Group: 4, + Title: "VPN endpoint(s)", + Description: endpointDesc, + Default: strings.Join(cfg.VPN.Endpoints, ","), + RequiresID: "configureVPN", RequiresValue: "true", + }, + { + ID: "allowPhysicalDNS", Key: "vpn.allowPhysicalDNS", Kind: KindBool, Group: 4, + Title: "Allow DNS on the physical link while the tunnel is down?", + Description: "Lets a VPN client re-resolve its server hostname to redial. Leaks " + + "only DNS-query metadata; your traffic stays blocked. Recommended if any " + + "endpoint is a hostname.", + Default: strconv.FormatBool(cfg.VPN.AllowPhysicalDNS), + RequiresID: "configureVPN", RequiresValue: "true", + }, + } + + // Live endpoint discovery is macOS-only, so elsewhere the question is not + // asked at all rather than asked and ignored. + if macOS { + qs = append(qs, Question{ + ID: "autoDiscover", Key: "vpn.autoDiscoverEndpoints", Kind: KindBool, Group: 5, + Title: "Auto-discover the VPN server address? (recommended)", + Description: "dezhban watches the live tunnel socket to learn the server IP, so " + + "you don't pin one that changes. macOS only.", + Default: strconv.FormatBool(autoDiscover), + RequiresID: "configureVPN", RequiresValue: "true", + }) + } + return qs +} + +// tunnelQuestion is a pick list when tunnels were detected and free text when +// none were — the same split the CLI's tunnelSelector used to make on its own. +func tunnelQuestion(detected, configured []string) Question { + q := Question{ + ID: "tunnels", Key: "vpn.tunnelInterfaces", Group: 3, + Title: "Tunnel interface(s)", + RequiresID: "autoMode", RequiresValue: "false", + } + if len(detected) == 0 { + q.Kind = KindList + q.Description = "None detected. Enter comma-separated names (e.g. utun4)." + q.Default = strings.Join(configured, ",") + return q + } + cfgSet := map[string]bool{} + for _, t := range configured { + cfgSet[t] = true + } + q.Kind = KindMultiSelect + q.Description = "Detected tunnels — pick the VPN's." + for _, t := range detected { + q.Options = append(q.Options, Option{Label: t, Value: t}) + if cfgSet[t] { + q.Selected = append(q.Selected, t) + } + } + return q +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..6143bbf --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,270 @@ +package setup + +import ( + "reflect" + "strconv" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Apply in auto mode must produce a "connect any VPN" config: no pinned +// interfaces (autodetect implied on Normalize), profiles carried, and +// allowPhysicalDNS honored. +func TestApplyAutoMode(t *testing.T) { + cfg := config.Default() + Apply(&cfg, Input{ + PollInterval: "30s", Hysteresis: "3", LogLevel: "info", + ConfigureVPN: true, AutoMode: true, + Tunnels: []string{"utun9"}, // must be ignored in auto mode + Endpoints: []string{"vpn.example.com"}, + Profiles: []config.Profile{{Name: "home", Endpoints: []string{"203.0.113.7"}}}, + AutoDiscover: true, + AllowPhysicalDNS: true, + }) + if len(cfg.VPN.TunnelInterfaces) != 0 { + t.Errorf("auto mode must not pin interfaces, got %v", cfg.VPN.TunnelInterfaces) + } + if !cfg.VPN.AllowPhysicalDNS { + t.Error("allowPhysicalDNS should be set") + } + if len(cfg.VPN.Profiles) != 1 || cfg.VPN.Profiles[0].Name != "home" { + t.Errorf("profiles not carried: %+v", cfg.VPN.Profiles) + } + // Normalize (run on save) implies autodetect when no interfaces are pinned. + config.Normalize(&cfg) + if !cfg.VPN.AutoDetect { + t.Error("autodetect should be implied for an auto-mode config") + } + if err := cfg.Validate(); err != nil { + t.Errorf("auto-mode config should validate: %v", err) + } +} + +// Advanced mode pins the chosen interfaces. +func TestApplyAdvancedPin(t *testing.T) { + cfg := config.Default() + Apply(&cfg, Input{ + PollInterval: "30s", Hysteresis: "3", LogLevel: "info", + ConfigureVPN: true, AutoMode: false, + Tunnels: []string{"utun4"}, + Endpoints: []string{"203.0.113.7"}, + }) + if len(cfg.VPN.TunnelInterfaces) != 1 || cfg.VPN.TunnelInterfaces[0] != "utun4" { + t.Errorf("advanced mode should pin utun4, got %v", cfg.VPN.TunnelInterfaces) + } +} + +// Answering "no" to "configure your VPN now?" must leave a VPN somebody already +// set up completely alone — the wizard is also how people change their log +// level. +func TestDecliningTheVPNBranchTouchesNoVPNKey(t *testing.T) { + cfg := config.Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.Endpoints = []string{"203.0.113.7"} + cfg.VPN.AllowPhysicalDNS = true + + Apply(&cfg, Input{PollInterval: "45s", LogLevel: "warn", ConfigureVPN: false}) + + if !reflect.DeepEqual(cfg.VPN.TunnelInterfaces, []string{"utun4"}) { + t.Errorf("tunnels changed: %v", cfg.VPN.TunnelInterfaces) + } + if !reflect.DeepEqual(cfg.VPN.Endpoints, []string{"203.0.113.7"}) { + t.Errorf("endpoints changed: %v", cfg.VPN.Endpoints) + } + if !cfg.VPN.AllowPhysicalDNS { + t.Error("allowPhysicalDNS was cleared") + } + if cfg.LogLevel != "warn" { + t.Errorf("the answers that WERE given should still apply, got logLevel %q", cfg.LogLevel) + } +} + +// Profiles are imported from files named in the wizard, and nowhere else. If +// applying them ASSIGNED rather than merged, re-running setup without naming a +// file again would silently delete every saved profile. +func TestImportedProfilesAddToTheSavedOnes(t *testing.T) { + cfg := config.Default() + cfg.VPN.Profiles = []config.Profile{ + {Name: "home", Endpoints: []string{"203.0.113.7"}}, + {Name: "work", Endpoints: []string{"198.51.100.9"}}, + } + + // A run that imported nothing keeps both. + Apply(&cfg, Input{ConfigureVPN: true, AutoMode: true}) + if len(cfg.VPN.Profiles) != 2 { + t.Fatalf("a run importing nothing must keep saved profiles, got %+v", cfg.VPN.Profiles) + } + + // A run that re-imports one replaces that one and keeps the other. + Apply(&cfg, Input{ConfigureVPN: true, AutoMode: true, + Profiles: []config.Profile{{Name: "work", Endpoints: []string{"192.0.2.5"}}}}) + if len(cfg.VPN.Profiles) != 2 { + t.Fatalf("re-importing a profile must not drop the others, got %+v", cfg.VPN.Profiles) + } + byName := map[string][]string{} + for _, p := range cfg.VPN.Profiles { + byName[p.Name] = p.Endpoints + } + if !reflect.DeepEqual(byName["work"], []string{"192.0.2.5"}) { + t.Errorf("re-imported profile should win, got %v", byName["work"]) + } + if !reflect.DeepEqual(byName["home"], []string{"203.0.113.7"}) { + t.Errorf("untouched profile should survive, got %v", byName["home"]) + } +} + +// --- questions --- + +// The wizard edits rather than clobbers, so every question must start at what +// the config already says. +func TestQuestionsSeedFromTheConfig(t *testing.T) { + cfg := config.Default() + cfg.BlockedCountries = []string{"IR", "AQ", "CN"} + cfg.VPN.Endpoints = []string{"203.0.113.7", "vpn.example.com"} + cfg.LogLevel = "debug" + + qs := byID(Questions(Options{Config: &cfg, ConfigExisted: true, GOOS: "darwin"})) + + if got := qs["blockedCountries"].Selected; !reflect.DeepEqual(got, []string{"IR", "CN"}) { + t.Errorf("checkbox seed = %v, want the codes that ARE on the common list", got) + } + // A configured code the wizard does not offer must survive a re-run, which + // means it has to land in the free-text question. + if got := qs["otherCountries"].Default; got != "AQ" { + t.Errorf("other-countries seed = %q, want the codes not on the list", got) + } + if got := qs["endpoints"].Default; got != "203.0.113.7,vpn.example.com" { + t.Errorf("endpoints seed = %q", got) + } + if got := qs["logLevel"].Default; got != "debug" { + t.Errorf("logLevel seed = %q", got) + } +} + +// Endpoint discovery is macOS-only, and defaults on only for a brand-new config +// there: re-running setup must never silently flip an explicit false back on. +func TestAutoDiscoverDefaultsOnlyForANewMacConfig(t *testing.T) { + off := config.Default() + off.VPN.AutoDiscoverEndpoints = false + + fresh := byID(Questions(Options{Config: &off, ConfigExisted: false, GOOS: "darwin"})) + if fresh["autoDiscover"].Default != "true" { + t.Error("a brand-new macOS config should be offered discovery on") + } + + existing := byID(Questions(Options{Config: &off, ConfigExisted: true, GOOS: "darwin"})) + if existing["autoDiscover"].Default != "false" { + t.Error("an existing config's explicit false must be preserved") + } + + linux := byID(Questions(Options{Config: &off, ConfigExisted: false, GOOS: "linux"})) + if _, asked := linux["autoDiscover"]; asked { + t.Error("discovery is macOS-only, so elsewhere it must not be asked at all") + } +} + +// The tunnel question is a pick list when tunnels were detected and free text +// when none were — the same split the CLI used to make on its own. +func TestTunnelQuestionFollowsDetection(t *testing.T) { + cfg := config.Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + + detected := byID(Questions(Options{Config: &cfg, GOOS: "darwin", DetectedTunnels: []string{"utun4", "utun7"}}))["tunnels"] + if detected.Kind != KindMultiSelect { + t.Errorf("kind = %q, want a pick list when tunnels were detected", detected.Kind) + } + if !reflect.DeepEqual(detected.Selected, []string{"utun4"}) { + t.Errorf("configured tunnel should be preselected, got %v", detected.Selected) + } + + none := byID(Questions(Options{Config: &cfg, GOOS: "darwin"}))["tunnels"] + if none.Kind != KindList { + t.Errorf("kind = %q, want free text when nothing was detected", none.Kind) + } + if none.Default != "utun4" { + t.Errorf("free-text seed = %q", none.Default) + } +} + +// --- gating --- + +func TestGatingHidesTheWholeVPNBranch(t *testing.T) { + qs := Questions(Options{GOOS: "darwin"}) + a := NewAnswers(qs) + a.Set("configureVPN", "false") + + for _, q := range qs { + if q.RequiresID == "configureVPN" && a.ShouldAsk(q) { + t.Errorf("%s should not be asked when the VPN branch was declined", q.ID) + } + } + + a.Set("configureVPN", "true") + a.Set("autoMode", "true") + for _, q := range qs { + if q.ID == "tunnels" && a.ShouldAsk(q) { + t.Error("automatic detection must not ask which interface to pin") + } + } + a.Set("autoMode", "false") + for _, q := range qs { + if q.ID == "tunnels" && !a.ShouldAsk(q) { + t.Error("declining automatic detection must ask which interface to pin") + } + } +} + +// Walking the wizard and pressing Enter on every question must land on the +// config you started with. Anything else means a default is stated in one place +// and applied differently in another — the drift Phase M exists to prevent, +// arriving through the wizard instead. +func TestAnsweringNothingChangesNothing(t *testing.T) { + cfg := config.Default() + cfg.BlockedCountries = []string{"IR", "AQ"} + cfg.VPN.Endpoints = []string{"203.0.113.7"} + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.AllowPhysicalDNS = true + config.Normalize(&cfg) + before := config.KeyValues(&cfg) + + qs := Questions(Options{Config: &cfg, ConfigExisted: true, GOOS: "darwin", DetectedTunnels: []string{"utun4"}}) + a := NewAnswers(qs) + // The one answer with no config to seed it: the VPN branch is offered, and + // its own sub-answers are seeded, so accepting them must be a no-op too. + a.Set("configureVPN", "true") + a.Set("autoMode", "false") + + after := cfg + // Hysteresis has no question; the wizard carries the current value through, + // which is exactly what the caller passes here. + Apply(&after, a.Input(strconv.Itoa(cfg.Hysteresis), nil)) + config.Normalize(&after) + + for key, want := range before { + if got := config.KeyValues(&after)[key]; got != want { + t.Errorf("%s changed by answering nothing: %q → %q", key, want, got) + } + } +} + +func TestValidDuration(t *testing.T) { + for _, ok := range []string{"30s", "5m", " 1h "} { + if err := ValidDuration(ok); err != nil { + t.Errorf("ValidDuration(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "soon", "0s", "-5m"} { + if err := ValidDuration(bad); err == nil { + t.Errorf("ValidDuration(%q) accepted", bad) + } + } +} + +func byID(qs []Question) map[string]Question { + out := make(map[string]Question, len(qs)) + for _, q := range qs { + out[q.ID] = q + } + return out +} From b84f0255a02294719fbe47817c2bd4e7ce63a2b5 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 00:09:42 +0330 Subject: [PATCH 17/27] feat(gui): set dezhban up without a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS app's first launch on an unconfigured host now walks the setup questions natively. It reads them from `setup --questions --json` rather than holding its own copy, so the two wizards ask the same things in the same order with the same gating, and it saves through the same batched, validated `config set` as every other pane — no second write path, no second validator. Offered only when dezhban does not know a VPN yet: someone who set it up from the CLI has already answered these, and asking again would look like the app had forgotten. "Run Setup Again…" in Settings reopens it seeded with current values, which is the guided path for changing VPN. Two rules that are not derivable from a question's key, and that mirror Go's setup.Apply exactly, are pinned by tests: the free-text country codes fold into blockedCountries rather than writing a key of their own, and choosing automatic detection CLEARS pinned interfaces instead of skipping the key — a leftover pin is what stops autodetect from happening. Named VPN config files are imported after the config is saved, since a profile is not a config key. Cancelling that prompt loses the import, not the setup. --- CHANGELOG.md | 12 + docs/contribute/testing.md | 19 ++ docs/usage/getting-started.md | 5 + .../Sources/DezhbanCore/SetupQuestions.swift | 192 ++++++++++++ .../Sources/DezhbanMenu/AppDelegate.swift | 1 + gui/macos/Sources/DezhbanMenu/AppState.swift | 27 ++ .../Sources/DezhbanMenu/DezhbanCLI.swift | 14 + .../Sources/DezhbanMenu/FirstRunView.swift | 284 ++++++++++++++++++ gui/macos/Sources/DezhbanMenu/MainView.swift | 12 + .../Sources/DezhbanMenu/SettingsView.swift | 5 + .../SetupQuestionsTests.swift | 146 +++++++++ 11 files changed, 717 insertions(+) create mode 100644 gui/macos/Sources/DezhbanCore/SetupQuestions.swift create mode 100644 gui/macos/Sources/DezhbanMenu/FirstRunView.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index acdbfd1..907ae01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,18 @@ current as you land changes. ask different questions or apply the same answer differently — the same reason the settings schema lives in one place. +- **The macOS app has a setup wizard.** Launch it with nothing configured and it + walks the same questions `dezhban setup` asks — which countries to refuse, how + to find your VPN, whether to pin an interface — seeded from whatever config + you already have, with no terminal involved. It reads the question set from + the daemon rather than keeping its own, and saves through the same batched, + validated `config set` every other pane uses. + + It is offered only when dezhban does not know a VPN yet: if you set it up from + the CLI, the app does not ask you to do it again. Settings → **Run Setup + Again…** reopens it whenever you want, which is the guided way through those + decisions when you change VPN. + ### Changed - **A pause longer than `vpn.pauseMax` is now refused and explained, instead of diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 04efbe3..cdf8ccc 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -461,6 +461,25 @@ macOS only, privileged (`dezhban upgrade download`/`apply`). See - [ ] `dezhban setup --questions --json` runs with no TTY, no root, and no config file present, and lists the same questions the wizard asks. +### First-run wizard (macOS app) + +- [ ] With no VPN configured and `defaults delete com.dezhban.menu + dezhban.firstRunCompleted`, launching the app opens the window **and** the + wizard. With a VPN already configured from the CLI, it does not — the + questions were already answered. +- [ ] The questions, their order, and the gating match `dezhban setup` run in a + terminal on the same host. Declining "Configure your VPN now?" skips the + whole VPN branch in both. +- [ ] Saving writes through one `config set` (one password prompt, or none with + a token enrolled) and the values land in `dezhban config show`. Choosing + automatic detection leaves `vpn.tunnelInterfaces` **empty**. +- [ ] Cancelling with "Not now" writes nothing and offers the wizard again next + launch. +- [ ] Naming VPN config files imports them as profiles (`dezhban vpn list`); + cancelling that second prompt leaves the config saved and only the import + undone. +- [ ] Settings → "Run Setup Again…" reopens it seeded with current values. + ## macOS app Build and launch: diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index 67c3df2..c38cc41 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -55,6 +55,11 @@ a VPN config file, import it instead of typing endpoints: dezhban vpn import ~/wg0.conf # WireGuard .conf, OpenVPN .ovpn, or V2Ray JSON ``` +On macOS you can answer the same questions in the app instead: launch Dezhban +with nothing configured and the setup wizard opens by itself, seeded from +whatever config you already have. It is also in Settings → **Run Setup Again…**, +for when you change VPN. + Prefer to write the file yourself? Start from `configs/dezhban.example.json` and see the [config reference](config.md). diff --git a/gui/macos/Sources/DezhbanCore/SetupQuestions.swift b/gui/macos/Sources/DezhbanCore/SetupQuestions.swift new file mode 100644 index 0000000..e183bd4 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/SetupQuestions.swift @@ -0,0 +1,192 @@ +import Foundation + +/// Mirrors Go's `setup.Option`. +public struct SetupOption: Codable, Identifiable, Hashable { + public var id: String { value } + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } +} + +/// Mirrors Go's `setup.Question` (`dezhban setup --questions --json`). +/// +/// The app asks what the CLI wizard asks, in the same order, gated the same +/// way — because it asks the daemon rather than keeping its own list. A +/// question added in Go appears here without a Swift change. +public struct SetupQuestion: Codable, Identifiable, Hashable { + public var id: String { questionID } + /// `id` in the JSON; renamed because `id` is taken by Identifiable. + public let questionID: String + /// The dotted config key this answer writes, empty when the question only + /// steers the flow or is folded into another key's value. + public let key: String + public let kind: String + public let title: String + public let description: String + public let options: [SetupOption] + /// Seeded answer for every kind but multiselect. + public let defaultValue: String + /// Seeded answer for multiselect. + public let selected: [String] + public let group: Int + /// Gate: this question is asked only when `requiresID`'s answer equals + /// `requiresValue`. + public let requiresID: String + public let requiresValue: String + + private enum CodingKeys: String, CodingKey { + case questionID = "id" + case key, kind, title, description, options + case defaultValue = "default" + case selected, group + case requiresID = "requiresId" + case requiresValue + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + questionID = try c.decode(String.self, forKey: .questionID) + kind = try c.decode(String.self, forKey: .kind) + title = try c.decode(String.self, forKey: .title) + // Every remaining field is `omitempty` on the Go side. + key = try c.decodeIfPresent(String.self, forKey: .key) ?? "" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + options = try c.decodeIfPresent([SetupOption].self, forKey: .options) ?? [] + defaultValue = try c.decodeIfPresent(String.self, forKey: .defaultValue) ?? "" + selected = try c.decodeIfPresent([String].self, forKey: .selected) ?? [] + group = try c.decodeIfPresent(Int.self, forKey: .group) ?? 0 + requiresID = try c.decodeIfPresent(String.self, forKey: .requiresID) ?? "" + requiresValue = try c.decodeIfPresent(String.self, forKey: .requiresValue) ?? "" + } + + public init(questionID: String, key: String = "", kind: String, title: String, + description: String = "", options: [SetupOption] = [], + defaultValue: String = "", selected: [String] = [], group: Int = 1, + requiresID: String = "", requiresValue: String = "") { + self.questionID = questionID + self.key = key + self.kind = kind + self.title = title + self.description = description + self.options = options + self.defaultValue = defaultValue + self.selected = selected + self.group = group + self.requiresID = requiresID + self.requiresValue = requiresValue + } + + public var isGated: Bool { !requiresID.isEmpty } + + public static func decodeList(_ data: Data) -> [SetupQuestion]? { + try? JSONDecoder().decode([SetupQuestion].self, from: data) + } +} + +/// Question kinds, mirroring Go's `setup.Kind*` constants. +public enum SetupKind { + public static let duration = "duration" + public static let text = "text" + public static let list = "list" + public static let select = "select" + public static let multiSelect = "multiselect" + public static let bool = "bool" +} + +/// The answers collected so far, keyed by question id, plus the rules for +/// turning them into `config set` pairs. +/// +/// Values are strings for every kind — "true"/"false" for a bool, +/// comma-separated for a list — which is what `config set` takes and what Go's +/// `Answers.Set` accepts, so the two wizards agree on the wire as well as on +/// the questions. +public struct SetupAnswers { + public private(set) var values: [String: String] + + /// Seeds every question with its default, so a wizard the user clicks + /// straight through writes back what the config already says. + public init(questions: [SetupQuestion]) { + var seeded: [String: String] = [:] + for q in questions { + seeded[q.id] = q.kind == SetupKind.multiSelect + ? q.selected.joined(separator: ",") + : q.defaultValue + } + values = seeded + } + + public subscript(id: String) -> String { + get { values[id] ?? "" } + set { values[id] = newValue } + } + + public func bool(_ id: String) -> Bool { values[id] == "true" } + + public func list(_ id: String) -> [String] { + (values[id] ?? "").split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// Whether a question's gate is satisfied by the answers so far. Mirrors + /// Go's `Answers.ShouldAsk`. + public func shouldAsk(_ q: SetupQuestion) -> Bool { + guard q.isGated else { return true } + return self[q.requiresID] == q.requiresValue + } + + /// The `key=value` pairs one batched `config set` should write. + /// + /// Three rules are not derivable from a question's `key` alone, and they + /// mirror Go's `setup.Apply` exactly: + /// - the free-text country codes fold into `blockedCountries`; + /// - answering "no" to the VPN branch writes none of its keys, so a VPN + /// somebody already configured is left alone; + /// - choosing automatic detection CLEARS pinned interfaces rather than + /// skipping the key, because a leftover pin is what makes autodetect not + /// happen. + public func configPairs(for questions: [SetupQuestion]) -> [String] { + var pairs: [String] = [] + for q in questions where shouldAsk(q) && !q.key.isEmpty { + switch q.id { + case "blockedCountries": + let all = list("blockedCountries") + list("otherCountries") + pairs.append("blockedCountries=\(all.joined(separator: ","))") + case "tunnels": + pairs.append("vpn.tunnelInterfaces=\(list("tunnels").joined(separator: ","))") + default: + pairs.append("\(q.key)=\(self[q.id])") + } + } + if bool("configureVPN") && bool("autoMode") { + pairs.append("vpn.tunnelInterfaces=") + } + return pairs + } + + /// The VPN config files to import, which are not a config key at all — they + /// become profiles through `dezhban vpn import`. + public var profileFiles: [String] { list("profileFiles") } +} + +/// When the first-run wizard should be offered. +/// +/// Split from the UserDefaults flag that feeds it so the rule itself is +/// testable: whether the flag is set is bookkeeping, but whether an unasked +/// user should be asked is a decision — and getting it wrong means an app that +/// looks like it forgot the setup someone already did. +public enum FirstRunDecision { + /// - Parameters: + /// - isComplete: whether this account has completed the wizard before. + /// - vpnKnown: whether dezhban already knows a VPN server, from an + /// endpoint or a profile. Not "are tunnel interfaces set": those are + /// legitimately empty on an autodetect config, which is the recommended + /// one. + public static func offer(isComplete: Bool, vpnKnown: Bool) -> Bool { + !isComplete && !vpnKnown + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 0756e06..9745ec5 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -60,6 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } AppState.shared.refreshServiceState() AppState.shared.checkForUpdates() + AppState.shared.offerFirstRunIfNeeded() timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.refresh() } diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index f851174..4425217 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -135,6 +135,10 @@ final class AppState: ObservableObject { /// pane consumes it and sets it back to nil, so selecting Help again later /// does not jump back to a link the reader has moved on from. @Published var helpTarget: HelpTarget? + /// Whether the first-run wizard is on screen. Presented as a sheet over the + /// main window, so there is one window and the wizard cannot be lost behind + /// it. + @Published var showFirstRun = false /// Last update check result (nil: none run yet, or the last one found /// nothing worth reporting — see UpdateChecker.check's doc comment on why /// a failure never surfaces as an error here). @@ -144,6 +148,29 @@ final class AppState: ObservableObject { var isLive: Bool { PostureUI.isLive(snapshot) } + /// Offers the first-run wizard when this account has never completed it AND + /// nothing is configured yet. + /// + /// The check is "does dezhban know a VPN server yet", not "is there a config + /// file": someone who set dezhban up from the CLI has already answered these + /// questions, and asking again the first time they open the app would look + /// like it had forgotten. An endpoint or a profile is the signal, because + /// tunnel interfaces are legitimately empty on an autodetect config — the + /// recommended one. Opens the window too: a fresh install with nothing + /// configured is the one launch where a window is the point. + func offerFirstRunIfNeeded() { + guard !FirstRun.isComplete, cliFound else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let info = DezhbanCLI.readProfiles() + let configured = !(info?.defaultEndpoints.isEmpty ?? true) || !(info?.profiles.isEmpty ?? true) + DispatchQueue.main.async { + guard FirstRun.shouldOffer(vpnKnown: configured) else { return } + self?.showFirstRun = true + MainWindow.shared.open() + } + } + } + /// Opens the Help pane at a specific place in the documentation, named the /// way a `Tunable`'s docAnchor writes it ("usage/config.md#fields"). /// A docAnchor that names nothing bundled still opens the pane — better a diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index 2724b0b..37e8593 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -301,6 +301,20 @@ enum DezhbanCLI { return ConfigSchema.decode(data) } + /// Reads the setup wizard's questions — what it asks, in what order, gated + /// how, seeded from THIS host's config — via `setup --questions --json`. + /// Read-only, no root, and it asks nothing. + /// + /// Passes `--config` deliberately, unlike `readSchema`: the schema describes + /// keys in the abstract, but a wizard's job is to start from what is already + /// configured, so it must be seeded from the real file. + static func readSetupQuestions() -> [SetupQuestion]? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["setup", "--questions", "--json", "--config", resolvedConfigPath()]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return SetupQuestion.decodeList(data) + } + /// Reads the offered pause lengths and which of them this host's /// vpn.pauseMax allows, via `pause --list --json`. Read-only, no root. static func readPauseOptions() -> [PauseOption]? { diff --git a/gui/macos/Sources/DezhbanMenu/FirstRunView.swift b/gui/macos/Sources/DezhbanMenu/FirstRunView.swift new file mode 100644 index 0000000..29d790f --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/FirstRunView.swift @@ -0,0 +1,284 @@ +import AppKit +import SwiftUI +import DezhbanCore + +/// The first-run wizard: the same questions `dezhban setup` asks, rendered +/// natively and answered without a terminal. +/// +/// It asks the daemon what to ask (`setup --questions --json`) rather than +/// keeping its own list, so the two wizards cannot drift; it writes through the +/// same batched, validated `config set` every other pane uses, so there is one +/// write path and one validation authority; and it never shells out to the huh +/// wizard, which needs a TTY the app does not have. +struct FirstRunView: View { + @EnvironmentObject var state: AppState + /// Called when the sheet should close, with whether anything was written. + let done: (Bool) -> Void + + @State private var questions: [SetupQuestion] = [] + @State private var answers = SetupAnswers(questions: []) + @State private var groupIndex = 0 + @State private var busy = false + @State private var error: String? + @State private var loaded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 560, height: 520) + .onAppear(perform: load) + } + + // MARK: - Chrome + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Set up dezhban").font(.title2.weight(.semibold)) + Text("dezhban blocks everything that isn’t your VPN. These are the same questions `dezhban setup` asks — you can change any of them later in Settings.") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + .padding(16) + } + + @ViewBuilder + private var content: some View { + if let error { + guided(symbol: "exclamationmark.triangle", title: "Couldn’t start setup", message: error) + } else if !loaded { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if questions.isEmpty { + guided(symbol: "questionmark.circle", title: "Nothing to ask", + message: "This copy of the dezhban CLI is too old to describe its setup questions. Run `sudo dezhban setup` in a terminal instead.") + } else { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + ForEach(currentQuestions) { q in + question(q) + } + } + .padding(16) + } + } + } + + private var footer: some View { + HStack { + if busy { ProgressView().controlSize(.small) } + Text(stepLabel).font(.callout).foregroundStyle(.secondary) + Spacer() + Button("Not now") { done(false) } + .disabled(busy) + Button(isLastStep ? "Save and finish" : "Continue") { advance() } + .keyboardShortcut(.defaultAction) + .disabled(busy || questions.isEmpty || error != nil || !stepIsValid) + } + .padding(16) + } + + private var stepLabel: String { + guard !visibleGroups.isEmpty else { return "" } + return "Step \(min(groupIndex + 1, visibleGroups.count)) of \(visibleGroups.count)" + } + + private func guided(symbol: String, title: String, message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: symbol).font(.system(size: 36)).foregroundStyle(.secondary) + Text(title).font(.title3.weight(.semibold)) + Text(message).multilineTextAlignment(.center).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + // MARK: - Questions + + /// The groups that still have something to ask, recomputed as answers + /// change — a gate closing removes a whole step rather than showing an + /// empty one. + private var visibleGroups: [Int] { + var seen = Set() + var out: [Int] = [] + for q in questions where answers.shouldAsk(q) { + if seen.insert(q.group).inserted { out.append(q.group) } + } + return out.sorted() + } + + private var currentQuestions: [SetupQuestion] { + guard groupIndex < visibleGroups.count else { return [] } + let group = visibleGroups[groupIndex] + return questions.filter { $0.group == group && answers.shouldAsk($0) } + } + + private var isLastStep: Bool { groupIndex >= visibleGroups.count - 1 } + + @ViewBuilder + private func question(_ q: SetupQuestion) -> some View { + VStack(alignment: .leading, spacing: 6) { + switch q.kind { + case SetupKind.bool: + Toggle(q.title, isOn: boolBinding(q.id)) + case SetupKind.select: + Picker(q.title, selection: stringBinding(q.id)) { + ForEach(q.options) { Text($0.label).tag($0.value) } + } + case SetupKind.multiSelect: + Text(q.title).font(.body.weight(.medium)) + ForEach(q.options) { opt in + Toggle(opt.label, isOn: memberBinding(q.id, opt.value)) + .toggleStyle(.checkbox) + } + default: + Text(q.title).font(.body.weight(.medium)) + TextField(q.id == "profileFiles" ? "Optional" : "", text: stringBinding(q.id)) + if q.id == "profileFiles" { + Button("Choose files…") { chooseProfileFiles() } + .controlSize(.small) + } + } + if !q.description.isEmpty { + Text(q.description).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if q.kind == SetupKind.duration, !DurationText.looksLikeGoDuration(answers[q.id]) { + Text("Enter a duration like 30s or 5m.").font(.callout).foregroundStyle(.red) + } + } + } + + private func stringBinding(_ id: String) -> Binding { + Binding(get: { answers[id] }, set: { answers[id] = $0 }) + } + + private func boolBinding(_ id: String) -> Binding { + Binding(get: { answers.bool(id) }, set: { answers[id] = $0 ? "true" : "false" }) + } + + /// A checkbox over one member of a comma-separated list answer. + private func memberBinding(_ id: String, _ value: String) -> Binding { + Binding( + get: { answers.list(id).contains(value) }, + set: { on in + var list = answers.list(id) + if on { + if !list.contains(value) { list.append(value) } + } else { + list.removeAll { $0 == value } + } + answers[id] = list.joined(separator: ",") + }) + } + + private func chooseProfileFiles() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.message = "Choose WireGuard (.conf), OpenVPN (.ovpn), or V2Ray (.json) files to import as profiles." + guard panel.runModal() == .OK else { return } + let paths = panel.urls.map(\.path) + answers["profileFiles"] = paths.joined(separator: ",") + } + + // MARK: - Flow + + private func load() { + guard !loaded else { return } + DispatchQueue.global(qos: .userInitiated).async { + let qs = DezhbanCLI.readSetupQuestions() + DispatchQueue.main.async { + loaded = true + guard let qs else { + error = DezhbanCLI.binaryPath() == nil + ? "The dezhban command-line tool isn’t installed, so there is nothing to configure yet." + : "`dezhban setup --questions` failed. Run `sudo dezhban setup` in a terminal instead." + return + } + questions = qs + answers = SetupAnswers(questions: qs) + } + } + } + + /// True when every duration on this step parses. A bad value blocks the + /// step it was typed on, while the field saying why is still on screen — + /// rather than surfacing as a modal after the last question. + private var stepIsValid: Bool { + !currentQuestions.contains { + $0.kind == SetupKind.duration && !DurationText.looksLikeGoDuration(answers[$0.id]) + } + } + + private func advance() { + guard stepIsValid else { return } + guard isLastStep else { + groupIndex += 1 + return + } + save() + } + + /// Writes every answer through ONE batched `config set` — the same + /// validated, token-gated path the Settings pane uses, so the wizard has no + /// write logic of its own and cannot bypass `config.Validate`. + /// + /// Named VPN config files are imported afterwards, because a profile is not + /// a config key: `vpn import` parses the file. That step is privileged and + /// asks separately; the config is already saved by then, so cancelling the + /// prompt loses the import, not the setup. + private func save() { + let pairs = answers.configPairs(for: questions) + guard !pairs.isEmpty else { + done(false) + return + } + busy = true + ConfigApply.apply(pairs: pairs, awaitPosture: false, title: "Setup") { outcome in + busy = false + guard outcome.ok else { + error = outcome.status + if let transcript = outcome.transcript, let title = outcome.transcriptTitle { + state.showInLogs(title: title, text: transcript) + } + return + } + let files = answers.profileFiles + FirstRun.markComplete() + guard !files.isEmpty else { + done(true) + return + } + AppActions.capturedSequence(files.map { ["vpn", "import", $0] }) { result in + if !result.ok { + state.showInLogs(title: "Setup — profile import failed", text: result.output) + } + done(true) + } + } + } +} + +/// Whether the first-run wizard has been offered on this Mac. +/// +/// A flag in UserDefaults, not in the config: the config belongs to the daemon +/// and is shared by every surface, while "has this user seen the wizard" is a +/// fact about this app on this account. Marked complete only after a successful +/// write, so a cancelled or failed setup is offered again. +enum FirstRun { + private static let key = "dezhban.firstRunCompleted" + + static var isComplete: Bool { UserDefaults.standard.bool(forKey: key) } + + static func markComplete() { UserDefaults.standard.set(true, forKey: key) } + + /// Offer the wizard when it has never been completed AND dezhban does not + /// know a VPN server yet. The rule lives in DezhbanCore so it is testable; + /// this only supplies the flag. + static func shouldOffer(vpnKnown: Bool) -> Bool { + FirstRunDecision.offer(isComplete: isComplete, vpnKnown: vpnKnown) + } +} diff --git a/gui/macos/Sources/DezhbanMenu/MainView.swift b/gui/macos/Sources/DezhbanMenu/MainView.swift index 056d886..ef433e9 100644 --- a/gui/macos/Sources/DezhbanMenu/MainView.swift +++ b/gui/macos/Sources/DezhbanMenu/MainView.swift @@ -25,5 +25,17 @@ struct MainView: View { case .about: AboutView() } } + // A sheet rather than a second window: the wizard is a step you are in, + // not a place you can leave open behind the thing it configures. + .sheet(isPresented: $state.showFirstRun) { + FirstRunView { saved in + state.showFirstRun = false + if saved { + state.refreshServiceState() + state.selectedSection = .overview + } + } + .environmentObject(state) + } } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 6ed4939..a176d00 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -197,6 +197,11 @@ struct SettingsView: View { Button("Open Config File…") { NSWorkspace.shared.open(URL(fileURLWithPath: configPath)) } + // The wizard is not first-run-only: it is the guided way + // back through the same decisions, for someone changing VPN + // rather than someone starting out. + Button("Run Setup Again…") { state.showFirstRun = true } + .help("Walk through the setup questions again, seeded with your current settings.") } footer: { Text("Some advanced options (control socket, geo providers, allowlist) live only in the config file.") .foregroundStyle(.secondary) diff --git a/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift b/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift new file mode 100644 index 0000000..e01e561 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// The producer side — what the questions ARE, how they are gated, and what +/// they apply to — is pinned by Go's internal/setup tests. This is the consumer +/// side: that the app decodes that JSON, gates the same way, and turns answers +/// into the same config writes. A difference here is two wizards asking the +/// same question and doing different things with the answer. +struct SetupQuestionsTests { + /// Exactly what `dezhban setup --questions --json` emits, `omitempty` and + /// all: `key`, `options`, `selected`, and the gate fields are absent on the + /// questions that do not have them. + static let json = """ + [ + {"id":"pollInterval","key":"pollInterval","kind":"duration","title":"Poll interval", + "description":"How often the exit country is checked, e.g. 30s.","default":"15s","group":1}, + {"id":"blockedCountries","key":"blockedCountries","kind":"multiselect","title":"Blocked countries", + "options":[{"label":"Iran (IR)","value":"IR"},{"label":"Russia (RU)","value":"RU"}], + "selected":["IR"],"group":1}, + {"id":"otherCountries","kind":"list","title":"Other country codes","default":"AQ","group":1}, + {"id":"configureVPN","kind":"bool","title":"Configure your VPN now?","default":"true","group":1}, + {"id":"autoMode","kind":"bool","title":"Use automatic VPN detection? (recommended)", + "default":"true","group":2,"requiresId":"configureVPN","requiresValue":"true"}, + {"id":"tunnels","key":"vpn.tunnelInterfaces","kind":"multiselect","title":"Tunnel interface(s)", + "options":[{"label":"utun4","value":"utun4"},{"label":"utun7","value":"utun7"}], + "selected":["utun4"],"group":3,"requiresId":"autoMode","requiresValue":"false"}, + {"id":"profileFiles","kind":"list","title":"Self-hosted VPN config files","group":4, + "requiresId":"configureVPN","requiresValue":"true"}, + {"id":"endpoints","key":"vpn.endpoints","kind":"list","title":"VPN endpoint(s)", + "default":"203.0.113.7","group":4,"requiresId":"configureVPN","requiresValue":"true"} + ] + """ + + static func questions() throws -> [SetupQuestion] { + try #require(SetupQuestion.decodeList(Data(json.utf8))) + } + + @Test func decodesTheDaemonsQuestions() throws { + let qs = try Self.questions() + #expect(qs.count == 8) + let countries = try #require(qs.first { $0.id == "blockedCountries" }) + #expect(countries.selected == ["IR"]) + #expect(countries.options.map(\.value) == ["IR", "RU"]) + // Absent `key` decodes as "no config key", not as a decode failure. + #expect(try #require(qs.first { $0.id == "otherCountries" }).key.isEmpty) + #expect(try #require(qs.first { $0.id == "autoMode" }).isGated) + #expect(!(try #require(qs.first { $0.id == "pollInterval" }).isGated)) + } + + @Test func answersSeedFromTheQuestions() throws { + let a = SetupAnswers(questions: try Self.questions()) + #expect(a["pollInterval"] == "15s") + #expect(a.list("blockedCountries") == ["IR"]) + #expect(a.bool("configureVPN")) + } + + @Test func gatingMatchesTheCLI() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + + a["configureVPN"] = "false" + for q in qs where q.requiresID == "configureVPN" { + #expect(!a.shouldAsk(q), "\(q.id) should be hidden when the VPN branch is declined") + } + + a["configureVPN"] = "true" + a["autoMode"] = "true" + #expect(!a.shouldAsk(try #require(qs.first { $0.id == "tunnels" }))) + a["autoMode"] = "false" + #expect(a.shouldAsk(try #require(qs.first { $0.id == "tunnels" }))) + } + + /// The free-text codes fold into the same key as the checkboxes — they are + /// one setting asked as two questions, and writing them separately would + /// mean the second overwrote the first. + @Test func otherCountriesFoldIntoBlockedCountries() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["blockedCountries"] = "IR,RU" + a["otherCountries"] = "AQ, KP" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("blockedCountries=IR,RU,AQ,KP")) + // And never as a key of its own. + #expect(!pairs.contains { $0.hasPrefix("otherCountries=") }) + } + + /// Choosing automatic detection must CLEAR pinned interfaces, not skip the + /// key: a leftover pin is exactly what stops autodetect from happening. + @Test func automaticDetectionClearsPinnedInterfaces() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "true" + a["autoMode"] = "true" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("vpn.tunnelInterfaces=")) + } + + @Test func pinningWritesTheChosenInterfaces() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "true" + a["autoMode"] = "false" + a["tunnels"] = "utun4,utun7" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("vpn.tunnelInterfaces=utun4,utun7")) + #expect(pairs.filter { $0.hasPrefix("vpn.tunnelInterfaces=") }.count == 1, + "a pinned config must not also be cleared") + } + + /// Declining the VPN branch writes none of its keys, so a VPN somebody + /// already configured is left alone — the same rule as Go's setup.Apply. + @Test func decliningTheVPNBranchWritesNoVPNKey() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "false" + + let pairs = a.configPairs(for: qs) + #expect(!pairs.contains { $0.hasPrefix("vpn.") }) + // The answers that were given still apply. + #expect(pairs.contains { $0.hasPrefix("pollInterval=") }) + } + + /// Profile files are not a config key: they become profiles through + /// `vpn import`, so they must never reach `config set`. + @Test func profileFilesAreNotAConfigKey() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["profileFiles"] = "/tmp/home.conf, /tmp/work.ovpn" + + #expect(a.profileFiles == ["/tmp/home.conf", "/tmp/work.ovpn"]) + #expect(!a.configPairs(for: qs).contains { $0.contains("profileFiles") }) + } + + @Test func firstRunIsOfferedOnlyWhenNothingIsKnownYet() { + // Whether the flag is set is UserDefaults' business; whether the wizard + // should be offered given that flag is a rule, and lives in the core. + #expect(FirstRunDecision.offer(isComplete: false, vpnKnown: false)) + #expect(!FirstRunDecision.offer(isComplete: false, vpnKnown: true), + "a VPN configured from the CLI means these questions were already answered") + #expect(!FirstRunDecision.offer(isComplete: true, vpnKnown: false)) + } +} From 5a96381de033ce35c00d9e2a464062aa8a4982cc Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 00:14:59 +0330 Subject: [PATCH 18/27] feat(gui): the menubar becomes a glance, not a control panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown is what you open to check whether you are exposed and how long is left. It now carries that and the actions that are urgent at that moment: the posture line, the switch or pause countdown, hold the line, and Open Dezhban. Block now and Unblock move to the window's Overview, where they already are. Somebody who wants to cut their own internet can turn off Wi-Fi; blocking by hand is a power-user and debugging affordance, not part of the routine flow. Panic moves behind ⌥ as the alternate to "Open Dezhban…" — the item swaps when Option is held, and ⌘⌥O fires it. It stays in the menubar because the moment it is needed is the moment the main window may not open, but it should not sit one slip away in a menu people open to read a countdown. --- CHANGELOG.md | 13 +++++ docs/contribute/testing.md | 11 ++-- .../Sources/DezhbanMenu/AppDelegate.swift | 50 +++++++++++-------- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 907ae01..e58c37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,19 @@ current as you land changes. ### Changed +- **The macOS menubar is a glance and the actions that are urgent when you look + at it** — the posture line, the switch or pause countdown, hold the line, and + Open Dezhban. **Block now and Unblock have moved into the window's Overview**: + someone who wants to cut their own internet can turn off Wi-Fi, so blocking by + hand is a power-user and debugging affordance rather than part of the routine + flow. + + **Panic now sits behind the Option key**, as the alternate to "Open Dezhban…" — + hold ⌥ and the item becomes "Panic — force unblock…", or press ⌘⌥O. It stays + in the menubar on purpose, because the moment it is needed is the moment the + main window may not open; it is one keystroke away rather than one slip away + in a menu people open to check a countdown. + - **A pause longer than `vpn.pauseMax` is now refused and explained, instead of silently shortened to the cap.** Asking for an hour against a 30-minute cap used to grant thirty minutes and report success, which is indistinguishable diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index cdf8ccc..6c4ef7d 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -490,9 +490,14 @@ task gui:build && open dist/Dezhban.app ### Surfaces & window lifecycle -- [ ] **Menubar is the safety core only.** The dropdown shows exactly: one status - line, Open Dezhban… (⌘O), Block now, Unblock, the switch item (VPN mode), - Panic — force unblock…, Quit. Nothing else. +- [ ] **Menubar is a glance and the time-critical set only.** The dropdown shows + exactly: one status line, Open Dezhban… (⌘O), the switch/pause item with + its live countdown, hold the line, Quit. **No Block or Unblock** — those + live in the window's Overview. +- [ ] **Panic is behind ⌥.** Holding Option swaps "Open Dezhban…" for "Panic — + force unblock…"; ⌘⌥O fires it; releasing Option restores the open item. It + still works with the daemon stopped and with the main window unable to + open. - [ ] **Window opening.** "Open Dezhban…" and a Dock-icon click both open/focus the main window; a fresh app launch opens **no** window (menubar + Dock only); closing the window (⌘W) leaves the app and icon running. diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 9745ec5..507a294 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -7,10 +7,14 @@ import DezhbanCore /// main window renders from; the dropdown is rebuilt from the current snapshot /// each time it opens. /// -/// The dropdown deliberately carries only the emergency/time-critical set — -/// status line, Open Dezhban, Block/Unblock, the switch window, Panic, Quit. -/// Panic and Block must work even if the main window can't open, so they never -/// move behind it. Everything else lives in the window (MainWindow/MainView). +/// The dropdown is a glance and the handful of actions that are time-critical +/// at the moment you reach for it: the status line, Open Dezhban, the switch +/// window with its countdown, Pause, hold the line, and Quit — plus Panic +/// behind ⌥, which must work even if the main window cannot open, so it never +/// moves behind it. Manual block and unblock are deliberately NOT here: +/// somebody who wants to cut their own internet can turn off Wi-Fi, so blocking +/// by hand is a power-user affordance and lives in the window's Overview with +/// everything else (MainWindow/MainView). final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private let menu = NSMenu() @@ -236,20 +240,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let open = addAction("Open Dezhban…", #selector(openMainWindow)) open.keyEquivalent = "o" + open.toolTip = "Everything else — settings, diagnostics, manual block, logs, help. Hold ⌥ for Panic." + + // Panic is the lockout escape hatch: it must never depend on the main + // window opening, so it keeps a menubar item. It sits behind ⌥ as the + // alternate to "Open Dezhban…" — one keystroke away in a fixed place, + // but not one slip away from a menu people open to check a countdown. + // + // An alternate must be adjacent to its sibling and share its key + // equivalent, differing only by the extra modifier: ⌘O opens the window, + // ⌥ swaps the item, ⌘⌥O panics. + let panic = addAction("Panic — force unblock…", #selector(confirmPanic), + enabled: DezhbanCLI.binaryPath() != nil) + panic.keyEquivalent = "o" + panic.keyEquivalentModifierMask = [.command, .option] + panic.isAlternate = true + panic.toolTip = "Removes every rule dezhban installed. Works with no daemon running." menu.addItem(.separator()) - // Manual block / unblock, gated on current posture. These go to the running - // daemon over its control socket, so they normally need no password — say so, - // and say the opposite when they'd have to fall back to a direct root action. - let blocked = s?.blocked ?? false - // With the guard holding a downed tunnel, Unblock doubles as the - // "my VPN is off on purpose — release the line" action. - let guardHolds = isRunning && PostureUI.guardHoldsDownedTunnel(s) - addAction("Block now", #selector(blockNow), enabled: isRunning && !blocked) - .toolTip = AppState.shared.routineHint("Cuts all egress and holds it until you unblock.") - addAction("Unblock", #selector(unblockNow), enabled: isRunning && (blocked || guardHolds)) - .toolTip = AppState.shared.routineHint("Releases a manual block and resumes monitoring.") + // Manual block and unblock are NOT here. Somebody who wants to cut their + // own internet can turn off Wi-Fi; blocking by hand is a power-user and + // debugging affordance, and it lives in the window's Overview with the + // rest of them. What stays is what is time-critical at the moment you + // reach for the menubar. // Switch window: connect a brand-new VPN whose server isn't known yet. // Time-critical mid-flow, and the countdown is glanceable — so it stays. @@ -324,10 +338,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } - // Panic is the lockout escape hatch: it must never depend on the main - // window opening, so it keeps a first-class menubar item. - addAction("Panic — force unblock…", #selector(confirmPanic), enabled: DezhbanCLI.binaryPath() != nil) - menu.addItem(.separator()) addAction("Quit", #selector(quit)) @@ -354,8 +364,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Routine posture ops: handled by the running daemon over its control socket, // with no password — semantics in AppActions.routine (refusals never escalate). - @objc private func blockNow() { AppActions.routine(["block"], "block") } - @objc private func unblockNow() { AppActions.routine(["unblock"], "unblock") } @objc private func openSwitch() { AppActions.routine(["switch", "--no-wait"], "open a switch window") } @objc private func cancelSwitch() { AppActions.routine(["switch", "--cancel"], "cancel the switch window") } @objc private func pauseNow() { AppActions.routine(["pause"], "pause the guard") } From da1a7ef67104253aae7bbceead4c6c114c60d76b Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 13:46:16 +0330 Subject: [PATCH 19/27] fix(help): refuse the markdown it cannot show, instead of degrading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled-docs design rests on one promise: a page the subset renderer cannot represent fails the build. It was not implemented. Unsupported was appended in exactly one place — an unclosed code fence — so everything else degraded quietly and shipped wrong while every test passed. What shipped: Quick start, the first page of the guided track, opened with three lines of raw HTML source rendered as visible text. Bold that the author soft-wrapped across two lines left literal ** in eight of the nine pages. Nested bullets flattened into siblings, putting loose text directly inside the
        . And an asterisk inside a code span (the glob in `vpn.advanced.*`) paired with an unrelated one to open an that closed outside the . Two of those are reporting gaps and two are renderer bugs, so both halves are fixed: - Unsupported now names raw HTML blocks, nested list items, unpaired **, and disallowed link schemes. A refused scheme is also replaced with "#" so a javascript: href cannot reach the bundle even past a failed build. - Blocks join their soft-wrapped lines before inline markup is read, which is what CommonMark does and what this renderer's hard-wrapped input requires. That fixes the straddling emphasis without rewriting prose, and puts a list item's continuation inside its
      • . - Code spans are lifted out to placeholders before the emphasis passes and restored after. Wrapping them in first never protected the contents — the comment claiming it did was wrong. - Bold is non-greedy rather than [^*]+, so **… *not* …** renders. The tests could not have caught any of it: the subset test rendered a synthetic sample of supported constructs, and the self-containment test looked for src="http while the leaked tag was escaped. Both now run against the real pages and assert on the rendered HTML, not only on the reporting they exist to police. The three pages the enforcement rejected are fixed too: the Quick start banner is removed (it cannot render offline in a self-contained bundle), a bold span in how-it-works is rewrapped, and the cli.md sidebar nesting becomes its own flat list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 12 ++ CLAUDE.md | 6 + docs/concepts/how-it-works.md | 5 +- docs/usage/cli.md | 63 ++++----- docs/usage/getting-started.md | 4 - internal/help/help_test.go | 118 +++++++++++++++++ internal/help/markdown.go | 238 +++++++++++++++++++++++++++++----- 7 files changed, 378 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e58c37e..f0fab58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,18 @@ current as you land changes. schema the pane falls back to a plainer label rather than a stale value: less helpful, never wrong. +- **The documentation bundled into the app now renders as written.** The subset + renderer degraded silently instead of refusing, so pages shipped wrong while + every test passed: Quick start — the first page of the guided track — opened + with three lines of raw HTML source shown as text, bold that the author had + wrapped across two lines left literal `**` in eight of the nine pages, nested + bullets flattened into their own parents, and an asterisk inside `` `code` `` + paired with an unrelated one to open emphasis that closed outside the tag. + Soft-wrapped lines are now joined before inline markup is read, a list item's + continuation stays inside the item, emphasis cannot reach into a code span, + and anything the renderer still cannot represent **fails the build** rather + than shipping — which is what the design claimed all along. + - **A settings value can no longer be written under the wrong key.** The pane staged its twenty-five values as an array destructured by position, with only a count check — so inserting or reordering a key would have silently applied diff --git a/CLAUDE.md b/CLAUDE.md index 1031ac1..78d2d11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,6 +301,12 @@ The design depends on these invariants (rationale in bundled page is moved, renamed, or written with markdown the subset renderer cannot show — and every `Tunable.DocAnchor` is checked to resolve against a real heading, so a contextual help link cannot rot into a silent no-op. + Concretely, a bundled page may not use **raw HTML** or **nested lists**; both + fail the build by name. Soft-wrapped emphasis is fine (blocks are joined before + inline markup is read). The rule that makes this safe is that the renderer + must **refuse** what it cannot represent, never degrade quietly: silent + degradation ships a wrong page while every test passes, which is exactly how + the banner on Quick start reached users. - **Every PR that changes user-visible behavior updates [CHANGELOG.md](CHANGELOG.md)'s `## [Unreleased]` section, in the same PR** — not as a follow-up. `[Unreleased]` *is* the next release's notes (see [docs/contribute/releasing.md](docs/contribute/releasing.md)); a PR diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 2646d76..c5a4a1e 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -107,8 +107,9 @@ below in [Exit-country policing](#exit-country-policing). the daemon discovers the new server socket, runs a geo lookup through the tunnel, and on a confirmed non-blocked exit **snaps the window shut early**, learns the endpoint, and restores GUARD. Total interaction required: zero. -4. **Or nothing redials.** The window expires and the guard **fail-closes - and stays closed** — no second window until a tunnel actually comes back. +4. **Or nothing redials.** The window expires and the guard + **fail-closes and stays closed** — no second window until a tunnel + actually comes back. A flapping tunnel doesn't get windows at all (`vpn.advanced.redialMinUptime`). diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 377dc52..d3cec02 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -466,36 +466,39 @@ Two surfaces, split by urgency: lockout-recovery actions; they never depend on the main window opening. Items enable/disable from the current state. - **Main window — everything else**, opened from the dropdown or by clicking the - Dock icon (never automatically at launch). Sidebar sections: - - **Overview** — live status hero (posture, IP/country, tunnel, endpoints, - every configured VPN profile with the matched one marked, switch-window - countdown, enforcement-error banner) plus the daily controls, Pause, and a - visually-separated Panic. With profiles configured, "Switching VPN…" - becomes a menu so a switch window can target one by name. Degraded states - are guided: CLI missing, service not installed, and daemon stopped each - render an explanation with the one relevant action inline (Install - service… / Guard up). - - **Settings** — startup ("Start the guard at boot" installs the launchd - system service so enforcement survives reboots; "Open this app at login" via - `SMAppService`; essential-event notifications), a **strictness preset - picker** (Strict/Balanced/Relaxed, each showing its cost, or "Custom" with - the keys that differ), tunnels/endpoints/autodetection, blocking (blocked - countries, poll interval), windows (switch/redial/endpoint grace), timing, - all applied through one validated `config set` batch, an **Advanced** - disclosure exposing every `vpn.advanced.*` key, **"Use Touch ID for - settings changes"** (see below), an explicit **Restart dezhban…**, and the - raw config file escape hatch (control socket, geo providers, allowlist are - JSON-only). - - **Diagnostics** — `doctor`'s findings (`--json`), rendered as status rows - with fixes inline instead of a text dump; an optional "Find my VPN's - server" checkbox runs it with `--discover`. Read-only, same guarantee as - running `dezhban doctor` in a terminal. - - **Logs** — a scoped `log show --last 1h`, a live `log stream` with Stop - (also opens Console.app), and the transcripts of window-triggered - panic/install/uninstall/apply/restart runs. - - **About** — version, config/binary paths, posture, service state, and which - elevation path (Touch ID-capable Authorization Services vs password-only - fallback) privileged actions will take. + Dock icon (never automatically at launch). + +The main window's sidebar sections: + +- **Overview** — live status hero (posture, IP/country, tunnel, endpoints, + every configured VPN profile with the matched one marked, switch-window + countdown, enforcement-error banner) plus the daily controls, Pause, and a + visually-separated Panic. With profiles configured, "Switching VPN…" + becomes a menu so a switch window can target one by name. Degraded states + are guided: CLI missing, service not installed, and daemon stopped each + render an explanation with the one relevant action inline (Install + service… / Guard up). +- **Settings** — startup ("Start the guard at boot" installs the launchd + system service so enforcement survives reboots; "Open this app at login" via + `SMAppService`; essential-event notifications), a **strictness preset + picker** (Strict/Balanced/Relaxed, each showing its cost, or "Custom" with + the keys that differ), tunnels/endpoints/autodetection, blocking (blocked + countries, poll interval), windows (switch/redial/endpoint grace), timing, + all applied through one validated `config set` batch, an **Advanced** + disclosure exposing every `vpn.advanced.*` key, **"Use Touch ID for + settings changes"** (see below), an explicit **Restart dezhban…**, and the + raw config file escape hatch (control socket, geo providers, allowlist are + JSON-only). +- **Diagnostics** — `doctor`'s findings (`--json`), rendered as status rows + with fixes inline instead of a text dump; an optional "Find my VPN's + server" checkbox runs it with `--discover`. Read-only, same guarantee as + running `dezhban doctor` in a terminal. +- **Logs** — a scoped `log show --last 1h`, a live `log stream` with Stop + (also opens Console.app), and the transcripts of window-triggered + panic/install/uninstall/apply/restart runs. +- **About** — version, config/binary paths, posture, service state, and which + elevation path (Touch ID-capable Authorization Services vs password-only + fallback) privileged actions will take. **Status icon** — full-color brand state icons (from `gui/artifacts/`), shown in both the menu bar and the Dock tile: teal allow/guard, red block/full-block, amber diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index c38cc41..cbc0c0d 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -1,7 +1,3 @@ -

        - Dezhban — system-wide network kill switch -

        - # Quick start Get dezhban protecting your machine in about ten minutes — without locking diff --git a/internal/help/help_test.go b/internal/help/help_test.go index da2dffe..f955cae 100644 --- a/internal/help/help_test.go +++ b/internal/help/help_test.go @@ -171,6 +171,124 @@ func TestFencedCodeIsVerbatim(t *testing.T) { } } +// TestEveryRealPageRendersCleanly is the test the synthetic subset sample above +// cannot be: it runs the renderer over the pages that actually ship. +// +// Both checks failed before they existed. usage/getting-started.md — the first +// page of the guided track — opened with a raw HTML banner that rendered as +// three paragraphs of visible tag source, and usage/cli.md left literal ** in +// the middle of a sentence. Render reported neither, so the build passed and the +// bundle shipped wrong. Asserting on the rendered HTML, not just on Unsupported, +// is what makes this independent of the reporting it is meant to police. +func TestEveryRealPageRendersCleanly(t *testing.T) { + for _, page := range Pages { + data, err := os.ReadFile(filepath.Join(docsDir, filepath.FromSlash(page.Source))) + if err != nil { + t.Fatalf("%s: %v", page.Source, err) + } + r := Render(string(data)) + if len(r.Unsupported) != 0 { + t.Errorf("%s: renderer reported %v", page.Source, r.Unsupported) + } + // A paragraph or list item opening with an escaped tag is markup that + // leaked through as text. Inline code legitimately contains <, hence + // anchoring on the element boundary rather than searching for <. + for _, leak := range []string{"

        <", "

      • <"} { + if strings.Contains(r.HTML, leak) { + t.Errorf("%s: raw HTML rendered as visible text (%q)", page.Source, leak) + } + } + // Fenced code is verbatim and may legitimately contain **, so only the + // prose outside it is checked. + if strings.Contains(stripFences(r.HTML), "**") { + t.Errorf("%s: literal ** survived into the rendered page", page.Source) + } + } +} + +// stripFences removes
         blocks, whose contents are verbatim by design.
        +func stripFences(html string) string {
        +	var b strings.Builder
        +	for rest := html; ; {
        +		start := strings.Index(rest, "
        ")
        +		if start < 0 {
        +			b.WriteString(rest)
        +			return b.String()
        +		}
        +		b.WriteString(rest[:start])
        +		end := strings.Index(rest[start:], "
        ") + if end < 0 { + return b.String() + } + rest = rest[start+end+len("
        "):] + } +} + +// TestRenderReportsWhatItCannotShow pins the reporting itself. Each of these +// rendered silently — and wrongly — before, which is the failure mode the whole +// bundled-docs design depends on not having. +func TestRenderReportsWhatItCannotShow(t *testing.T) { + cases := map[string]string{ + "raw HTML block": "

        \n \n

        \n", + "nested list item": "- parent\n - child\n", + "unpaired bold": "A sentence with **one marker only.\n", + "javascript: link": "A [trap](javascript:alert(1)) link.\n", + "unclosed code fence": "```sh\necho hi\n", + } + for name, md := range cases { + if r := Render(md); len(r.Unsupported) == 0 { + t.Errorf("%s was rendered without being reported:\n%s", name, r.HTML) + } + } + // A refused scheme must also not reach the href, so the bundle cannot carry + // the link even if someone ships past the build failure. + if r := Render("[trap](javascript:alert(1))\n"); strings.Contains(r.HTML, "javascript:") { + t.Errorf("a javascript: href survived into the rendered page:\n%s", r.HTML) + } +} + +// TestInlineMarkupSpansSoftLineBreaks — these docs are hard-wrapped prose, so +// emphasis routinely straddles a line break. Rendering line by line left the ** +// as literal text in eight of the nine bundled pages. +func TestInlineMarkupSpansSoftLineBreaks(t *testing.T) { + r := Render("The guard **fail-closes\nand stays closed** afterwards.\n") + if !strings.Contains(r.HTML, "fail-closes and stays closed") { + t.Errorf("bold did not survive a soft line break:\n%s", r.HTML) + } + if len(r.Unsupported) != 0 { + t.Errorf("a soft-wrapped bold span was reported as unsupported: %v", r.Unsupported) + } +} + +// A list item's continuation belongs INSIDE its
      • . Emitting it afterwards put +// loose text directly inside the
          , which is invalid. +func TestListItemContinuationStaysInsideTheItem(t *testing.T) { + r := Render("- first line\n second line\n- next item\n") + if !strings.Contains(r.HTML, "
        • first line second line
        • ") { + t.Errorf("continuation leaked out of its list item:\n%s", r.HTML) + } +} + +// Emphasis must not reach inside a code span. `vpn.advanced.*` used to pair its +// glob with a later asterisk and open an that closed outside the
          . +func TestCodeSpansAreNotTouchedByEmphasis(t *testing.T) { + r := Render("Every `vpn.advanced.*` key, **\"Use Touch ID\"** and more.\n") + if !strings.Contains(r.HTML, "vpn.advanced.*") { + t.Errorf("a code span was rewritten by the emphasis passes:\n%s", r.HTML) + } + if strings.Contains(r.HTML, "") { + t.Errorf("an asterisk inside a code span opened emphasis:\n%s", r.HTML) + } +} + +// Bold containing italics is written throughout the docs. +func TestItalicsInsideBold(t *testing.T) { + r := Render("**Endpoints are deliberately *not* required.**\n") + if !strings.Contains(r.HTML, "") || !strings.Contains(r.HTML, "not") { + t.Errorf("nested emphasis was not rendered:\n%s", r.HTML) + } +} + // The anchors have to match the ones written in the docs (and in DocAnchor), so // this pins the derivation rather than trusting it. func TestAnchorMatchesTheDocsConvention(t *testing.T) { diff --git a/internal/help/markdown.go b/internal/help/markdown.go index 3ffbd18..edcfb70 100644 --- a/internal/help/markdown.go +++ b/internal/help/markdown.go @@ -4,6 +4,8 @@ import ( "fmt" "html" "regexp" + "sort" + "strconv" "strings" ) @@ -18,6 +20,12 @@ import ( // not understand, and a test fails the build when a bundled page uses it. That // keeps the renderer honest AND keeps the docs inside a style the renderer can // actually show — a silently mangled page would be worse than a build failure. +// +// "Reports anything it does not understand" is the load-bearing half, and it has +// to be maintained deliberately: a construct this renderer degrades silently is +// a page that ships wrong while every test passes. Whenever a case is added +// here that cannot be represented, note() it — do not let it fall through to +// the paragraph branch and render as literal text. // Heading is one heading in a rendered page, for search and anchor resolution. type Heading struct { @@ -38,23 +46,57 @@ type Rendered struct { } var ( - inlineCode = regexp.MustCompile("`([^`]+)`") - inlineLink = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) - inlineImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) - inlineBold = regexp.MustCompile(`\*\*([^*]+)\*\*`) + inlineCode = regexp.MustCompile("`([^`]+)`") + inlineLink = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) + inlineImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + // Non-greedy and NOT [^*]+, so a bold span may contain italics: the docs + // write **… *not* …** and an inner-asterisk ban left the outer ** as + // literal text. Bold is substituted first and the inner *not* survives into + // the emphasis pass below, which is what turns it into . + inlineBold = regexp.MustCompile(`\*\*(.+?)\*\*`) inlineItalic = regexp.MustCompile(`(^|[^*])\*([^*]+)\*`) headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) bulletRe = regexp.MustCompile(`^\s*[-*]\s+(.*)$`) numberedRe = regexp.MustCompile(`^\s*\d+\.\s+(.*)$`) anchorStrip = regexp.MustCompile(`[^a-z0-9 -]`) + // htmlBlockRe matches a line that opens, closes, or comments raw HTML. The + // renderer has no way to show it, and escaping it prints the tag's source + // at the top of the page — which is exactly what shipped before this check + // existed. + htmlBlockRe = regexp.MustCompile(`^<(/?[a-zA-Z][a-zA-Z0-9-]*|!--)`) + // schemeRe matches a URL scheme prefix, so a link can be checked against the + // allowlist in rewriteLink. + schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) ) +// linkSchemes is what a documentation link is allowed to use. Anything else — +// javascript:, data:, file: — is refused at render time rather than relied on +// being cancelled by the app's navigation delegate: the bundle should not +// contain the link in the first place. +var linkSchemes = map[string]bool{"http": true, "https": true, "mailto": true} + // Render converts one markdown document to HTML. func Render(markdown string) Rendered { var out strings.Builder var text strings.Builder r := Rendered{} + // note collects what could not be represented. A set, because one page + // repeating the same unsupported construct twenty times is still one thing + // to fix, and the message should say what it is rather than how often. + unsupported := map[string]bool{} + note := func(what string) { unsupported[what] = true } + + // renderInline is the only way inline markup reaches the output, so every + // construct it cannot represent is reported from one place. + renderInline := func(s string) string { + rendered := inline(s, note) + if strings.Contains(rendered, "**") { + note("unpaired ** (bold that never closed)") + } + return rendered + } + lines := strings.Split(markdown, "\n") inCode, inList, inQuote := false, false, false listTag := "" @@ -101,6 +143,14 @@ func Render(markdown string) Rendered { continue } + // Raw HTML. Checked before everything else that could swallow the line, + // and reported rather than rendered: there is no honest way to show it, + // and the paragraph branch would print the tag source as visible text. + if htmlBlockRe.MatchString(trimmed) { + note("raw HTML (" + firstTag(trimmed) + ") — the renderer has no way to show it") + continue + } + if m := headingRe.FindStringSubmatch(trimmed); m != nil { closeList() closeQuote() @@ -108,7 +158,7 @@ func Render(markdown string) Rendered { title := stripInline(m[2]) anchor := Anchor(title) r.Headings = append(r.Headings, Heading{Level: level, Text: title, Anchor: anchor}) - fmt.Fprintf(&out, "%s\n", level, anchor, inline(m[2]), level) + fmt.Fprintf(&out, "%s\n", level, anchor, renderInline(m[2]), level) text.WriteString(title + "\n") continue } @@ -125,7 +175,7 @@ func Render(markdown string) Rendered { if strings.HasPrefix(trimmed, "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]) { closeList() closeQuote() - consumed := renderTable(&out, &text, lines[i:]) + consumed := renderTable(&out, &text, lines[i:], renderInline) i += consumed - 1 continue } @@ -136,13 +186,18 @@ func Render(markdown string) Rendered { out.WriteString("
          \n") inQuote = true } - body := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) - out.WriteString("

          " + inline(body) + "

          \n") + body := quoteBody(trimmed) + for i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { + i++ + body += " " + quoteBody(strings.TrimSpace(lines[i])) + } + out.WriteString("

          " + renderInline(body) + "

          \n") text.WriteString(stripInline(body) + "\n") continue } if m := bulletRe.FindStringSubmatch(line); m != nil { + noteIfNested(line, note) if inList && listTag != "ul" { closeList() } @@ -150,11 +205,14 @@ func Render(markdown string) Rendered { out.WriteString("
            \n") inList, listTag = true, "ul" } - out.WriteString("
          • " + inline(m[1]) + "
          • \n") - text.WriteString(stripInline(m[1]) + "\n") + body, last := gatherItem(lines, i, m[1]) + i = last + out.WriteString("
          • " + renderInline(body) + "
          • \n") + text.WriteString(stripInline(body) + "\n") continue } if m := numberedRe.FindStringSubmatch(line); m != nil { + noteIfNested(line, note) if inList && listTag != "ol" { closeList() } @@ -162,35 +220,115 @@ func Render(markdown string) Rendered { out.WriteString("
              \n") inList, listTag = true, "ol" } - out.WriteString("
            1. " + inline(m[1]) + "
            2. \n") - text.WriteString(stripInline(m[1]) + "\n") - continue - } - - // A continuation line inside a list item, rather than a new paragraph. - if inList && strings.HasPrefix(line, " ") { - out.WriteString(" " + inline(trimmed)) - text.WriteString(stripInline(trimmed) + "\n") + body, last := gatherItem(lines, i, m[1]) + i = last + out.WriteString("
            3. " + renderInline(body) + "
            4. \n") + text.WriteString(stripInline(body) + "\n") continue } closeList() closeQuote() - out.WriteString("

              " + inline(trimmed) + "

              \n") - text.WriteString(stripInline(trimmed) + "\n") + body := trimmed + for i+1 < len(lines) && !startsNewBlock(lines, i+1) { + i++ + body += " " + strings.TrimSpace(lines[i]) + } + out.WriteString("

              " + renderInline(body) + "

              \n") + text.WriteString(stripInline(body) + "\n") } closeList() closeQuote() if inCode { - r.Unsupported = append(r.Unsupported, "an unclosed code fence") + note("an unclosed code fence") } + // Sorted, so a build failure names the same things in the same order twice + // running and a diff of two failures is readable. + r.Unsupported = make([]string, 0, len(unsupported)) + for what := range unsupported { + r.Unsupported = append(r.Unsupported, what) + } + sort.Strings(r.Unsupported) + r.HTML = out.String() r.Text = text.String() return r } +// A block's soft-wrapped lines are joined before any inline markup is rendered, +// because markdown emphasis spans a line break and this renderer's input is +// hard-wrapped prose. Rendering line by line meant a bold span the author wrapped +// across two lines never paired, and the `**` showed up as literal text in the +// shipped bundle — in eight of the nine pages. Joining is also what puts a list +// item's continuation INSIDE its
            5. rather than after it. + +// startsNewBlock reports whether the line at i begins something that cannot be +// a continuation of the paragraph or list item being gathered. +func startsNewBlock(lines []string, i int) bool { + line := lines[i] + t := strings.TrimSpace(line) + switch { + case t == "": + case strings.HasPrefix(t, "```"): + case htmlBlockRe.MatchString(t): + case headingRe.MatchString(t): + case t == "---" || t == "***" || t == "___": + case strings.HasPrefix(t, ">"): + case bulletRe.MatchString(line) || numberedRe.MatchString(line): + case strings.HasPrefix(t, "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]): + default: + return false + } + return true +} + +// gatherItem joins a list item with its indented continuation lines, reporting +// the index of the last line it consumed. Continuation must be indented: an +// unindented line after an item ends the list, the same boundary the renderer +// drew before blocks were gathered. +func gatherItem(lines []string, i int, first string) (string, int) { + body := first + for i+1 < len(lines) { + next := lines[i+1] + if startsNewBlock(lines, i+1) || strings.TrimLeft(next, " \t") == next { + break + } + i++ + body += " " + strings.TrimSpace(next) + } + return body, i +} + +// quoteBody strips one leading ">" from a block-quote line. +func quoteBody(trimmed string) string { + return strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) +} + +// noteIfNested reports an indented list item. The renderer emits one flat list, +// so a nested item would silently become a sibling of its own parent — the +// hierarchy the author wrote would be gone, and the surrounding text would end +// up directly inside the
                . +func noteIfNested(line string, note func(string)) { + if len(line)-len(strings.TrimLeft(line, " \t")) >= 2 { + note("a nested list item — this renderer emits one flat list") + } +} + +// firstTag names the tag in a raw-HTML line, so the build failure says which +// one to remove rather than only that some HTML exists. +func firstTag(line string) string { + if strings.HasPrefix(line, "" + } + end := strings.IndexAny(line, " \t>") + if end < 0 { + end = len(line) + } + return line[:end] + ">" +} + // isTableSeparator matches the |---|---| row that turns the line above it into // a table header. func isTableSeparator(line string) bool { @@ -207,11 +345,11 @@ func isTableSeparator(line string) bool { } // renderTable emits one table and reports how many lines it consumed. -func renderTable(out *strings.Builder, text *strings.Builder, lines []string) int { +func renderTable(out *strings.Builder, text *strings.Builder, lines []string, renderInline func(string) string) int { header := splitRow(lines[0]) out.WriteString("
                \n") for _, c := range header { - out.WriteString("") + out.WriteString("") text.WriteString(stripInline(c) + " ") } out.WriteString("\n\n") @@ -225,7 +363,7 @@ func renderTable(out *strings.Builder, text *strings.Builder, lines []string) in } out.WriteString("") for _, c := range splitRow(s) { - out.WriteString("") + out.WriteString("") text.WriteString(stripInline(c) + " ") } out.WriteString("\n") @@ -244,20 +382,44 @@ func splitRow(line string) []string { return parts } +// codeMark delimits a lifted code span. NUL cannot appear in the escaped text — +// html.EscapeString does not produce it and these documents do not contain it — +// so a placeholder can never collide with document content, and it matches none +// of the emphasis or link patterns. +const codeMark = "\x00" + +func codePlaceholder(i int) string { return codeMark + strconv.Itoa(i) + codeMark } + // inline renders inline markup. Escaping happens FIRST and the markup is // substituted into the escaped text, so no document content can inject HTML — // the pages are ours, but the rule costs nothing and removes the question. -func inline(s string) string { +func inline(s string, note func(string)) string { out := html.EscapeString(s) - // Code first: its contents must not then be read as emphasis. - out = inlineCode.ReplaceAllString(out, "$1") + + // Code spans are LIFTED OUT before anything else runs, then put back last. + // Wrapping them in in place is not enough: the emphasis passes scan + // the whole string afterwards, so an asterisk inside a code span (the glob + // in `vpn.advanced.*`) would pair with an unrelated asterisk later in the + // line and open an that closes outside the . That produced + // invalid markup in the shipped bundle. Lifting also keeps link and image + // syntax inside a code span from being turned into a real link. + var spans []string + out = inlineCode.ReplaceAllStringFunc(out, func(m string) string { + spans = append(spans, inlineCode.FindStringSubmatch(m)[1]) + return codePlaceholder(len(spans) - 1) + }) + out = inlineImage.ReplaceAllString(out, `$1`) out = inlineLink.ReplaceAllStringFunc(out, func(m string) string { g := inlineLink.FindStringSubmatch(m) - return fmt.Sprintf("%s", rewriteLink(g[2]), g[1]) + return fmt.Sprintf("%s", rewriteLink(g[2], note), g[1]) }) out = inlineBold.ReplaceAllString(out, "$1") out = inlineItalic.ReplaceAllString(out, "$1$2") + + for i, span := range spans { + out = strings.ReplaceAll(out, codePlaceholder(i), ""+span+"") + } return out } @@ -265,9 +427,21 @@ func inline(s string) string { // A link to something not bundled is left as written; the browser refuses // anything that is not a local file, so it simply does nothing rather than // silently leaving the app. -func rewriteLink(href string) string { - if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || - strings.HasPrefix(href, "#") { +// +// A scheme outside linkSchemes is refused HERE, at build time, rather than left +// for the app's navigation delegate to cancel. Defence in depth is the reason +// the delegate exists, but a javascript: or data: href has no business being in +// the bundle at all, and a build failure is how it gets removed. +func rewriteLink(href string, note func(string)) string { + if scheme := schemeRe.FindString(href); scheme != "" { + name := strings.ToLower(strings.TrimSuffix(scheme, ":")) + if !linkSchemes[name] { + note("a " + name + ": link — only http, https, and mailto are allowed") + return "#" + } + return href + } + if strings.HasPrefix(href, "#") { return href } path, frag, _ := strings.Cut(href, "#") From a3ea5367cc5e1bde2740f132a7db0ecf3c726354 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 13:46:59 +0330 Subject: [PATCH 20/27] fix(pause): say why a pause was refused on the command-file path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the socket path refuse an over-cap pause left clampPause as a wrapper that called pauseDuration and threw the refusal away — so it returned 0 for a request that was merely too long, not disabled. Its one caller, the root-owned command file, then logged "pausing is disabled (vpn.pauseMax: \"0\")" while pauseMax was a perfectly real 30m. That path is not a backwater: it is what runs when no daemon answers the socket, and the only one when control.allowPauseOps is false. The CLI pre-validates with PauseRefusal, but only when its config loads and only against its own view of it, so a daemon that has since reloaded a lower cap still lands here. Before this branch the request was clamped and the pause opened; after it, the pause silently did not happen and the log blamed the wrong setting. It fails safe — no pause opens — so this is about the account the operator gets. That path has no reply channel, which makes the log line the whole explanation, so it now carries pauseDuration's refusal verbatim: which setting refused, and what to ask for instead. clampPause is deleted rather than fixed; with the refusal string it had nothing left to add, and its doc comment had become false on both of its claims. The two comments that named it are updated to name pauseDuration. Also corrects the hold-the-line comment in maybeAutoWindow: it claimed the flag is spent "whether or not anything else would have suppressed the window anyway", but the guard clause above it returns first for standby, FULL BLOCK, an open window, and a tunnel never seen up. The flag does survive those, and that is safe — a drop cannot follow a drop without an intervening tunnel-up edge, which disarms it — so the comment now says that instead of overstating the code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 10 +++++--- internal/runner/runner.go | 53 ++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fab58..1eba44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,10 +154,12 @@ current as you land changes. silently shortened to the cap.** Asking for an hour against a 30-minute cap used to grant thirty minutes and report success, which is indistinguishable from having got what you asked for. It now fails, names the cap, and says how - to raise it. Both the CLI and the daemon refuse, so a control-socket client - cannot get the old behaviour either. A pause with *no* duration given is - unchanged: nobody asked for a particular length, so the built-in default is - still clamped to the cap. + to raise it. Every path refuses — the CLI, the control socket, and the + root-owned command file — so no client can get the old behaviour, and the + command file (the one that still works with `control.allowPauseOps: false`) + logs the same reason the socket would have replied with. A pause with *no* + duration given is unchanged: nobody asked for a particular length, so the + built-in default is still clamped to the cap. ### Fixed diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ee9deec..2c5c19a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -990,9 +990,14 @@ func (o Options) runGuard(ctx context.Context) error { } // Hold the line, checked before the flap guard: an operator who said // this drop is deliberate has answered the only question the window - // exists to guess at. One-shot — spent here, whether or not anything - // else would have suppressed the window anyway, so "armed" never - // silently carries over to a later accidental drop. + // exists to guess at. One-shot — spent here rather than left armed for + // a later, accidental drop. + // + // The guard clause above returns first when nothing could have opened a + // window anyway (standby, FULL BLOCK, a window already open, a tunnel + // never seen up), so the flag survives those. That is safe because a + // drop cannot follow a drop without an intervening tunnel-up edge, and + // that edge disarms it — see the st.Up branch in the watcher. if holdArmed { holdArmed = false o.Log.Warn("vpn tunnel down — redial window suppressed (hold the line was armed); "+ @@ -1531,7 +1536,7 @@ func (o Options) runGuard(ctx context.Context) error { } pauseWindowMax = ls.PauseMax pauseEnabled = pauseWindowMax > 0 && o.PollCommand != nil - o.PauseMax = ls.PauseMax // clampPause reads this, and so does the log line below + o.PauseMax = ls.PauseMax // pauseDuration reads this, and so does the log line below if ls.EndpointRefresh > 0 { if ls.EndpointRefresh != o.EndpointRefresh { @@ -1593,8 +1598,9 @@ func (o Options) runGuard(ctx context.Context) error { // that started with both disabled must still notice a command once one is // re-enabled — otherwise the reload reports the key as applied while the // root-owned command path stays deaf until a restart. Every command handler - // below re-checks the live value (clampWindow / clampPause return <=0 when the - // trigger is off), so an ungated tick can never act on a disabled trigger. + // below re-checks the live value (clampWindow returns <=0 and pauseDuration + // refuses when the trigger is off), so an ungated tick can never act on a + // disabled trigger. var cmdC <-chan time.Time if o.PollCommand != nil { cmdInterval := o.CommandPoll @@ -1760,12 +1766,19 @@ func (o Options) runGuard(ctx context.Context) error { o.Log.Warn("ignoring pause command — a switch window is already open (cancel it first)") continue } - dur := o.clampPause(cmd.Duration) - if dur <= 0 { - // Reachable, and the live gate: the poll ticks regardless of - // whether pausing is enabled. Never open a pause the operator - // disabled. - o.Log.Warn("ignoring pause command — pausing is disabled (vpn.pauseMax: \"0\")") + // Same refusal the socket path gives, for the same reason: an + // over-cap request is declined and explained, never silently + // shortened. Reporting the refusal verbatim matters because this + // path has no reply channel — the log line is the only account + // the operator gets, so it must say which setting refused and + // why rather than blaming a disabled pause for a length problem. + // + // Reachable, and the live gate: the poll ticks regardless of + // whether pausing is enabled, so a PauseMax <= 0 refusal here is + // what stops a disabled pause from ever opening. + dur, refusal := o.pauseDuration(cmd.Duration) + if refusal != "" { + o.Log.Warn("ignoring pause command — " + refusal) continue } openWindow(now, dur, "", state.TriggerPause) @@ -2204,20 +2217,14 @@ func (o Options) clampWindow(req string) time.Duration { // comes from the caller (CLI flag / GUI preset). const defaultPauseDuration = 15 * time.Minute -// clampPause parses a requested pause duration and caps it at PauseMax (no -// floor). An empty/invalid request falls back to defaultPauseDuration. Returns -// 0 when pausing is disabled (PauseMax <= 0) — callers (control-socket ops, -// command-poll cases) are already gated on pauseEnabled, but failing closed -// here means a future caller that skipped the gate can never turn "disabled" -// into a real relaxation of the guard, whatever value req parses to. -func (o Options) clampPause(req string) time.Duration { - dur, _ := o.pauseDuration(req) - return dur -} - // pauseDuration resolves a requested pause length, or explains why it cannot be // granted. The refusal string is empty when the duration is usable. // +// Both callers — the control-socket op and the command-file poll — are already +// gated on pauseEnabled, but this fails closed on PauseMax <= 0 anyway, so a +// future caller that skipped the gate can never turn "disabled" into a real +// relaxation of the guard, whatever value req parses to. +// // An explicitly requested length longer than vpn.pauseMax is REFUSED, not // shortened. Quietly granting 30m to someone who asked for an hour is the same // class of bug as accepting a disabled window and restoring the default: the From 99798c5870f516306928c71a3b879218c2d5f982 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 13:47:25 +0330 Subject: [PATCH 21/27] fix(setup): leave a key alone when the wizard never asked about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autoDiscover question is macOS-only — Questions() does not append it on any other platform. NewAnswers therefore allocates no binding for it, Answers.Bool reports false for a missing binding, and Apply wrote that false straight onto vpn.autoDiscoverEndpoints. Re-running setup on Linux or Windows to change a log level turned off a setting the user had set, without asking and without saying so. This is the same failure that made setup delete imported profiles, one commit-series earlier: a value the user never touched, overwritten by a wizard that had nothing to say about it. The profiles fix merged; this one has to distinguish "answered false" from "never asked", which a bool cannot do — hence OptionalBool and a *bool on Input, with Apply writing only when the pointer is non-nil. The effect today is confined: the key does nothing off macOS. But GOOS is explicitly the platform the config is FOR, not the one running the wizard, so authoring a macOS config elsewhere is a supported path, and a config key being silently rewritten is worth fixing on its own terms. The regression test asserts through the real question set rather than a hand-built Input, so it fails if the question ever stops being gated the way it is — the shape of the bug, not just this instance of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 7 +++++++ internal/setup/answers.go | 34 ++++++++++++++++++++++++++++------ internal/setup/setup_test.go | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eba44a..587b1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,13 @@ current as you land changes. a count check — so inserting or reordering a key would have silently applied one field's value to another setting. Values are now keyed throughout. +- **`dezhban setup` no longer clears `vpn.autoDiscoverEndpoints` on Linux and + Windows.** Endpoint discovery is macOS-only, so the wizard does not ask about + it elsewhere — and an unasked question was read as "no" and written to the + config, quietly turning off a setting the user had set. The same class of bug + as the deleted profiles above: a question the wizard never put on screen now + leaves its key alone. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/internal/setup/answers.go b/internal/setup/answers.go index d6749e2..caaf831 100644 --- a/internal/setup/answers.go +++ b/internal/setup/answers.go @@ -92,6 +92,19 @@ func (a *Answers) Bool(id string) bool { return false } +// OptionalBool distinguishes "answered false" from "never asked". A question the +// wizard did not put on screen — the macOS-only ones, on another platform — has +// no binding at all, and Bool would report false for it, which Apply would then +// write over whatever the user had configured. +func (a *Answers) OptionalBool(id string) *bool { + p, ok := a.flag[id] + if !ok { + return nil + } + v := *p + return &v +} + func (a *Answers) List(id string) []string { if p, ok := a.list[id]; ok { return append([]string(nil), *p...) @@ -138,8 +151,13 @@ type Input struct { AutoMode bool Tunnels, Endpoints []string Profiles []config.Profile - AutoDiscover bool - AllowPhysicalDNS bool + // AutoDiscover is nil when the wizard never asked — the question is macOS-only, + // so on every other platform there is no answer to apply. Nil rather than + // false because Apply must leave an unasked key ALONE: writing false would + // silently clear a value the user set, which is the same failure that made + // re-running setup delete imported profiles (see mergeProfiles). + AutoDiscover *bool + AllowPhysicalDNS bool } // Input folds the answers into the shape Apply writes. @@ -166,9 +184,9 @@ func (a *Answers) Input(hysteresis string, profiles []config.Profile) Input { Tunnels: tunnels, Endpoints: SplitList(a.Text("endpoints")), Profiles: profiles, - // Absent on every platform but macOS, where the question is not asked — - // so the binding is missing and this reads false, which is the truth. - AutoDiscover: a.Bool("autoDiscover"), + // Absent on every platform but macOS, where the question is not asked at + // all. Nil there, so Apply leaves the configured value untouched. + AutoDiscover: a.OptionalBool("autoDiscover"), AllowPhysicalDNS: a.Bool("allowPhysicalDNS"), } } @@ -202,7 +220,11 @@ func Apply(cfg *config.Config, in Input) { } cfg.VPN.Endpoints = in.Endpoints cfg.VPN.Profiles = mergeProfiles(cfg.VPN.Profiles, in.Profiles) - cfg.VPN.AutoDiscoverEndpoints = in.AutoDiscover + // Only when the wizard asked. An unasked question leaves its key alone — + // the same rule ConfigureVPN applies to the whole VPN branch. + if in.AutoDiscover != nil { + cfg.VPN.AutoDiscoverEndpoints = *in.AutoDiscover + } cfg.VPN.AllowPhysicalDNS = in.AllowPhysicalDNS } diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 6143bbf..ccdee13 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -19,9 +19,12 @@ func TestApplyAutoMode(t *testing.T) { Tunnels: []string{"utun9"}, // must be ignored in auto mode Endpoints: []string{"vpn.example.com"}, Profiles: []config.Profile{{Name: "home", Endpoints: []string{"203.0.113.7"}}}, - AutoDiscover: true, + AutoDiscover: boolPtr(true), AllowPhysicalDNS: true, }) + if !cfg.VPN.AutoDiscoverEndpoints { + t.Error("an answered autoDiscover should be applied") + } if len(cfg.VPN.TunnelInterfaces) != 0 { t.Errorf("auto mode must not pin interfaces, got %v", cfg.VPN.TunnelInterfaces) } @@ -41,6 +44,35 @@ func TestApplyAutoMode(t *testing.T) { } } +func boolPtr(v bool) *bool { return &v } + +// A question the wizard never put on screen must not have its key rewritten. +// The autoDiscover question is macOS-only, so off macOS there is no binding for +// it — and reading a missing binding as "false" meant re-running setup on Linux +// silently cleared vpn.autoDiscoverEndpoints. Same failure as the one that used +// to delete imported profiles: a value the user never touched, overwritten. +func TestAnUnaskedQuestionLeavesItsKeyAlone(t *testing.T) { + cfg := config.Default() + cfg.VPN.AutoDiscoverEndpoints = true + + qs := Questions(Options{Config: &cfg, GOOS: "linux", ConfigExisted: true}) + for _, q := range qs { + if q.ID == "autoDiscover" { + t.Fatal("the autoDiscover question is macOS-only; this test assumes it is absent") + } + } + + answers := NewAnswers(qs) + if answers.OptionalBool("autoDiscover") != nil { + t.Fatal("an unasked question should have no binding") + } + Apply(&cfg, answers.Input(strconv.Itoa(cfg.Hysteresis), nil)) + + if !cfg.VPN.AutoDiscoverEndpoints { + t.Error("vpn.autoDiscoverEndpoints was cleared by a wizard that never asked about it") + } +} + // Advanced mode pins the chosen interfaces. func TestApplyAdvancedPin(t *testing.T) { cfg := config.Default() From 5b61f52ac87d60aa3647c5f568a9acd3402714be Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 13:47:55 +0330 Subject: [PATCH 22/27] fix(gui,render): name the day a drop happened, and contain by path component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small correctness fixes found alongside the larger ones. The drop record is carried until a tunnel comes back, which through an overnight outage or a long FULL BLOCK is not the same day. Rendering it as a bare time.Kitchen meant "Your VPN dropped at 3:04PM" read as a few minutes ago and understated how long the host had been cut — on the one screen whose whole job is saying what happened and when. The day is named as soon as the drop is not on the snapshot's own day, compared against s.Time rather than time.Now() so a Display still depends only on the Snapshot it was rendered from. HelpWebView confined navigation with hasPrefix on the readAccess path, so a sibling directory whose name merely starts the same way — "help-old" beside "help" — would satisfy it while being inside nothing. The bundle is generated and has no such sibling, which is the reason the check should not depend on that staying true. Compared by path component now. And the comment above allowsContentJavaScript said the coordinator scrolls via evaluateJavaScript, which its own implementation contradicts thirty lines below: the anchor rides in the URL fragment and WebKit does the scrolling, precisely so no script goes into a pane that has none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 6 ++++++ gui/macos/Sources/DezhbanMenu/HelpView.swift | 21 ++++++++++++++++---- internal/render/render.go | 20 +++++++++++++++++++ internal/render/render_test.go | 16 +++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 587b1f3..280bcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,6 +202,12 @@ current as you land changes. as the deleted profiles above: a question the wizard never put on screen now leaves its key alone. +- **A drop that did not happen today now says which day it was.** The drop + record is carried until a tunnel returns, so through an overnight outage or a + long FULL BLOCK "Your VPN dropped at 3:04PM" read as *a few minutes ago* and + understated how long the host had been cut. It now reads "at 3:04PM on Jul 26" + once the drop is no longer on the snapshot's own day. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/gui/macos/Sources/DezhbanMenu/HelpView.swift b/gui/macos/Sources/DezhbanMenu/HelpView.swift index 4a40556..4f3fd58 100644 --- a/gui/macos/Sources/DezhbanMenu/HelpView.swift +++ b/gui/macos/Sources/DezhbanMenu/HelpView.swift @@ -235,7 +235,8 @@ struct HelpWebView: NSViewRepresentable { func makeNSView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() // Nothing bundled runs script; the pane only ever scrolls to an anchor, - // which the coordinator does itself via evaluateJavaScript. + // which the coordinator does with a URL fragment and WebKit's own + // scrolling — no script is injected either (see `load`). config.defaultWebpagePreferences.allowsContentJavaScript = false let web = WKWebView(frame: .zero, configuration: config) web.navigationDelegate = context.coordinator @@ -284,6 +285,20 @@ struct HelpWebView: NSViewRepresentable { DispatchQueue.main.async { [parent] in parent.anchor = nil } } + /// Whether `url` is `root` itself or something beneath it. + /// + /// Compared by path COMPONENT, not by string prefix: a sibling whose + /// name merely starts with the same characters — "help-old" next to + /// "help" — satisfies a prefix test while being inside nothing. The + /// bundle is generated and has no such sibling today, which is exactly + /// why the check should not depend on that staying true. + static func isInside(_ url: URL, _ root: URL) -> Bool { + let here = url.standardizedFileURL.pathComponents + let base = root.standardizedFileURL.pathComponents + guard here.count >= base.count else { return false } + return Array(here.prefix(base.count)) == base + } + func webView(_ web: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -295,9 +310,7 @@ struct HelpWebView: NSViewRepresentable { // refused — no http(s), no custom scheme, no path outside the // bundle. A refused link is reported so the pane can say what // happened rather than appearing to ignore the click. - guard target.isFileURL, - target.standardizedFileURL.path.hasPrefix(parent.readAccess.standardizedFileURL.path) - else { + guard target.isFileURL, Coordinator.isInside(target, parent.readAccess) else { let blocked = target DispatchQueue.main.async { [parent] in parent.blockedLink = blocked } decisionHandler(.cancel) diff --git a/internal/render/render.go b/internal/render/render.go index c4aca92..a64ef55 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -288,14 +288,34 @@ func redialCause(s state.Snapshot) string { return "Your VPN dropped and the guard relaxed so it can redial." } +// droppedFormat qualifies a drop with the day it happened. Used once the drop is +// no longer on the snapshot's own day. +const droppedFormat = time.Kitchen + " on Jan 2" + // dropTime renders the carried drop's time, or "" when no drop is being carried. +// +// The record is carried until a tunnel returns, which through an overnight +// outage or a long FULL BLOCK is not the same day. A bare "3:04PM" then reads as +// "a few minutes ago" and quietly misstates how long the host has been cut, so +// the day is named as soon as it is not the snapshot's own. Compared against +// s.Time rather than time.Now() so the sentence a snapshot renders to depends +// only on the snapshot. func dropTime(s state.Snapshot) string { if s.Drop == nil || s.Drop.At.IsZero() { return "" } + if !s.Time.IsZero() && !sameDay(s.Drop.At, s.Time) { + return s.Drop.At.Format(droppedFormat) + } return s.Drop.At.Format(untilFormat) } +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + // lookupNote surfaces a genuine exit-country lookup failure. ExitUnknown is // deliberately never surfaced here: it names the ordinary condition of having // no VPN exit to measure (no tunnel up), and prose about it would be exactly diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 4ffe4d3..3372f6d 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -71,6 +71,22 @@ func TestText(t *testing.T) { wantDetail: "Your VPN dropped at 3:04PM. Guard active, but no tunnel is up — " + "all traffic is cut until your VPN redials.", }, + { + // The record is carried until a tunnel returns, so it outlives the + // day it happened on. A bare clock time would then read as "a few + // minutes ago" and understate how long the host has been cut. + name: "guard holds downed tunnel, drop was not today", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until.AddDate(0, 0, 2), + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until, Cut: true}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM on " + until.Format("Jan 2") + + ". Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", + }, { name: "full block with country", snap: state.Snapshot{Posture: PostureFullBlock, CountryCode: "IR"}, From 3cf6133f3b1c85a1fab48a74d73b9b02d9926591 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 14:52:56 +0330 Subject: [PATCH 23/27] fix(help): every link goes somewhere, and nothing loads from the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled pages cross-reference the ADRs and the contributor docs thirty times, and those deliberately do not ship. rewriteLink returned an unmatched relative link unchanged, so "../adr/0008-arm-at-boot.md" reached the bundle verbatim and resolved beside it, where no such file exists. Clicking one reported "That link points outside the app: file:///…/Contents/Resources/adr/ 0008-arm-at-boot.md" — an internal path, on a Copy button that copied it. Every one of the nine pages had them, and the only link assertion covered the case that DID resolve. Render now takes the page's source path, because that is what a relative link means: "../adr/0001.md" is a different file in usage/config.md than in concepts/modes.md. Resolved against the repo root, a target that is a bundled page rewrites to the bundled copy as before, and anything else becomes a github.com URL — which the pane still cannot follow, but now reports as something a reader can use. Pinned to main rather than the built tag: this is the one link that is copied into a browser later, and a deleted tag reads as a broken project. TestEveryLinkGoesSomewhere builds the bundle and stats every local href, which is the assertion whose absence let thirty of them ship. Also closes the network guarantee the pane's own doc comment states. The navigation delegate sees navigations, not subresource loads, so it was never what would stop an — only a grep in the bundle test was. The renderer now refuses a remote image by name, and each page declares a CSP of its own. `file:` is named beside 'self' there deliberately: a file: origin is opaque, so whether 'self' matches the page's own stylesheet is a WebKit detail, and a CSP that quietly dropped help.css would ship an unstyled pane — a worse regression than the one it prevents. Added to the on-host checklist, since only a real WKWebView can show it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 10 +++++ CLAUDE.md | 10 +++-- docs/contribute/testing.md | 9 +++++ internal/help/bundle.go | 18 ++++++++- internal/help/help_test.go | 63 ++++++++++++++++++++++++----- internal/help/markdown.go | 81 ++++++++++++++++++++++++++++++-------- 6 files changed, 161 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280bcfb..67a2801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -208,6 +208,16 @@ current as you land changes. understated how long the host had been cut. It now reads "at 3:04PM on Jul 26" once the drop is no longer on the snapshot's own day. +- **Links in the app's Help pane go somewhere.** The bundled pages cross-reference + the ADRs and the contributor docs thirty times over, and those deliberately do + not ship — so each of those links resolved to a file beside the bundle that does + not exist, and clicking one reported *"That link points outside the app: + file:///…/Contents/Resources/adr/0008-arm-at-boot.md"* with a Copy button that + copied exactly that. Every bundled page had them. A link to a document that is + not bundled now points at the repository, so the pane names a URL that works in + a browser; a link to a page that *is* bundled still opens it in place. The pane + still reaches the network for nothing. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/CLAUDE.md b/CLAUDE.md index 78d2d11..f12883c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,9 +301,13 @@ The design depends on these invariants (rationale in bundled page is moved, renamed, or written with markdown the subset renderer cannot show — and every `Tunable.DocAnchor` is checked to resolve against a real heading, so a contextual help link cannot rot into a silent no-op. - Concretely, a bundled page may not use **raw HTML** or **nested lists**; both - fail the build by name. Soft-wrapped emphasis is fine (blocks are joined before - inline markup is read). The rule that makes this safe is that the renderer + Concretely, a bundled page may not use **raw HTML**, **nested lists**, or a + **remote image**; all three fail the build by name. Soft-wrapped emphasis is + fine (blocks are joined before inline markup is read). **A relative link to a + doc that is not bundled is rewritten to point at the repository** — never left + as written, which resolved to a nonexistent file beside the bundle and made + every cross-reference to an ADR a dead click; `TestEveryLinkGoesSomewhere` + pins it. The rule that makes all of this safe is that the renderer must **refuse** what it cannot represent, never degrade quietly: silent degradation ships a wrong page while every test passes, which is exactly how the banner on Quick start reached users. diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 6c4ef7d..703ae21 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -666,6 +666,15 @@ traffic, so the check that matters is the one CI cannot run: with egress gone. it, so the highlighted row is the page being read. - [ ] A link that points off the bundle (an `https://` one in a doc) is refused in the pane and reported with a Copy link button — it must not navigate. +- [ ] A link to a doc that is **not** bundled — the ADR references in Postures, + say — reports a `https://github.com/…` URL, not a `file:///…/Contents/…` + path. Every such link was a dead click reporting an internal path before + the renderer rewrote them. +- [ ] Pages are **styled** — headings, table borders, code backgrounds, and the + dark-mode palette. The bundled pages carry a `Content-Security-Policy`, and + a `file:` origin is opaque, so a CSP that is too strict would silently drop + `help.css` and leave the pane readable but unstyled. Only a real WKWebView + shows this; the Go tests cannot. - [ ] Built from a checkout whose `docs/` was renamed under it, `task gui:build` **fails** rather than producing an app whose Help pane is missing a page. - [ ] The **?** beside a Settings field opens Help scrolled to that key's own diff --git a/internal/help/bundle.go b/internal/help/bundle.go index f88eae0..4c34bdb 100644 --- a/internal/help/bundle.go +++ b/internal/help/bundle.go @@ -44,7 +44,7 @@ func Build(docsDir, outDir string) ([]IndexEntry, error) { "moving or renaming a doc means updating the manifest)", page.Source, err) } - rendered := Render(string(data)) + rendered := Render(page.Source, string(data)) if len(rendered.Unsupported) > 0 { return nil, fmt.Errorf("%s uses markdown the help renderer cannot show: %s", page.Source, strings.Join(rendered.Unsupported, ", ")) @@ -82,6 +82,22 @@ func document(page Page, r Rendered) string { var b strings.Builder b.WriteString("\n\n\n") b.WriteString("\n") + // The pane's navigation delegate only sees NAVIGATIONS, so it is not what + // would stop an or an @import from reaching the network. This is: the + // page itself declares that nothing but its own stylesheet may load. Three + // layers now say the same thing — the renderer refuses a remote src, the + // bundle test greps for one, and the page cannot execute it either — because + // the guarantee ("it never touches the network") is the reason this feature + // exists at all, and the pane is opened when there is no network to check. + // + // `file:` is named alongside 'self' deliberately. A page loaded from a file: + // URL has an opaque origin, so whether 'self' matches its own sibling + // stylesheet is a WebKit implementation detail — and a CSP that quietly + // blocked help.css would ship an unstyled help pane, which is a worse + // regression than the one this prevents. Naming the scheme keeps the part + // that matters (no http, no https, nothing off-disk) independent of that. + b.WriteString("\n") b.WriteString("" + html.EscapeString(page.Title) + "\n") b.WriteString("\n") b.WriteString("\n\n") diff --git a/internal/help/help_test.go b/internal/help/help_test.go index f955cae..32301f4 100644 --- a/internal/help/help_test.go +++ b/internal/help/help_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "strings" "testing" @@ -138,7 +139,7 @@ func TestRenderCoversTheSubset(t *testing.T) { "| Field | Default |\n|---|---|\n| `a` | `1s` |\n\n" + "```sh\necho hi\n```\n\n" + "---\n" - r := Render(md) + r := Render("usage/config.md", md) if len(r.Unsupported) != 0 { t.Fatalf("the subset renderer reported %v on its own subset", r.Unsupported) @@ -162,7 +163,7 @@ func TestRenderCoversTheSubset(t *testing.T) { // Content inside a fence is verbatim: a shell example full of pipes and dashes // must not be read as a table, and markup in it must not be interpreted. func TestFencedCodeIsVerbatim(t *testing.T) { - r := Render("```sh\n| a | b |\n|---|---|\n**not bold**\n```\n") + r := Render("", "```sh\n| a | b |\n|---|---|\n**not bold**\n```\n") if strings.Contains(r.HTML, "
                " + inline(c) + "" + renderInline(c) + "
                " + inline(c) + "" + renderInline(c) + "
                ") { t.Error("a table inside a code fence was rendered as a table") } @@ -186,7 +187,7 @@ func TestEveryRealPageRendersCleanly(t *testing.T) { if err != nil { t.Fatalf("%s: %v", page.Source, err) } - r := Render(string(data)) + r := Render(page.Source, string(data)) if len(r.Unsupported) != 0 { t.Errorf("%s: renderer reported %v", page.Source, r.Unsupported) } @@ -224,6 +225,50 @@ func stripFences(html string) string { } } +// TestEveryLinkGoesSomewhere — no bundled page may contain a link that resolves +// to nothing. +// +// Thirty did. The docs cross-reference the ADRs and the contributor docs +// constantly, and rewriteLink used to return an unmatched relative link +// unchanged, so "../adr/0008-arm-at-boot.md" shipped verbatim and resolved +// beside the bundle, where no such file exists. Clicking one produced "That link +// points outside the app: file:///…/Contents/Resources/adr/0008-arm-at-boot.md" +// — an internal path, on a Copy button that copied it. Every page had them, and +// the only existing link assertion covered the case that DID resolve. +// +// The rule is checked against the built bundle rather than the renderer, because +// "resolves to nothing" is a fact about the directory, not about the markdown. +func TestEveryLinkGoesSomewhere(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + href := regexp.MustCompile(`href="([^"]*)"`) + for _, page := range Pages { + name := OutputName(page.Source) + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + for _, m := range href.FindAllStringSubmatch(string(data), -1) { + link := m[1] + switch { + case strings.HasPrefix(link, "#"), link == "help.css": + continue + case strings.HasPrefix(link, "https://"), strings.HasPrefix(link, "http://"), + strings.HasPrefix(link, "mailto:"): + // Off-bundle by design: the pane reports it and offers the URL. + continue + } + file, _, _ := strings.Cut(link, "#") + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + t.Errorf("%s links to %q, which is not in the bundle — it would resolve "+ + "to a file that does not exist", page.Source, link) + } + } + } +} + // TestRenderReportsWhatItCannotShow pins the reporting itself. Each of these // rendered silently — and wrongly — before, which is the failure mode the whole // bundled-docs design depends on not having. @@ -236,13 +281,13 @@ func TestRenderReportsWhatItCannotShow(t *testing.T) { "unclosed code fence": "```sh\necho hi\n", } for name, md := range cases { - if r := Render(md); len(r.Unsupported) == 0 { + if r := Render("usage/config.md", md); len(r.Unsupported) == 0 { t.Errorf("%s was rendered without being reported:\n%s", name, r.HTML) } } // A refused scheme must also not reach the href, so the bundle cannot carry // the link even if someone ships past the build failure. - if r := Render("[trap](javascript:alert(1))\n"); strings.Contains(r.HTML, "javascript:") { + if r := Render("usage/config.md", "[trap](javascript:alert(1))\n"); strings.Contains(r.HTML, "javascript:") { t.Errorf("a javascript: href survived into the rendered page:\n%s", r.HTML) } } @@ -251,7 +296,7 @@ func TestRenderReportsWhatItCannotShow(t *testing.T) { // emphasis routinely straddles a line break. Rendering line by line left the ** // as literal text in eight of the nine bundled pages. func TestInlineMarkupSpansSoftLineBreaks(t *testing.T) { - r := Render("The guard **fail-closes\nand stays closed** afterwards.\n") + r := Render("", "The guard **fail-closes\nand stays closed** afterwards.\n") if !strings.Contains(r.HTML, "fail-closes and stays closed") { t.Errorf("bold did not survive a soft line break:\n%s", r.HTML) } @@ -263,7 +308,7 @@ func TestInlineMarkupSpansSoftLineBreaks(t *testing.T) { // A list item's continuation belongs INSIDE its
              • . Emitting it afterwards put // loose text directly inside the
                  , which is invalid. func TestListItemContinuationStaysInsideTheItem(t *testing.T) { - r := Render("- first line\n second line\n- next item\n") + r := Render("", "- first line\n second line\n- next item\n") if !strings.Contains(r.HTML, "
                • first line second line
                • ") { t.Errorf("continuation leaked out of its list item:\n%s", r.HTML) } @@ -272,7 +317,7 @@ func TestListItemContinuationStaysInsideTheItem(t *testing.T) { // Emphasis must not reach inside a code span. `vpn.advanced.*` used to pair its // glob with a later asterisk and open an that closed outside the . func TestCodeSpansAreNotTouchedByEmphasis(t *testing.T) { - r := Render("Every `vpn.advanced.*` key, **\"Use Touch ID\"** and more.\n") + r := Render("", "Every `vpn.advanced.*` key, **\"Use Touch ID\"** and more.\n") if !strings.Contains(r.HTML, "vpn.advanced.*") { t.Errorf("a code span was rewritten by the emphasis passes:\n%s", r.HTML) } @@ -283,7 +328,7 @@ func TestCodeSpansAreNotTouchedByEmphasis(t *testing.T) { // Bold containing italics is written throughout the docs. func TestItalicsInsideBold(t *testing.T) { - r := Render("**Endpoints are deliberately *not* required.**\n") + r := Render("", "**Endpoints are deliberately *not* required.**\n") if !strings.Contains(r.HTML, "") || !strings.Contains(r.HTML, "not") { t.Errorf("nested emphasis was not rendered:\n%s", r.HTML) } diff --git a/internal/help/markdown.go b/internal/help/markdown.go index edcfb70..db571d9 100644 --- a/internal/help/markdown.go +++ b/internal/help/markdown.go @@ -3,6 +3,7 @@ package help import ( "fmt" "html" + "path" "regexp" "sort" "strconv" @@ -75,8 +76,28 @@ var ( // contain the link in the first place. var linkSchemes = map[string]bool{"http": true, "https": true, "mailto": true} +// repoBase is where a document that is NOT bundled lives instead. The docs +// cross-reference the ADRs and the contributor docs constantly, and those +// deliberately do not ship (see Pages) — so their links have to become +// somewhere real. Left as written, they resolve to a file: path beside the +// bundle that does not exist, and the pane reports "points outside the app" +// with an internal path nobody can use. +// +// Pinned to main rather than the built tag on purpose: this is the only kind of +// link the pane cannot follow, so it is a URL a reader copies into a browser +// later, and a tag that has since been deleted or rewritten reads as a broken +// project. The bundled pages themselves are always version-matched, which is +// what the offline guarantee is actually about. +const repoBase = "https://github.com/behnam-rk/dezhban/blob/main/" + // Render converts one markdown document to HTML. -func Render(markdown string) Rendered { +// +// source is the page's docs-relative path ("usage/cli.md"), which is what +// relative links in it are resolved against — "../adr/0001.md" means something +// different depending on the page it is written in. Empty source is for +// rendering a fragment with no place in the manifest (tests): relative links +// are then reported as unresolvable rather than guessed at. +func Render(source, markdown string) Rendered { var out strings.Builder var text strings.Builder r := Rendered{} @@ -90,7 +111,7 @@ func Render(markdown string) Rendered { // renderInline is the only way inline markup reaches the output, so every // construct it cannot represent is reported from one place. renderInline := func(s string) string { - rendered := inline(s, note) + rendered := inline(source, s, note) if strings.Contains(rendered, "**") { note("unpaired ** (bold that never closed)") } @@ -393,7 +414,7 @@ func codePlaceholder(i int) string { return codeMark + strconv.Itoa(i) + codeMar // inline renders inline markup. Escaping happens FIRST and the markup is // substituted into the escaped text, so no document content can inject HTML — // the pages are ours, but the rule costs nothing and removes the question. -func inline(s string, note func(string)) string { +func inline(source, s string, note func(string)) string { out := html.EscapeString(s) // Code spans are LIFTED OUT before anything else runs, then put back last. @@ -409,10 +430,22 @@ func inline(s string, note func(string)) string { return codePlaceholder(len(spans) - 1) }) - out = inlineImage.ReplaceAllString(out, `$1`) + // An image is checked, not rewritten. It has no banner to fall back on the + // way a link does — the navigation delegate never sees a subresource load — + // so a remote src would be a silent network request from the one pane whose + // entire purpose is working with egress cut. Refused here rather than left + // to the bundle's self-contained test, so Render is safe on its own terms. + out = inlineImage.ReplaceAllStringFunc(out, func(m string) string { + g := inlineImage.FindStringSubmatch(m) + if schemeRe.MatchString(g[2]) || strings.HasPrefix(g[2], "//") { + note("a remote image (" + g[2] + ") — the pane must load nothing from the network") + return g[1] + } + return fmt.Sprintf("%q", g[2], g[1]) + }) out = inlineLink.ReplaceAllStringFunc(out, func(m string) string { g := inlineLink.FindStringSubmatch(m) - return fmt.Sprintf("%s", rewriteLink(g[2], note), g[1]) + return fmt.Sprintf("%s", rewriteLink(source, g[2], note), g[1]) }) out = inlineBold.ReplaceAllString(out, "$1") out = inlineItalic.ReplaceAllString(out, "$1$2") @@ -432,7 +465,7 @@ func inline(s string, note func(string)) string { // for the app's navigation delegate to cancel. Defence in depth is the reason // the delegate exists, but a javascript: or data: href has no business being in // the bundle at all, and a build failure is how it gets removed. -func rewriteLink(href string, note func(string)) string { +func rewriteLink(source, href string, note func(string)) string { if scheme := schemeRe.FindString(href); scheme != "" { name := strings.ToLower(strings.TrimSuffix(scheme, ":")) if !linkSchemes[name] { @@ -444,22 +477,36 @@ func rewriteLink(href string, note func(string)) string { if strings.HasPrefix(href, "#") { return href } - path, frag, _ := strings.Cut(href, "#") - // Links are written relative to the page they live in ("../adr/0001.md", - // "config.md"); resolve to the docs-relative form the manifest uses. - path = strings.TrimPrefix(path, "./") - for strings.HasPrefix(path, "../") { - path = strings.TrimPrefix(path, "../") + target, frag, _ := strings.Cut(href, "#") + if target == "" { + return href + } + if source == "" { + note("a relative link (" + href + ") in a page with no place in the manifest") + return href } - for _, p := range Pages { - if p.Source == path || strings.HasSuffix(p.Source, "/"+path) { + // Links are written relative to the page they live in, so resolve against + // that page's own directory: "../adr/0001.md" from usage/config.md is a + // different file than the same text in concepts/modes.md. path.Join cleans + // the "../" segments, and the repo root is the frame of reference because + // the docs also link outside docs/ ("../../configs/dezhban.example.json"). + repoPath := path.Join("docs", path.Dir(source), target) + + if inDocs, ok := strings.CutPrefix(repoPath, "docs/"); ok { + if _, bundled := PageBySource(inDocs); bundled { if frag != "" { - return OutputName(p.Source) + "#" + frag + return OutputName(inDocs) + "#" + frag } - return OutputName(p.Source) + return OutputName(inDocs) } } - return href + // Not bundled. Point at the repository rather than leaving a path that + // resolves to nothing beside the bundle — see repoBase. The pane still + // cannot follow it, but it reports a URL the reader can actually use. + if frag != "" { + return repoBase + repoPath + "#" + frag + } + return repoBase + repoPath } // stripInline reduces a line to its words, for the search index. From 57de0d31f411ace783774f23e8513be32f3bd5d9 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 14:54:06 +0330 Subject: [PATCH 24/27] fix(state): drop a field no surface could render truthfully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DropRecord carried a Cut flag meaning "the guard was enforcing at that moment, so traffic really was cut". It was written by the runner, decoded by the app, and asserted on both sides — and read by nothing. That is not an oversight, it is the field resisting being used. Try to write the sentence it enables and there isn't one: "cut since 3:04PM" is false whenever a redial window opened and expired in between, which is the common path, and "not cut" is false the instant the window closes. Its only truthful reading is "cut at that microsecond", which no reader needs and no posture line can say. What a reader wants from a drop is WHEN; the posture fields already say what is happening now. It was also computed wrong. Cut: !standby claimed traffic was cut for a drop that happened inside an open switch window — which is precisely what a switch window is for, disconnecting one VPN to reach another. A correct !standby && !windowActive would have been a right answer to a question with no asker. The Swift test that pinned "a standby drop is not a cut" is replaced by one pinning that an unknown key does not fail the decode. The daemon and the app ship together but are not restarted together, so a running daemon can still be emitting `cut` after the app updates, and refusing the whole snapshot over a field this build no longer knows would blank the menubar until a restart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 2 +- docs/contribute/architecture.md | 3 +-- gui/macos/Sources/DezhbanCore/Snapshot.swift | 10 +++++----- .../DezhbanCoreTests/SnapshotTests.swift | 19 +++++++++++-------- internal/render/render_test.go | 6 +++--- internal/runner/runner.go | 8 +++----- internal/runner/runner_test.go | 4 ++-- internal/state/state.go | 14 +++++++------- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a2801..17e5802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ current as you land changes. rendered strings. - **The state file records when your VPN dropped.** `status --json` gains a - `drop` object (`at`, `cut`) present from a tunnel drop until a tunnel is up + `drop` object (`at`) present from a tunnel drop until a tunnel is up again. Until now the moment the guard cut traffic was unobservable on the common path: the automatic redial window opens on the same edge, so the cut snapshot was replaced within microseconds while observers read the file about diff --git a/docs/contribute/architecture.md b/docs/contribute/architecture.md index 6d6d680..20b263f 100644 --- a/docs/contribute/architecture.md +++ b/docs/contribute/architecture.md @@ -74,8 +74,7 @@ something to describe — absent in STANDBY, before any tunnel is known. "trigger": "manual" // "manual" (operator command) | "auto" (redial window on a tunnel drop); absent from older daemons — treat as "manual" }, "drop": { // (vpn) present from a tunnel drop until a tunnel is up again - "at": "2026-07-01T12:01:30Z", - "cut": true // the guard was enforcing at that moment, so traffic really was cut + "at": "2026-07-01T12:01:30Z" // when — the posture fields say what is happening now }, "hold": { // (vpn) present only while "hold the line" is armed "armed": true, // the NEXT tunnel drop stays cut — no automatic redial window diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index dba2c6a..32a1ee6 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -25,15 +25,15 @@ public struct SwitchState: Codable { public var isPause: Bool { trigger == "pause" } } -/// The moment a healthy tunnel went down and the guard cut traffic — mirrors -/// Go's `state.DropRecord`. Carried from the drop until a tunnel is up again, +/// The moment a healthy tunnel went down — mirrors Go's `state.DropRecord`. +/// Carried from the drop until a tunnel is up again, /// including across the redial window that follows, which is the only reason any /// surface can report the drop at all. +/// +/// It carries the moment and nothing else — see Go's DropRecord for why a "was +/// traffic cut" companion flag could not be rendered truthfully. public struct DropRecord: Codable { public let at: Date - /// The guard was enforcing at that moment, so traffic really was cut. False - /// for a drop in standby, where nothing was being enforced. - public let cut: Bool } /// "Hold the line" is armed — mirrors Go's `state.HoldState`. The next tunnel diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index 2bef64d..52ea9a8 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -72,11 +72,11 @@ struct SnapshotTests { { "time": "2026-07-25T10:00:05Z", "posture": "switch-window", "blocked": false, "switch": { "open": true, "until": "2026-07-25T10:00:35Z", "trigger": "auto" }, - "drop": { "at": "2026-07-25T10:00:00Z", "cut": true } + "drop": { "at": "2026-07-25T10:00:00Z" } } """.data(using: .utf8)! let s = try! #require(StateReader.decode(json)) - #expect(s.drop?.cut == true) + #expect(s.drop != nil) #expect(s.switch?.isAutoRedial == true) // The drop happened before the window it opened, and the two timestamps // must not be confused for one another. @@ -86,15 +86,18 @@ struct SnapshotTests { #expect(dropAt < s.time) } - /// A drop in standby is not a cut — nothing was being enforced — and the app - /// must not claim otherwise. - @Test func aStandbyDropIsNotACut() { + /// A key the app no longer knows about must not fail the decode. The daemon + /// and the app ship in one package but are not guaranteed to be restarted + /// together, so a running daemon can still be emitting a field this build + /// has dropped — `drop.cut` is the first one that happened to. Refusing the + /// whole snapshot over it would blank the menubar until the next restart. + @Test func anUnknownFieldOnTheDropRecordIsIgnored() { let json = """ - { "time": "2026-07-25T10:00:05Z", "posture": "standby", "blocked": false, - "drop": { "at": "2026-07-25T10:00:00Z", "cut": false } } + { "time": "2026-07-25T10:00:05Z", "posture": "guard", "blocked": true, + "drop": { "at": "2026-07-25T10:00:00Z", "cut": true } } """.data(using: .utf8)! let s = try! #require(StateReader.decode(json)) - #expect(s.drop?.cut == false) + #expect(s.drop?.at != nil) } @Test func decodesHoldTheLineArmed() { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 3372f6d..9392c98 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -64,7 +64,7 @@ func TestText(t *testing.T) { snap: state.Snapshot{ Posture: PostureGuard, Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, - Drop: &state.DropRecord{At: until, Cut: true}, + Drop: &state.DropRecord{At: until}, }, wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", @@ -80,7 +80,7 @@ func TestText(t *testing.T) { Posture: PostureGuard, Time: until.AddDate(0, 0, 2), Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, - Drop: &state.DropRecord{At: until, Cut: true}, + Drop: &state.DropRecord{At: until}, }, wantKey: KeyBlocked, wantHeadline: "VPN down — traffic cut", @@ -126,7 +126,7 @@ func TestText(t *testing.T) { snap: state.Snapshot{ Posture: PostureSwitchWindow, Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerAuto}, - Drop: &state.DropRecord{At: dropAt, Cut: true}, + Drop: &state.DropRecord{At: dropAt}, }, wantKey: KeyWarning, wantHeadline: "Redial window open", diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2c5c19a..812d875 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1680,13 +1680,11 @@ func (o Options) runGuard(ctx context.Context) error { tryAutoArm(st.Detail) } if wasUp && !st.Up { - // Record the cut, and publish it, BEFORE anything relaxes. + // Record the drop, and publish it, BEFORE anything relaxes. // maybeAutoWindow may open a redial window on this very line, // which would otherwise make the guard-holding-a-downed-tunnel - // state unreachable on the common path. Cut is false in standby - // because nothing was being enforced, and saying traffic was cut - // would be a lie. - lastDrop = &state.DropRecord{At: time.Now(), Cut: !standby} + // state unreachable on the common path. + lastDrop = &state.DropRecord{At: time.Now()} snapshot() maybeAutoWindow(time.Now(), st.Detail) } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 701053e..a66ab88 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1120,8 +1120,8 @@ func TestTunnelDropPublishesTheCutBeforeRelaxing(t *testing.T) { if got := snaps[firstDrop].Posture; got != "guard" { t.Errorf("the cut snapshot's posture = %q, want \"guard\" (the guard holding a downed tunnel)", got) } - if !snaps[firstDrop].Drop.Cut { - t.Error("the cut snapshot reports Cut=false, but the guard was enforcing when the tunnel dropped") + if snaps[firstDrop].Drop.At.IsZero() { + t.Error("the drop record carries no time, which is the one thing it exists to report") } // Carried through the window, or the surface that users actually look at diff --git a/internal/state/state.go b/internal/state/state.go index cb2175c..6d70c73 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -148,8 +148,8 @@ type SwitchState struct { Trigger string `json:"trigger,omitempty"` } -// DropRecord is the moment a healthy tunnel went down and the guard cut traffic, -// carried forward until a tunnel is up again. +// DropRecord is the moment a healthy tunnel went down, carried forward until a +// tunnel is up again. // // It exists because the cut is otherwise unobservable on the common path. The // run loop opens the automatic redial window on the same tunnel-down edge, so a @@ -161,14 +161,14 @@ type SwitchState struct { // Carrying it does NOT make the window look like a cut. An open window is a // relaxation: traffic is flowing and the real IP may be exposed, so it stays // amber. The record says what happened, not what is happening. +// It carries the moment and nothing else. A companion "was traffic cut at that +// instant" flag was tried and removed: no surface could truthfully render it, +// because a window may have opened and expired in between, so neither "cut +// since" nor "not cut" describes what followed. What a reader needs from a drop +// is WHEN — the posture fields already say what is happening now. type DropRecord struct { // At is when the tunnel was observed down. At time.Time `json:"at"` - // Cut reports that the guard was actually enforcing at that moment, so - // traffic really was cut before anything relaxed. False when the drop - // happened in a posture that was not cutting (standby), where saying - // "traffic was cut" would be a lie. - Cut bool `json:"cut"` } // HoldState reports that "hold the line" is armed: the next tunnel drop will From aca3293683be17880a8cab0430a74b0fa164f3a3 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 14:54:29 +0330 Subject: [PATCH 25/27] fix(runner): account for a refused hold, and say how to lift a pause cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arming "hold the line" through the root-owned command file was a silent no-op when vpn.redialWindow was "0": nothing to suppress, nothing logged, no reply channel to refuse over. `dezhban hold` checks the config before writing — but skips that check when the file cannot be read, and then prints "hold the line armed" for something the daemon discards without a word. That is the same gap the pause path had until the commit before last, and the rule that fixed it applies unchanged: a channel with no reply owes the operator a log line, because the log is the only account they get. The socket path already refused this by name. The pause refusal itself now names the command that raises the cap, matching what config.PauseRefusal tells a CLI user before the request is ever sent. The two stay two sentences rather than one helper — internal/runner takes an Options and never a *config.Config, and that boundary is worth more than the duplication — so both ends now say so and point at each other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- CHANGELOG.md | 11 +++++++++++ internal/config/preset.go | 5 +++++ internal/runner/runner.go | 24 +++++++++++++++++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e5802..9de7363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -218,6 +218,17 @@ current as you land changes. a browser; a link to a page that *is* bundled still opens it in place. The pane still reaches the network for nothing. +- **`dezhban hold` no longer reports success for a hold the daemon discards.** + Arming through the root-owned command file was a silent no-op when + `vpn.redialWindow` was `"0"` — nothing to suppress, nothing logged. The CLI + checks the config first, but skips that check when the file cannot be read, and + then printed "hold the line armed". The daemon now says why it ignored the + command, the same way the pause path does. + +- **An over-cap pause refusal now says how to fix it.** The daemon's log named the + cap but not the command to raise it, so the CLI and the daemon explained the + same refusal differently. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/internal/config/preset.go b/internal/config/preset.go index 6d3b26e..76b28bd 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -315,6 +315,11 @@ func PauseOptions(c *Config) []PauseOption { // an over-cap request to the cap, which is the same class of bug as accepting a // disabled window and restoring the default: the user asked for one thing, got // another, and was not told. +// +// runner.pauseDuration says the same things to a client that got past this one +// (a raw socket call, or a stale command file). Kept as two sentences rather +// than one helper because internal/runner takes an Options and never a +// *Config — change one, change the other. func PauseRefusal(c *Config, requested time.Duration) string { switch { case c.VPN.PauseMax <= 0: diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 812d875..edae441 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1786,7 +1786,18 @@ func (o Options) runGuard(ctx context.Context) error { closeWindowRevert("resumed") } case command.OpHoldArm: - if o.RedialWindow > 0 && !holdArmed { + // Same account the socket path gives, for the same reason as the + // pause refusal above: this path has no reply channel, so a + // command that changed nothing must still say so. `dezhban hold` + // checks the config first, but it skips that check when the file + // cannot be read — and then prints "armed" for something the + // daemon would otherwise discard in silence. + if o.RedialWindow <= 0 { + o.Log.Warn("ignoring hold command — the automatic redial window is already " + + "disabled (vpn.redialWindow: \"0\"), so there is nothing to hold") + continue + } + if !holdArmed { holdArmed = true holdArmedAt = time.Now() o.Log.Info("hold the line armed — the next tunnel drop will stay cut") @@ -2223,6 +2234,12 @@ const defaultPauseDuration = 15 * time.Minute // future caller that skipped the gate can never turn "disabled" into a real // relaxation of the guard, whatever value req parses to. // +// The refusals are worded to match config.PauseRefusal, which says the same +// things to a CLI user before the request is ever sent. They are two sentences +// rather than one shared helper on purpose: internal/runner takes an Options, +// never a *config.Config, and that boundary is worth more than the duplication. +// Change one, change the other. +// // An explicitly requested length longer than vpn.pauseMax is REFUSED, not // shortened. Quietly granting 30m to someone who asked for an hour is the same // class of bug as accepting a disabled window and restoring the default: the @@ -2245,8 +2262,9 @@ func (o Options) pauseDuration(req string) (time.Duration, string) { return 0, fmt.Sprintf("invalid pause duration %q", req) } if d > o.PauseMax { - return 0, fmt.Sprintf("%s is longer than the %s cap (vpn.pauseMax); "+ - "ask for %s or less, or raise the cap", d, o.PauseMax, o.PauseMax) + return 0, fmt.Sprintf("%s is longer than your %s cap (vpn.pauseMax). "+ + "Ask for %s or less, or raise the cap with `dezhban config set vpn.pauseMax=%s`.", + d, o.PauseMax, o.PauseMax, d) } return d, "" } From a5eca46af07d5bfa679709b9b35c58c4a74af946 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 15:09:13 +0330 Subject: [PATCH 26/27] fix(render): stop echoing the pause re-arm time twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pause's rendered sentence stated the same clock time twice in a row: "You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM." pausedSentence and rearmSentence were each handed `until` and each said so, which reads as an unintentional echo rather than emphasis — the two facts (using the real IP now, and when that ends) only need stating once between them. pausedSentence no longer takes or states the until time; rearmSentence, which already runs immediately after it at the only call site, is left to say it once. Updated the two tests that pinned the old wording. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- cmd/dezhban/vpn_cmd_test.go | 2 +- internal/render/render.go | 13 ++++++------- internal/render/render_test.go | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cmd/dezhban/vpn_cmd_test.go b/cmd/dezhban/vpn_cmd_test.go index c2d0f0c..049cf0c 100644 --- a/cmd/dezhban/vpn_cmd_test.go +++ b/cmd/dezhban/vpn_cmd_test.go @@ -59,7 +59,7 @@ func TestPrintSwitchStatus(t *testing.T) { Posture: "switch-window", Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerPause}, }, - want: "pause: OPEN — You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + + want: "pause: OPEN — You are using your real IP at your request. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + "until: 2026-07-25T15:04:00Z\n", }, } diff --git a/internal/render/render.go b/internal/render/render.go index a64ef55..2fa0904 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -234,7 +234,7 @@ func windowDisplay(s state.Snapshot) Display { return Display{ Key: KeyPaused, Headline: "Paused", - Detail: joinSentences(pausedSentence(until), rearmSentence(until)), + Detail: joinSentences(pausedSentence(), rearmSentence(until)), } case state.TriggerAuto: return Display{ @@ -261,12 +261,11 @@ func rearmSentence(until string) string { // pausedSentence leads with the fact that matters — the real IP is in use — and // says so as a present condition, not a risk: during a pause the exposure is the -// point, not a side effect. -func pausedSentence(until string) string { - if until == "" { - return "You are using your real IP at your request." - } - return "You are using your real IP at your request, until " + until + "." +// point, not a side effect. It never states the until time itself: rearmSentence +// states it immediately after, and stating it twice in adjacent sentences reads +// as an unintentional echo rather than emphasis. +func pausedSentence() string { + return "You are using your real IP at your request." } func exposedSentence(until string) string { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 9392c98..a3ba627 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -140,7 +140,7 @@ func TestText(t *testing.T) { }}, wantKey: KeyPaused, wantHeadline: "Paused", - wantDetail: "You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM.", + wantDetail: "You are using your real IP at your request. The guard re-arms automatically at 3:04PM.", }, { name: "switch window with no Switch struct falls back to manual wording", From b12ef5e007f5e72afd845660a07109e5cd7691ab Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Mon, 27 Jul 2026 15:09:30 +0330 Subject: [PATCH 27/27] fix(help): refuse a doc link that climbs above the repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rewriteLink resolves a bundled page's relative links against the repo root ("docs" + the page's own directory + the link target), which is correct for the ordinary case of a link into docs/ or a sibling like configs/. It did not bound how far a target's own "../" segments could climb: enough of them turn the resolved path into one that climbs past the repo root itself, which repoBase then turns into a URL pointing outside the project — the same class of thing a disallowed scheme (javascript:, data:) is already refused for by name, just reached through path segments instead of a scheme prefix. A path climbing that far isn't reachable from the docs as written today, but nothing enforced that it couldn't be, and the existing rule here is "refuse what cannot be represented safely, don't ship it and hope" — the same rule the scheme allowlist and the remote-image check already follow a few lines above. TestRelativeLinkCannotClimbAboveTheRepoRoot pins the refusal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s --- internal/help/help_test.go | 16 ++++++++++++++++ internal/help/markdown.go | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/help/help_test.go b/internal/help/help_test.go index 32301f4..3505944 100644 --- a/internal/help/help_test.go +++ b/internal/help/help_test.go @@ -292,6 +292,22 @@ func TestRenderReportsWhatItCannotShow(t *testing.T) { } } +// TestRelativeLinkCannotClimbAboveTheRepoRoot pins rewriteLink's bound on ".." +// segments: enough of them turn "docs//" into a path that climbs +// past the repo root, which repoBase would otherwise turn into a URL pointing +// outside the project (or nowhere). Refused the same way a disallowed scheme +// is, rather than shipped. +func TestRelativeLinkCannotClimbAboveTheRepoRoot(t *testing.T) { + md := "[trap](../../../../../../../etc/passwd)\n" + r := Render("usage/config.md", md) + if len(r.Unsupported) == 0 { + t.Fatalf("a link climbing above the repo root was rendered without being reported:\n%s", r.HTML) + } + if strings.Contains(r.HTML, "etc/passwd") { + t.Errorf("the climbing path survived into the rendered href:\n%s", r.HTML) + } +} + // TestInlineMarkupSpansSoftLineBreaks — these docs are hard-wrapped prose, so // emphasis routinely straddles a line break. Rendering line by line left the ** // as literal text in eight of the nine bundled pages. diff --git a/internal/help/markdown.go b/internal/help/markdown.go index db571d9..68a359b 100644 --- a/internal/help/markdown.go +++ b/internal/help/markdown.go @@ -492,6 +492,15 @@ func rewriteLink(source, href string, note func(string)) string { // the docs also link outside docs/ ("../../configs/dezhban.example.json"). repoPath := path.Join("docs", path.Dir(source), target) + // A link with enough "../" to climb past the repo root itself is refused + // rather than turned into a repoBase URL that climbs past it too — that + // would resolve to something outside the project, or nothing at all, + // which is the same class of bug a bad scheme is refused for above. + if strings.HasPrefix(repoPath, "../") { + note("a relative link (" + href + ") that climbs above the repository root") + return "#" + } + if inDocs, ok := strings.CutPrefix(repoPath, "docs/"); ok { if _, bundled := PageBySource(inDocs); bundled { if frag != "" {