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:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package main
import (
"fmt"
"os"
)
// showFatal prints, since a development build on Linux has a console.
func showFatal(msg string) {
fmt.Fprintln(os.Stderr, msg)
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package main
import "gitea.apointless.space/bsncubed/ipswap/internal/dialog"
// showFatal puts a startup failure on screen. Without this the binary would
// simply not appear: -H windowsgui means there is no console to print to.
func showFatal(msg string) {
dialog.Error("ipswap", msg)
}
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="space.apointless.ipswap"
version="1.0.0.0"
processorArchitecture="*"/>
<!--
asInvoker, NOT requireAdministrator.
requireAdministrator would put a UAC prompt in front of every launch,
including the ones that start with Windows. asInvoker means zero prompts on
a machine with UAC relaxed or an already-elevated parent, and on a
locked-down machine ipswap still starts — it just offers "Relaunch as
administrator" in the tray and when an apply is refused.
-->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<!-- Per-monitor v2 so the confirmation MessageBox is not blurry on a scaled
laptop panel driving an unscaled external monitor. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<!-- Common Controls v6, so the message boxes use the modern theme. -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<!-- Declare support through Windows 11 so version APIs do not lie to us. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> <!-- 10 / 11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> <!-- 8.1 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- 8 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- 7 -->
</application>
</compatibility>
</assembly>
+96
View File
@@ -0,0 +1,96 @@
// Command ipswap is a tray-resident switcher for saved static-IP presets.
//
// Build for Windows with:
//
// GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui -X main.version=1.0.0" ./cmd/ipswap
//
// -H windowsgui is what stops a console window appearing behind the tray icon.
package main
import (
"flag"
"fmt"
"log"
"os"
"runtime"
"gitea.apointless.space/bsncubed/ipswap/internal/applog"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
"gitea.apointless.space/bsncubed/ipswap/internal/desktop"
"gitea.apointless.space/bsncubed/ipswap/internal/elevate"
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
"gitea.apointless.space/bsncubed/ipswap/internal/server"
"gitea.apointless.space/bsncubed/ipswap/internal/tray"
)
// version is set at link time with -X main.version=...
var version = "dev"
func main() {
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("%s %s\n", config.AppName, version)
return
}
if err := run(); err != nil {
// With -H windowsgui there is no stderr to read, so anything fatal has
// to be shown rather than printed.
fatal(err)
}
}
func run() error {
paths, err := config.ResolvePaths()
if err != nil {
return err
}
// The log is the only diagnostic available on a customer site, so it is
// set up before anything that could fail interestingly.
logw, err := applog.Setup(paths.Log, runtime.GOOS != "windows")
if err != nil {
return err
}
defer logw.Close()
log.Printf("--- %s %s starting (elevated=%v) ---", config.AppName, version, elevate.IsElevated())
settings, err := config.Load(paths.Config)
if err != nil {
// Settings are conveniences; a broken config.json must not stop the
// app from starting with defaults.
log.Printf("using default settings: %v", err)
}
store, err := preset.NewStore(paths.Presets)
if err != nil {
return fmt.Errorf("loading presets: %w", err)
}
log.Printf("loaded %d preset(s) from %s", len(store.All()), paths.Presets)
// Keep the Run key in step with the setting: the user may have removed the
// entry by other means, or copied config.json onto a new machine.
if desktop.RunAtLogin() != settings.StartWithWindows {
if err := desktop.SetRunAtLogin(settings.StartWithWindows); err != nil {
log.Printf("could not update the start-with-Windows entry: %v", err)
}
}
mgr := netcfg.New()
srv := server.New(store, mgr, paths, settings)
app := tray.New(store, mgr, srv, paths, settings, version)
// systray.Run takes over this goroutine and does not return until Exit.
app.Run()
return nil
}
func fatal(err error) {
log.Printf("fatal: %v", err)
showFatal(fmt.Sprintf("%s could not start.\n\n%v", config.AppName, err))
os.Exit(1)
}
+25
View File
@@ -0,0 +1,25 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 0, "Minor": 1, "Patch": 0, "Build": 0 },
"ProductVersion": { "Major": 0, "Minor": 1, "Patch": 0, "Build": 0 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"CompanyName": "apointless.space",
"FileDescription": "ipswap — network preset switcher",
"InternalName": "ipswap",
"LegalCopyright": "",
"OriginalFilename": "ipswap.exe",
"ProductName": "ipswap",
"ProductVersion": "0.1.0"
},
"VarFileInfo": {
"Translation": { "LangID": "0409", "CharsetID": "04B0" }
},
"IconPath": "../../internal/tray/icon.ico",
"ManifestPath": "ipswap.manifest"
}