23dcfb393f
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>
253 lines
9.7 KiB
Markdown
253 lines
9.7 KiB
Markdown
# 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
|
||
|