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,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package elevate
|
||||
|
||||
import "errors"
|
||||
|
||||
// IsElevated reports true off Windows so development builds do not show the
|
||||
// "Relaunch as administrator" item that could not work anyway.
|
||||
func IsElevated() bool { return true }
|
||||
|
||||
// RelaunchAsAdmin has no meaning outside Windows.
|
||||
func RelaunchAsAdmin() error {
|
||||
return errors.New("elevation is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//go:build windows
|
||||
|
||||
// Package elevate answers "is this process actually elevated?" and, if not,
|
||||
// can re-exec it through the UAC prompt.
|
||||
//
|
||||
// The manifest asks for asInvoker rather than requireAdministrator: on a
|
||||
// machine with UAC relaxed, or when the parent is already elevated, that means
|
||||
// ipswap starts with zero prompts. The cost is that on a locked-down machine
|
||||
// we start unprivileged and have to offer the relaunch, which is what this
|
||||
// package is for.
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||
shellExecuteW = shell32.NewProc("ShellExecuteW")
|
||||
)
|
||||
|
||||
const swShowNormal = 1
|
||||
|
||||
// IsElevated reports whether the current process token carries administrator
|
||||
// rights right now.
|
||||
//
|
||||
// Membership of the Administrators group is not the same thing: with UAC on,
|
||||
// a member's process gets a filtered token, and `netsh interface ipv4 set
|
||||
// address` then fails with "The requested operation requires elevation."
|
||||
// Checking the token is the only answer that predicts whether an apply works.
|
||||
func IsElevated() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// RelaunchAsAdmin re-execs the current binary through the UAC prompt and
|
||||
// returns once the new process has been started. The caller is expected to
|
||||
// exit immediately afterwards so the two copies do not both own a tray icon.
|
||||
//
|
||||
// A user who clicks "No" on the UAC prompt produces ERROR_CANCELLED, which is
|
||||
// reported as an error but is not a failure worth a message box.
|
||||
func RelaunchAsAdmin() error {
|
||||
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 fmt.Errorf("resolving %s: %w", exe, err)
|
||||
}
|
||||
|
||||
verb, err := syscall.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := syscall.UTF16PtrFromString(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args, err := syscall.UTF16PtrFromString(strings.Join(quoteArgs(os.Args[1:]), " "))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := syscall.UTF16PtrFromString(filepath.Dir(exe))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ShellExecuteW signals failure with a return value of 32 or less.
|
||||
ret, _, callErr := shellExecuteW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(file)),
|
||||
uintptr(unsafe.Pointer(args)),
|
||||
uintptr(unsafe.Pointer(cwd)),
|
||||
swShowNormal,
|
||||
)
|
||||
if ret <= 32 {
|
||||
if ret == uintptr(windows.ERROR_CANCELLED) {
|
||||
return fmt.Errorf("the elevation prompt was cancelled")
|
||||
}
|
||||
return fmt.Errorf("ShellExecuteW returned %d: %v", ret, callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// quoteArgs re-quotes arguments for the single command-line string
|
||||
// ShellExecuteW takes, since it does not accept an argv.
|
||||
func quoteArgs(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
if strings.ContainsAny(a, ` "`) {
|
||||
out = append(out, `"`+strings.ReplaceAll(a, `"`, `\"`)+`"`)
|
||||
} else {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user