f267c430d9
Three features plus the design-system swap. Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches the app's asInvoker manifest, and it is also what lets the in-app updater replace the .exe later without elevation — a Program Files install could not. It offers a desktop shortcut and an optional start-with-Windows entry, and reuses the app's own single-instance mutex as AppMutex so setup notices a running copy, since a running .exe cannot be overwritten. Uninstall leaves presets.json, config.json and the log in place. Launching. Opening ipswap opens the editor in a browser, starting the tray first if it is not already up; if it is, the running copy opens the browser and the second process exits. internal/instance does this with a loopback control listener whose port and token live in a mode-600 session.json, plus a session-local named mutex on Windows to settle a launch race. A stale session file from a crash is detected by a failed call and treated as "no primary", so it can never wedge startup. The Run-key entry now passes --background, because a browser tab at every login is not wanted. Switching from the browser. New endpoints for active state, apply preview and apply. The browser confirms against the same before/after text the tray's MessageBox shows, built from a live read at prompt time. Apply requires the session token in a header like every other mutation — it changes the machine's network, so it is not a weaker case than editing a preset. A netsh refusal for lack of elevation comes back as 409 with a flag, so the page can point at "Relaunch as administrator" instead of showing a generic failure. To avoid two copies of that sequence, internal/switcher now owns everything between "the user said yes" and "the adapter changed", and both front ends call it. It serialises applies: two interleaved netsh sequences on one adapter would leave it matching neither preset, and there are now two ways to start one. CSS. apointless.css is vendored from bsncubed/css and left untouched; the previous file was the boarding-pass stylesheet and its class names did not match this markup. ipswap.css adds only what the design system does not ship — page chrome, tables, modals, stat tiles, a success alert — on its tokens, so re-pulling apointless.css restyles the app. Not verified: the installer is uncompiled (no Inno Setup or wine on this box) and the UI is not visually rendered (headless Firefox hangs here). Both are checked as far as the tooling allows — assets and endpoints serve, and every DOM id the JS touches exists in the markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.5 KiB
Markdown
184 lines
8.5 KiB
Markdown
# ipswap
|
||
|
||
A tray-resident Windows utility for switching a network adapter between saved
|
||
static-IP configurations. Built for field and support work, where a laptop hops
|
||
between customer subnets — broadcast control networks, media networks,
|
||
management VLANs — many times a day.
|
||
|
||
Switching a preset is two clicks and no browser. Editing fifty presets happens
|
||
in a real UI. Those are different problems, so they use different interfaces.
|
||
|
||
## How it works
|
||
|
||
**Fast path (native, no browser).** The tray icon holds a menu of presets
|
||
grouped into submenus. Clicking one reads the adapter's live configuration,
|
||
shows a Win32 message box with a before/after diff, and on Yes runs the netsh
|
||
sequence. The tray tooltip then names the preset that is actually applied.
|
||
|
||
**Slow path (browser).** "Manage presets…" starts a local HTTP server on a
|
||
random loopback port and opens your default browser to it. Full CRUD, adapter
|
||
picker, import/export, settings, and switching. The server shuts itself down
|
||
five minutes after the browser stops sending heartbeats, and on exit.
|
||
|
||
Switching works from either side. The tray confirms with a native MessageBox;
|
||
the browser shows the identical before/after text in a modal. Both read the
|
||
adapter live at prompt time and both go through the same code — see
|
||
`internal/switcher`, which exists so that path is written once.
|
||
|
||
## Launching
|
||
|
||
Opening ipswap — from the desktop shortcut, the Start menu, or by running the
|
||
.exe — opens the editor in your browser. If ipswap is not already running that
|
||
also starts the tray; if it is, the running copy opens the browser and the
|
||
second process exits. You never get two tray icons.
|
||
|
||
That is done with a loopback control listener owned by the running instance,
|
||
whose port and token live in `%APPDATA%\ipswap\session.json` (mode 600). A
|
||
second launch reads that file, calls the listener and exits. On Windows a
|
||
session-local named mutex settles who is primary when two launches race.
|
||
|
||
The start-with-Windows entry passes `--background`, which starts the tray
|
||
without opening a browser. Nobody wants a browser tab at login.
|
||
|
||
## Building
|
||
|
||
Pure Go, no cgo anywhere, so a Linux or macOS host cross-compiles the Windows
|
||
binary directly — no Wine, no mingw, no fyne-cross.
|
||
|
||
```
|
||
make build # -> bin/ipswap.exe
|
||
make check # tests + vet + typecheck for both targets
|
||
make dist # bin/ipswap.exe plus dist/SHA256SUMS
|
||
make installer # -> Output/ipswap-<version>-setup.exe (needs Inno Setup)
|
||
```
|
||
|
||
`make build` regenerates `cmd/ipswap/resource_windows.syso` first. That object
|
||
carries the icon, the version resource and the manifest — the manifest is the
|
||
part that matters, because it is what asks for `asInvoker` and per-monitor DPI.
|
||
|
||
`GOOS=windows go vet ./...` is worth running on its own during development: the
|
||
Windows-only files (netsh, user32, the registry) are most of the risk and a
|
||
plain Linux build never looks at them. `make vet` does both targets.
|
||
|
||
`go run ./cmd/devserver` starts the editor alone against a throwaway preset
|
||
file, so the web UI can be opened on a non-Windows box. It is a development
|
||
aid; the shipped binary is `cmd/ipswap` only.
|
||
|
||
### Installer
|
||
|
||
`Inno-compile.iss` builds a **per-user** installer: it installs to
|
||
`%APPDATA%\ipswap` with `PrivilegesRequired=lowest`, so it never shows a UAC
|
||
prompt. That is deliberate and matches the app — ipswap ships an `asInvoker`
|
||
manifest, and an installer demanding elevation would undo the point. It also
|
||
means the in-app updater can replace the .exe without a privilege prompt,
|
||
which a Program Files install would not allow.
|
||
|
||
It offers a desktop shortcut and an optional start-with-Windows entry, and it
|
||
uses the app's own single-instance mutex as `AppMutex` so setup notices a
|
||
running copy — a running .exe on Windows cannot be overwritten.
|
||
|
||
Uninstalling leaves `presets.json`, `config.json` and the log alone. Inno only
|
||
removes what it installed, and preset packs represent real work.
|
||
|
||
Inno Setup is a Windows tool, so this is the one build step that is not
|
||
cross-platform; it runs fine under wine if you would rather not use Windows.
|
||
The `.exe` itself still cross-compiles natively, so CI never needs wine.
|
||
|
||
## Layout
|
||
|
||
```
|
||
cmd/ipswap/ entrypoint, manifest, version resource
|
||
cmd/devserver/ runs the editor alone for development
|
||
internal/preset/ data model, store, prefix parsing, import/export
|
||
internal/netcfg/ adapter reads (Win32) and the netsh command plan
|
||
internal/switcher/ the one place a preset is applied; both front ends use it
|
||
internal/instance/ single-instance lock and the launch control channel
|
||
internal/tray/ tray menu, confirm-then-apply, icon
|
||
internal/server/ the local editor server and its embedded web app
|
||
internal/updater/ Gitea release check, download, verify, swap
|
||
internal/dialog/ Win32 MessageBoxW wrappers
|
||
internal/elevate/ token elevation check, relaunch via ShellExecuteW runas
|
||
internal/desktop/ open-in-browser, HKCU Run key
|
||
internal/config/ paths and settings
|
||
internal/applog/ rotating log
|
||
```
|
||
|
||
`internal/server/web/apointless.css` is vendored verbatim from
|
||
[bsncubed/css](https://gitea.apointless.space/bsncubed/css) — re-pull it to
|
||
update, do not edit it here. `ipswap.css` beside it adds only what the design
|
||
system does not ship (page chrome, tables, modals, stat tiles) and is built
|
||
strictly on its tokens, so a refreshed apointless.css restyles the app too.
|
||
|
||
Two rules shape the split. Anything portable lives in portable code and is
|
||
tested on any host — most usefully `netcfg.Plan`, which builds the exact netsh
|
||
sequence and is covered without a Windows box. Anything Windows-specific has a
|
||
`_windows.go` and an `_other.go`, so the whole tree stays buildable and
|
||
vettable during development on Linux.
|
||
|
||
## Elevation
|
||
|
||
The manifest asks for `asInvoker`, not `requireAdministrator`. On a machine
|
||
with UAC relaxed, or when the parent process is already elevated, that means
|
||
zero prompts — including at login, if start-with-Windows is on.
|
||
|
||
Membership of the Administrators group is not the same as holding an elevated
|
||
token: with UAC on, a member's process gets a filtered token and `netsh
|
||
interface ipv4 set address` fails with "The requested operation requires
|
||
elevation." So ipswap checks the token at startup, and if it is not elevated it
|
||
adds a "Relaunch as administrator" item to the tray. A failed apply offers the
|
||
same thing. Nothing blocks startup either way.
|
||
|
||
## Data
|
||
|
||
```
|
||
%APPDATA%\ipswap\presets.json presets
|
||
%APPDATA%\ipswap\config.json settings
|
||
%APPDATA%\ipswap\ipswap.log rotating, 5 × 1 MB
|
||
```
|
||
|
||
`presets.json` is the export format too, so a preset pack is just this file and
|
||
stays hand-editable. See `examples/preset-pack.json`.
|
||
|
||
Masks are stored as an integer prefix. Both `/24` and `255.255.255.0` are
|
||
accepted on input, in the editor and in a hand-written file; the display style
|
||
is a single global setting.
|
||
|
||
## Why applies are destructive
|
||
|
||
After an apply, the adapter has exactly the addresses in the preset and nothing
|
||
else.
|
||
|
||
`netsh interface ipv4 set address … static` replaces the primary address but
|
||
leaves previously-added secondary addresses attached. A naive implementation
|
||
therefore leaks addresses across switches — after visiting three presets the
|
||
adapter is still carrying secondaries from the first two. So every apply
|
||
enumerates the live addresses, deletes each one, then sets the primary and adds
|
||
the preset's secondaries. `internal/netcfg/plan_test.go` pins that ordering.
|
||
|
||
Reads use `GetAdaptersAddresses`, not `netsh show config`: netsh's output is
|
||
localised and parsing it breaks on a non-English Windows.
|
||
|
||
## Updates
|
||
|
||
ipswap checks `https://gitea.apointless.space/bsncubed/ipswap` by default. The
|
||
check runs at startup in a goroutine, behind a short timeout, and fails
|
||
silently to the log — a laptop on a customer site usually cannot reach the
|
||
host, and that is not an error worth showing.
|
||
|
||
Point it elsewhere, or clear it to switch the check off entirely, in Settings
|
||
(`update_repo` in `config.json`). Forks should change it: otherwise they will
|
||
offer their users an upstream binary.
|
||
|
||
A newer tag adds "Update available — vX.Y.Z" to the top of the tray menu.
|
||
Clicking it downloads the `.exe`, verifies its SHA256 against the release, and
|
||
hands off to a small batch helper that waits for ipswap to exit, swaps the
|
||
binary and relaunches. **A release that publishes no checksum is refused** — an
|
||
unverified binary that is about to be run is not worth the convenience. It
|
||
never installs on its own.
|
||
|
||
## Not in v1
|
||
|
||
Revert / restore-previous / timed auto-revert; post-apply connectivity checks
|
||
(the confirmation diff is the check); global hotkeys; IPv6; adapter matching by
|
||
MAC address; per-preset scripts.
|