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
+92
View File
@@ -0,0 +1,92 @@
package netcfg
import (
"fmt"
"strings"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// ConfirmText renders the before/after body of the confirmation MessageBox.
//
// The live side must be read at prompt time rather than taken from the cached
// active-preset poll: the prompt is the only safety net before a destructive
// change, and a 30-second-stale "CURRENT" block would undermine it.
func ConfirmText(p preset.Preset, live LiveConfig) string {
var b strings.Builder
fmt.Fprintf(&b, "Apply preset %q to adapter %q?\n\n", p.Name, p.Adapter)
b.WriteString("CURRENT\n")
writeLive(&b, live)
b.WriteString("\nNEW\n")
writePreset(&b, p)
return b.String()
}
func writeLive(b *strings.Builder, live LiveConfig) {
if len(live.Addresses) == 0 {
b.WriteString(" (no IPv4 address)\n")
}
for i, a := range live.Addresses {
suffix := ""
if live.DHCP {
suffix = " (DHCP)"
}
prefix := " "
if i > 0 {
prefix = " + "
}
fmt.Fprintf(b, "%s%s%s\n", prefix, a.String(), suffix)
}
if len(live.Gateways) > 0 {
fmt.Fprintf(b, " Gateway: %s\n", strings.Join(live.Gateways, ", "))
}
if len(live.DNS) > 0 {
suffix := ""
if live.DNSFromDHCP {
suffix = " (DHCP)"
}
fmt.Fprintf(b, " DNS: %s%s\n", strings.Join(live.DNS, ", "), suffix)
}
}
func writePreset(b *strings.Builder, p preset.Preset) {
if p.Mode == preset.ModeDHCP {
b.WriteString(" DHCP\n")
} else if p.Primary != nil {
fmt.Fprintf(b, " %s\n", p.Primary.String())
for _, s := range p.Secondary {
fmt.Fprintf(b, " + %s\n", s.String())
}
if p.Primary.Gateway != "" {
fmt.Fprintf(b, " Gateway: %s\n", p.Primary.Gateway)
}
}
switch p.DNS.Mode {
case preset.ModeDHCP:
b.WriteString(" DNS: from DHCP\n")
default:
fmt.Fprintf(b, " DNS: %s\n", strings.Join(p.DNS.Servers, ", "))
}
}
// Summary is the one-line description of a live config, used for the editor's
// adapter picker.
func (l LiveConfig) Summary() string {
if len(l.Addresses) == 0 {
return "no IPv4 address"
}
parts := make([]string, 0, len(l.Addresses))
for _, a := range l.Addresses {
parts = append(parts, a.String())
}
s := strings.Join(parts, ", ")
if l.DHCP {
s += " (DHCP)"
}
return s
}