Files
bsncubed 23dcfb393f 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>
2026-08-20 13:29:35 +10:00

253 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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