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>
9.7 KiB
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 — keepsGOOS=windows go buildcross-compiling from Linux CI without Wine). - Fast-path dialogs: native Win32
MessageBoxWviasyscall.NewLazyDLL("user32.dll"). No GUI toolkit. - Editor UI: embedded static web app (
embed.FS) served on127.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).
{
"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:
adapteris 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 whatnetshexpects.- Subnet mask stored internally as an integer prefix. The editor must accept both
/24and255.255.255.0on input and display whichever the user last used (per-preset display preference is overkill — a single global setting is fine). gatewayoptional.gateway_metric0 = 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:
- Enumerate current IPv4 addresses on the target adapter.
netsh interface ipv4 delete address name="<adapter>" addr=<each existing>for every existing static address.- Set the primary:
netsh interface ipv4 set address name="<adapter>" static <addr> <mask> <gateway> <gwmetric> - Add each secondary:
netsh interface ipv4 add address name="<adapter>" <addr> <mask> - DNS static:
netsh interface ipv4 set dnsservers name="<adapter>" static <first> primary validate=nothen for each subsequent,netsh interface ipv4 add dnsservers name="<adapter>" <addr> index=<n> - 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
ShellExecuteWwith therunasverb. - 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. Includeversion. - 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
.exeasset 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 windowsguisuppresses the console window.- Embed an icon and a manifest (
asInvoker,dpiAware) —goversioninfoor a.syso. - Gitea Actions: build on tag, attach the
.exeand aSHA256SUMSfile 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
- Gitea host/owner/repo for the update endpoint.
- Whether the app should start with Windows (registry
Runkey, toggleable in settings) — assumed yes, off by default. - 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