Scaffold ipswap: tray-driven Windows IP preset switcher

Implements the design in claude.md as a building skeleton: pure Go, no cgo,
cross-compiles to a single Windows .exe from Linux.

Architecture follows the spec's deliberate split. The fast path is native —
tray icon, grouped submenus, a Win32 MessageBox showing a live before/after
diff, then netsh. The slow path is an embedded web editor served on a random
loopback port and opened in the default browser.

Two decisions worth recording:

Reads use GetAdaptersAddresses, writes use netsh. The spec left the
enumeration mechanism open; parsing `netsh show config` breaks on a
non-English Windows because the output is localised. DNS static-vs-DHCP
origin is not exposed by that API, so it comes from one registry read.

The netsh command plan is built in portable code. That puts the delete-every-
existing-address step — the one that stops secondary addresses leaking across
switches — under test without needing a Windows box.

The editor requires the session token in a header for mutations, not just the
cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie
attached, but it cannot set a header. The updater refuses to install a release
that publishes no SHA256.

Not yet done: no group picker for export (the API supports it), no
single-instance guard, and internal/server/web/app.css is reconstructed from
the description in claude.md rather than the canonical apointless.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
# ipswap — Windows IP preset switcher
## Purpose
A tray-resident Windows utility for rapidly switching a network adapter between saved
static-IP configurations. Built for field/support work where a laptop needs to hop
between customer subnets (broadcast control networks, media networks, management VLANs)
many times a day.
Working name `ipswap` — rename freely.
## Stack
- **Go**, single static `.exe`, no runtime dependency, no CGO.
- **Tray**: `fyne.io/systray` (pure-Go on Windows — keeps `GOOS=windows go build` cross-compiling from Linux CI without Wine).
- **Fast-path dialogs**: native Win32 `MessageBoxW` via `syscall.NewLazyDLL("user32.dll")`. No GUI toolkit.
- **Editor UI**: embedded static web app (`embed.FS`) served on `127.0.0.1:<random port>`, opened in the default browser.
- **No CGO anywhere.** If a dependency needs it, pick another dependency.
## Architecture
Two interaction paths, deliberately split:
**Fast path (native, no browser)**
Tray icon → menu of presets, grouped into submenus → click → confirmation MessageBox
showing a before/after diff → Yes → apply → tray tooltip updates to the active preset name.
**Slow path (browser)**
Tray → "Manage presets…" → spawns local HTTP server, opens default browser to
`http://127.0.0.1:<port>/?t=<session-token>`. Full CRUD on presets, adapter picker,
import/export, settings. Server shuts down when the browser session goes idle (no
heartbeat for 5 min) or on app exit.
Rationale: switching must be two clicks and zero browser. Editing 50 presets in a
MessageBox would be miserable.
### Web UI styling
Use the **apointless.css** design system: dark-first, JetBrains Mono + DM Sans, blue
accent `#3b82f6`, bg `#0b0d11`, surfaces `#12151b`/`#1a1e28`/`#232838`, subtle blue grid
via `body::before`, semantic colour tokens, light mode via `html.light` class +
localStorage. Use its existing components (cards, stat cards, badges, pills, buttons,
inputs, tables, alerts, code blocks, spinners) rather than inventing new ones.
## Data model
Stored at `%APPDATA%\ipswap\presets.json`. Settings at `%APPDATA%\ipswap\config.json`.
Log at `%APPDATA%\ipswap\ipswap.log` (rotating, keep last 5 × 1 MB).
```json
{
"version": 1,
"presets": [
{
"id": "01J8X...",
"name": "Artist frame — control",
"group": "Riedel",
"adapter": "Ethernet",
"mode": "static",
"primary": {
"address": "192.168.42.100",
"prefix": 24,
"gateway": "192.168.42.1",
"gateway_metric": 0
},
"secondary": [
{ "address": "10.0.10.50", "prefix": 24 }
],
"dns": {
"mode": "static",
"servers": ["192.168.42.1", "1.1.1.1"]
},
"notes": "Frame A, rack 3"
},
{
"id": "01J8Y...",
"name": "DHCP",
"group": "General",
"adapter": "Ethernet",
"mode": "dhcp",
"dns": { "mode": "dhcp" }
}
]
}
```
Notes:
- `adapter` is the Windows friendly name (`Ethernet`, `Wi-Fi`, `Ethernet 3`). Bound per
preset and editable in the preset editor. `net.Interfaces()` on Windows returns these
names and they match what `netsh` expects.
- Subnet mask stored internally as an integer prefix. The editor must **accept both**
`/24` and `255.255.255.0` on input and display whichever the user last used
(per-preset display preference is overkill — a single global setting is fine).
- `gateway` optional. `gateway_metric` 0 = automatic.
- Wi-Fi adapters are supported and treated identically. No special-casing.
## Applying a preset
Applies are **destructive**: after apply, the adapter has exactly the addresses in the
preset and nothing else.
`netsh interface ipv4 set address … static` replaces the primary but leaves previously
added secondary addresses in place, so a naive implementation leaks addresses across
switches. Sequence:
1. Enumerate current IPv4 addresses on the target adapter.
2. `netsh interface ipv4 delete address name="<adapter>" addr=<each existing>` for every
existing static address.
3. Set the primary:
`netsh interface ipv4 set address name="<adapter>" static <addr> <mask> <gateway> <gwmetric>`
4. Add each secondary:
`netsh interface ipv4 add address name="<adapter>" <addr> <mask>`
5. DNS static:
`netsh interface ipv4 set dnsservers name="<adapter>" static <first> primary validate=no`
then for each subsequent, `netsh interface ipv4 add dnsservers name="<adapter>" <addr> index=<n>`
6. DNS DHCP: `netsh interface ipv4 set dnsservers name="<adapter>" source=dhcp`
For `mode: "dhcp"`:
`netsh interface ipv4 set address name="<adapter>" source=dhcp`
(this also clears statics, so step 2 can be skipped).
Fallback if step 2 proves unreliable: set the adapter to DHCP first to flush statics,
then immediately apply the static config. Costs ~1s and a brief DHCP solicit — use only
if needed.
Run every `netsh` invocation **off the UI goroutine**. Applies take 1–3 s. Capture
stdout/stderr and exit code, log all of it, surface failures in a MessageBox with the
raw netsh output included.
### Confirmation prompt
Before applying, show a Win32 MessageBox (`MB_YESNO | MB_ICONQUESTION`) containing:
```
Apply preset "Artist frame — control" to adapter "Ethernet"?
CURRENT
192.168.1.87/24 (DHCP)
Gateway: 192.168.1.1
DNS: 192.168.1.1
NEW
192.168.42.100/24
+ 10.0.10.50/24
Gateway: 192.168.42.1
DNS: 192.168.42.1, 1.1.1.1
```
Read the current config live at prompt time, not from cache. A "don't ask again for this
session" checkbox is out of scope — the prompt is the safety net.
### Active preset detection
On startup, after any apply, and every 30 s, read each adapter's live config and mark any
preset that matches exactly. Show a check/radio mark next to it in the tray menu and set
the tray tooltip to `ipswap — <preset name>` (or `ipswap — unmatched`).
## Elevation
Manifest as `asInvoker`, **not** `requireAdministrator`. On a machine with UAC relaxed or
an already-elevated parent, this means zero prompts.
At startup, check whether the process token is actually elevated
(`windows.Token.IsElevated()` or `GetTokenInformation`/`TokenElevation`). If it is not:
- Do not fail, do not block startup.
- Add a tray menu item "Relaunch as administrator" that re-execs via `ShellExecuteW` with
the `runas` verb.
- If an apply fails with an elevation error, the failure MessageBox offers the same
relaunch action.
Being in the Administrators group is not the same as holding an elevated token — with UAC
on, the process gets a filtered token and `netsh set address` returns "The requested
operation requires elevation." This design costs nothing on a permissive machine and
degrades cleanly on a locked-down one.
## Tray menu layout
```
ipswap — Artist frame — control
─────────────────────────────
Riedel ▸ [submenu of presets in this group]
Herespace ▸
General ▸
─────────────────────────────
Manage presets…
Check for updates
─────────────────────────────
Relaunch as administrator [only shown if not elevated]
Exit
```
Presets with no `group` go into a top-level "Ungrouped" submenu. Design for ~50 presets:
submenus are mandatory, a flat list is not acceptable. No global hotkeys.
## Import / export
- Export: write the full `presets.json`, or a filtered subset by group, to a user-chosen
path. Include `version`.
- Import: merge or replace, user's choice. On merge, collide on `id` → keep both, suffix
the incoming name with `(imported)`. Validate the schema and reject with a clear error
rather than partially importing.
This is the mechanism for shipping preset packs to colleagues, so keep the file format
clean and hand-editable.
## Update checker
Gitea-hosted, same pattern as ShippingTracker.
- On startup (and on demand from the tray), GET
`https://<gitea-host>/api/v1/repos/<owner>/<repo>/releases/latest`.
- Compare the release tag against the compiled-in version using semver.
- If newer: tray menu gains "Update available — v1.2.0" as the top item.
- On click: download the `.exe` asset to `%TEMP%`, verify the SHA256 against a checksum
published in the release body or as a sibling asset, then write a small batch/helper
that waits for the parent to exit, swaps the binary, and relaunches.
- Never auto-install. Never block startup on the network call — run it in a goroutine
with a short timeout and fail silently to the log.
## Build & CI
- `GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui -X main.version=$VERSION"`.
`-H windowsgui` suppresses the console window.
- Embed an icon and a manifest (`asInvoker`, `dpiAware`) — `goversioninfo` or a `.syso`.
- Gitea Actions: build on tag, attach the `.exe` and a `SHA256SUMS` file to the release.
Pure Go means no Wine step is needed.
## Out of scope for v1
- Revert / restore-previous / timed auto-revert.
- Post-apply connectivity verification (ping/ARP). The confirmation diff is the check.
- Global hotkeys.
- IPv6.
- Adapter matching by MAC address (name only for now — worth revisiting if dock/USB-NIC
name drift becomes a problem).
- Per-preset scripts or hooks.
## Open questions for Ben
1. Gitea host/owner/repo for the update endpoint.
2. Whether the app should start with Windows (registry `Run` key, toggleable in settings)
— assumed **yes, off by default**.
3. Confirm the split UI (native tray + browser editor) is right. You picked "menu only"
for hotkeys but also wanted a confirmation prompt and 50 presets — those pull toward
needing a real editor window, hence the browser. Say if you'd rather have a native
window instead.
# Side note
Claude will be running in a tmux session