1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
|
#+TITLE: Waybar Network Module — Design Spec
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-06-29
* Goal
One waybar network component that does the whole job: shows connection state
(including the missing "associated but no internet / captive portal" state),
manages connections from a dropdown (nmcli-backed, optional GPG-encrypted secret
store), and runs the network diagnostics and remediation off the same place
(captive-portal detection + forcing, bounce/reset, gateway/DNS checks, speed
test).
It unifies three todo tasks that are really one feature:
- =[#C]= "archsetup Waybar Wi-Fi module should show no-internet state" — the
indicator state plus the 2026-06-22 roam expansion (bounce, diagnostics, speed
test off the component).
- =[#B]= "Network-manager dropdown, nmcli-backed with GPG-stored secrets" — the
management dropdown.
- The network diagnostics already shipped in =captive= (the hotel/captive-portal
tool, formerly =login-page=) become this module's diagnostics engine rather
than a standalone CLI.
* Scope
** In
- *Indicator* — wifi/ethernet icon + signal + SSID, plus an internet sub-state:
online / captive / no-internet / connecting / disconnected / airplane.
- *Interface-correct* — targets the wifi (or chosen) device, not the
default-route interface, so an active USB tether or wired link can't mask
wifi state. (Same lesson =captive= fixed; the current =custom/netspeed= keys
off the default route and has the bug.)
- *Connection management (panel)* — list saved connections most-recently-used
first, live signal for in-range wifi, click to switch; add / edit / remove;
ethernet↔wifi and wifi↔wifi switching even when a link appears mid-session.
- *Diagnostics (panel)* — captive probe (204-vs-portal) with the extracted
portal URL and an Open button; bounce/reset (fresh MAC); gateway ping; DNS
config + temporary 1.1.1.1 override test.
- *Speed test (panel)* — down/up/ping with a progress indicator and last-result
shown.
- *Credential store* — optional GPG-encrypted connection+secret file under
=~/.config=, opt-in (default unencrypted), passphrase cadence via gpg-agent
TTL. Supplements NetworkManager, does not replace it.
- *Persistence* — connectivity probe result cached in the runtime dir so the
bar reads it cheaply between probes.
** Out (v1, note for later)
- No replacement of NetworkManager's connection engine. NM stays the thing that
connects; we drive it via nmcli.
- No VPN / wireguard management (separate tooling already exists).
- No per-connection captive-portal auto-login automation beyond opening the
portal page.
- No graphing/history of speed-test results beyond the last run.
- The desktop-settings dropdown (sibling =[#B]=) is a separate module, but it
shares the GTK4 layer-shell panel shell built here.
* Architecture
Three layers. Keep the bar cheap, the panel rich, the logic in one tested place.
1. *Engine* — a =net= Python package (src-layout, pytest), exposing a CLI. Wraps
every nmcli op and owns the diagnostics. Emits JSON. This is the testable
core (fake =nmcli= / =curl= on PATH, like the existing =waybar-netspeed= and
=waybar-sysmon= test harnesses). Precedent: pocketbook is Python in the
dotfiles repo; =wtimer= is Python for the same testability reason.
2. *Indicator* — a thin =waybar-net= script that calls =net status --json= and
renders icon + signal + state + tooltip. Replaces =custom/netspeed=
(throughput folds into the tooltip).
3. *Panel* — a GTK4 + gtk4-layer-shell app (mirrors pocketbook's structure)
that imports the engine. Hosts connection management, diagnostics, and the
speed test.
How the existing pieces map in:
- =captive= (bash, shipped) — the engine shells out to it for the heavy,
interactive portal-force flow (sudo reset, DNS override, browser launch). Its
cheap portal-detection logic is mirrored natively in the engine for the fast
status path so the bar never blocks on a subprocess. =captive= stays a usable
standalone CLI.
- =waybar-netspeed= (sh, shipped) — retired; its throughput sampling moves into
the engine's status output and renders in the indicator tooltip only.
- =nmcli= — the connection backend for every op.
Language note: the engine is Python; the indicator is a thin Python or sh
wrapper over =net status --json=. The bar path must stay fast (sub-100ms for the
cheap poll), so the indicator does no network I/O itself — it reads link state
and the cached connectivity result.
* Connectivity model — split cadence
The indicator polls every ~2s, but a real internet/captive probe every 2s wastes
battery and can re-trigger a captive portal. So split it:
- *Fast path (every poll, cheap, no network)* — interface, type, SSID, signal,
IPv4 presence, throughput sample. From nmcli / sysfs only.
- *Slow path (cached, TTL ~45s)* — the actual internet/captive probe (the 204
check + meta-refresh portal extraction). Result cached at
=$XDG_RUNTIME_DIR/waybar/net-connectivity.json= with a timestamp.
The indicator reads the cache each poll. When the cache is older than the TTL,
=net status= kicks =net probe= in the background (non-blocking) and renders the
last cached sub-state meanwhile. A user-triggered diagnose/reconnect refreshes
the cache immediately. This keeps the bar responsive and the portal un-poked.
* Engine — =net= CLI surface
All subcommands take =--json= where a machine reads them. Pure formatting/state
functions under the CLI; IO (nmcli, curl, file) at the edges.
- =net status [--json] [--iface IF]= — fast link state + cached connectivity
sub-state + throughput. The indicator's source.
- =net probe [--iface IF]= — run the connectivity/captive probe now, update the
cache, print online | captive (+ portal URL) | no-internet. Mirrors =captive='s
cheap detection natively.
- =net list [--json]= — saved connections, MRU order, active flag, plus in-range
wifi with signal.
- =net up <id|ssid>= / =net down [--iface IF]= — switch / disconnect.
- =net add= / =net edit <id>= / =net remove <id>= — manage connections; sync the
GPG store (below).
- =net rescan [--iface IF]= — wifi rescan.
- =net diagnose [--json]= — full report: gateway ping, DNS config, DNS 1.1.1.1
override test, captive probe. Shells to =captive= for the interactive/sudo
parts; native for the read-only parts.
- =net portal= — run =captive='s portal-force flow (reset if needed, extract +
open the portal page).
- =net reset [--hardware-mac]= — fresh-MAC reconnect (=captive='s =fresh_mac=).
- =net speedtest [--json]= — librespeed run; down/up/ping.
* Indicator (task #C — Phase 1, the fast win)
** States (internet sub-state on top of link state)
- online — associated and the probe returned 204. Normal icon.
- captive — associated, probe hit a portal. Distinct glyph + warning CSS class;
tooltip names the portal host; left-click opens the panel's diagnostics with
the portal ready to open.
- no-internet — associated, probe failed (no portal, no 204). Distinct glyph +
warning class.
- connecting / disconnected / airplane / wired — as today, plus wired shown
correctly even when it appears after session start.
** Glyphs
Nerd-font codepoints, final values verified live before merge (same discipline
as wtimer). Reuse the signal-strength ramp already in =waybar-netspeed=; add a
captive/no-internet overlay glyph.
** Tooltip
SSID + signal + IPv4 + gateway + the throughput readout (absorbed from
netspeed) + the last probe result and age.
** Interactions (no keyboard-modifier clicks — waybar can't qualify clicks by
modifier; the panel hosts the rich actions)
- left-click — open the panel.
- right-click — quick reconnect / bounce (=net reset=, no panel).
- middle-click — run =net portal= (force the captive page).
- scroll — cycle nothing in v1 (reserved; could cycle saved connections later).
* Panel (tasks #B + #C diagnostics — Phases 2-3)
GTK4 + gtk4-layer-shell, pocketbook scaffold (src-layout package, pytest,
Makefile, gtk4-layer-shell anchored dropdown under the bar). One panel shell,
reused by the future desktop-settings panel.
Sections:
1. *Connections* — list, MRU-first, active marked, live signal bars for in-range
wifi; row click switches; buttons for add / edit / remove; a rescan control.
2. *Diagnostics* — buttons: Probe (204/captive, shows portal URL + Open),
Bounce/Reset, Gateway ping, DNS override test. Streaming output area.
3. *Speed test* — Run button, progress, down/up/ping result + last-run line.
Interaction-pattern catalog (=~/code/rulesets/patterns/=) principles that apply:
- transient-state-buttons — all the network levers in one place, reachable by
one chord (the bar click), state visible.
- default-most-common-friction-proportional — connections MRU-ordered so the
common pick is first; destructive ops (remove) get a confirm, switching does
not.
- one-prompt-picker-typed-prefix — if the connection picker ever goes
keyboard-driven, kind (wifi/eth/saved/in-range) + name in one typed picker.
* Connection management (nmcli)
- Every op via nmcli: =device status=, =connection show=, =con up/down=,
=con add/modify/delete=, =dev wifi rescan/list=.
- MRU ordering from NM's =connection.timestamp= (last activated), descending.
- Ethernet appears in the list whenever a wired device is present, selectable at
any time; switching just brings the chosen connection up.
- TDD with a fake =nmcli= on PATH returning canned output, asserting the exact
nmcli command sequence (behavior, not implementation) — the established
pattern in =tests/waybar-netspeed= and =tests/layout-navigate=.
* Credential storage (GPG)
- Store: =~/.config/net/connections.<ext>= — connection definitions + secrets
(PSK/EAP), one record per connection, with =last_used=.
- *Default unencrypted* (=connections.json=). Encryption is opt-in: when enabled,
=connections.json.gpg= encrypted to Craig's private key (=c@cjennings.net=).
- Passphrase cadence via gpg-agent: once per session (long cache TTL or
decrypt-and-hold), once per hour (=default-cache-ttl=), or never (plaintext).
Configured in =~/.config/net/config=.
- *Supplements NM, does not replace it.* NM's own store
(=/etc/NetworkManager/system-connections=, root-only) stays the source of
truth that actually connects. The GPG store is a portable, user-owned export +
secret vault. =net add/edit/remove= writes both (nmcli + store). =net import=
rebuilds NM connections from the store on a fresh machine. They sync on every
mutating op; NM wins on conflict for the live connection.
* Diagnostics + speed test
- Diagnostics reuse =captive= verbatim for the interactive flow (=net portal=,
=net reset=, the 1.1.1.1 DNS override test) and mirror its cheap probe natively
for =net status= / =net probe=. No logic is duplicated by hand beyond the small
portal-URL parser, which is already unit-tested in =tests/captive=.
- Speed test: *librespeed-cli* (no account, self-hostable, AUR), chosen over
Ookla speedtest-cli. =net speedtest --json= parses its JSON; the panel shows a
progress indicator and the down/up/ping result.
* Waybar wiring
- Replace =custom/netspeed= with =custom/net= in the bar's module list (same
slot).
- Module def: =exec: waybar-net=, =return-type: json=, =interval: 2=, a =signal=
for on-demand refresh (next free signal after wtimer's 14), =on-click: <open
panel>=, =on-click-right: net reset=, =on-click-middle: net portal=.
- Remove the old =on-click: pypr toggle network= scratchpad once the panel
replaces it.
* Testing plan (TDD)
- *Engine* — fake =nmcli= + fake =curl= on PATH; assert command sequences and
parsed/emitted JSON for status, list, up/down, add/edit/remove, probe,
diagnose, speedtest. Pure state/format functions tested directly.
- *Portal parser* — already covered in =tests/captive= (Normal/Boundary/Error +
the real SONIFI body). The engine's native probe reuses the same cases.
- *Indicator* — drive =net status --json= through =waybar-net=, assert the JSON
the bar renders for each state (online / captive / no-internet / wired /
disconnected), interface override via env like =WAYBAR_NETSPEED_IFACE=.
- *Panel* — pocketbook-style: test the backing logic (list ordering, op
dispatch, store read/write, gpg round-trip with a test key), not the GTK
widgets.
- *GPG store* — round-trip encrypt/decrypt with a throwaway test key; sync
on add/edit/remove; import rebuilds NM ops (asserted against fake nmcli).
* Files touched (planned)
- =hyprland/.local/bin/waybar-net= — the indicator (replaces =waybar-netspeed=).
- =hyprland/.local/bin/net= — engine CLI entry (or a package console-script).
- =net/= package (src-layout, like pocketbook) — engine + panel, in the dotfiles
repo (or in-tree as pocketbook is).
- =hyprland/.config/waybar/config= — swap =custom/netspeed= → =custom/net=.
- =hyprland/.config/waybar/style.css= — captive / no-internet state classes.
- =tests/net/=, =tests/waybar-net/= — suites.
- =~/.config/net/= — config + connection store (machine-local; not stowed
content beyond a seed config).
- =captive= — minor refactor so the engine can reuse its probe cleanly.
* Resolved decisions (this session, Craig's calls)
1. Panel UI tech → GTK4 + gtk4-layer-shell, shared pocketbook scaffold (one
panel shell, reused by the desktop-settings sibling).
2. Engine language → Python =net= package; shells out to =captive= for the
portal-force flow, native cheap probe for the bar path.
3. Connectivity probe → split cadence (fast link poll every 2s + slow cached
internet/captive probe, TTL ~45s).
4. No keyboard-modifier clicks (waybar can't qualify them) — the panel hosts the
rich actions; bar clicks are left=panel, right=reset, middle=portal.
5. GPG store supplements NM; NM stays the source of truth.
6. =custom/netspeed= absorbed into =custom/net=; throughput moves to the tooltip.
7. Speed-test backend → librespeed-cli.
* Phasing
- *Phase 1 — Indicator (task #C).* =net status= + =net probe= (native cheap
probe, reusing captive's logic) + =waybar-net= + the split-cadence cache + CSS
states. Ships the no-internet/captive state on the bar. Smallest, highest
value, fully testable without the GTK panel.
- *Phase 2 — Panel shell + connection management (task #B core).* GTK4
layer-shell scaffold + =net list/up/down/add/edit/remove/rescan= + MRU list.
- *Phase 3 — Diagnostics + speed test in the panel.* Wire =net diagnose= /
=net portal= / =net reset= / =net speedtest= into the panel; portal Open
button.
- *Phase 4 — GPG credential store.* Opt-in encryption, cadence config, NM sync,
import.
* Open items / risks
- gtk4-layer-shell dropdown anchoring under a waybar module needs the same
positioning work pocketbook solved; reuse it.
- librespeed-cli availability + a default server choice (public list vs a pinned
server) to confirm before Phase 3.
- The background-probe kick from =net status= must be truly non-blocking (spawn +
detach) so a slow/hanging probe never stalls the bar.
- NM↔GPG-store conflict policy on edit needs a concrete rule (NM wins for the
live connection) — confirm during Phase 4.
* Rollback
Each phase is independent. The indicator (Phase 1) is a drop-in replacement for
=custom/netspeed=; reverting is swapping the module back in the config. The panel
and store are additive — not wiring their clicks / not enabling encryption leaves
the bar working as before.
|