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,100 @@
|
||||
//go:build windows
|
||||
|
||||
// Package desktop holds the small Windows shell integrations that do not
|
||||
// belong to any one feature: opening a URL in the default browser, and the
|
||||
// HKCU Run key that starts ipswap with Windows.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||
shellExecuteW = shell32.NewProc("ShellExecuteW")
|
||||
)
|
||||
|
||||
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
|
||||
// OpenURL opens a URL in the default browser.
|
||||
//
|
||||
// ShellExecuteW rather than `cmd /c start`: no console window to suppress and
|
||||
// no quoting rules to get wrong around a URL that already contains "?" and "&".
|
||||
func OpenURL(url string) error {
|
||||
verb, err := syscall.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := syscall.UTF16PtrFromString(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret, _, callErr := shellExecuteW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(target)),
|
||||
0, 0, 1, // no args, no cwd, SW_SHOWNORMAL
|
||||
)
|
||||
if ret <= 32 {
|
||||
return fmt.Errorf("could not open %s (ShellExecuteW returned %d: %v)", url, ret, callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRunAtLogin adds or removes ipswap from the current user's Run key.
|
||||
//
|
||||
// HKCU rather than HKLM deliberately: HKLM needs elevation, and ipswap is
|
||||
// designed to run unelevated. It also keeps the setting per-user on a shared
|
||||
// support laptop.
|
||||
func SetRunAtLogin(enabled bool) error {
|
||||
key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening the Run key: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if !enabled {
|
||||
err := key.DeleteValue(config.AppName)
|
||||
if err != nil && err != registry.ErrNotExist {
|
||||
return fmt.Errorf("removing the Run key value: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locating the running executable: %w", err)
|
||||
}
|
||||
exe, err = filepath.Abs(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Quoted: the path routinely contains spaces (Program Files, or a user
|
||||
// profile with a space in the name) and the Run key value is a command
|
||||
// line, not a path.
|
||||
if err := key.SetStringValue(config.AppName, `"`+exe+`"`); err != nil {
|
||||
return fmt.Errorf("writing the Run key value: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunAtLogin reports whether the Run key entry is present.
|
||||
func RunAtLogin() bool {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
v, _, err := key.GetStringValue(config.AppName)
|
||||
return err == nil && v != ""
|
||||
}
|
||||
Reference in New Issue
Block a user