# 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:`, 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:/?t=`. 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="" addr=` for every existing static address. 3. Set the primary: `netsh interface ipv4 set address name="" static ` 4. Add each secondary: `netsh interface ipv4 add address name="" ` 5. DNS static: `netsh interface ipv4 set dnsservers name="" static primary validate=no` then for each subsequent, `netsh interface ipv4 add dnsservers name="" index=` 6. DNS DHCP: `netsh interface ipv4 set dnsservers name="" source=dhcp` For `mode: "dhcp"`: `netsh interface ipv4 set address name="" 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 — ` (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:///api/v1/repos///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