Add installer, launch-to-browser, and switching from the web GUI

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>
This commit is contained in:
2026-08-20 14:32:15 +10:00
parent a8bbf91223
commit f267c430d9
21 changed files with 2330 additions and 565 deletions
+59 -5
View File
@@ -17,8 +17,28 @@ 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. The server shuts itself down five minutes
after the browser stops sending heartbeats, and on exit.
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
@@ -26,9 +46,10 @@ 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 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
@@ -39,12 +60,39 @@ part that matters, because it is what asks for `asInvoker` and per-monitor DPI.
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
@@ -55,6 +103,12 @@ 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