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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user