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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user