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,430 @@
|
||||
// Package tray is the fast path: the tray icon, its grouped preset menu, and
|
||||
// the confirm-then-apply flow.
|
||||
//
|
||||
// Everything here is driven by systray's callbacks, which run on the UI
|
||||
// goroutine. Nothing slow may happen inline — a netsh apply takes one to three
|
||||
// seconds and would freeze the menu — so every click handler hands off to a
|
||||
// goroutine immediately.
|
||||
package tray
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/config"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/desktop"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/dialog"
|
||||
"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/updater"
|
||||
)
|
||||
|
||||
//go:embed icon.ico
|
||||
var iconICO []byte
|
||||
|
||||
// App owns the tray and everything it talks to.
|
||||
type App struct {
|
||||
Store *preset.Store
|
||||
Manager netcfg.Manager
|
||||
Server *server.Server
|
||||
Paths config.Paths
|
||||
Version string
|
||||
|
||||
mu sync.Mutex
|
||||
settings config.Settings
|
||||
// activeID is the preset currently matching the live adapter state, empty
|
||||
// when nothing matches.
|
||||
activeID string
|
||||
// pendingUpdate is set once the update check finds a newer release.
|
||||
pendingUpdate *updater.Update
|
||||
|
||||
// menuGen is closed and replaced on every menu rebuild, so the click
|
||||
// listeners attached to the previous set of items exit instead of leaking
|
||||
// a goroutine per rebuild.
|
||||
menuGen chan struct{}
|
||||
|
||||
titleItem *systray.MenuItem
|
||||
updateItem *systray.MenuItem
|
||||
// presetItems maps preset id to its menu entry, for the check marks.
|
||||
presetItems map[string]*systray.MenuItem
|
||||
|
||||
elevated bool
|
||||
}
|
||||
|
||||
// New builds the tray application.
|
||||
func New(store *preset.Store, mgr netcfg.Manager, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App {
|
||||
return &App{
|
||||
Store: store,
|
||||
Manager: mgr,
|
||||
Server: srv,
|
||||
Paths: paths,
|
||||
Version: version,
|
||||
settings: settings,
|
||||
elevated: elevate.IsElevated(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until the user exits. It must be called from the main goroutine.
|
||||
func (a *App) Run() {
|
||||
a.Server.OnPresetsChange(func() {
|
||||
// The editor changed something; rebuild on the UI goroutine.
|
||||
go a.rebuild()
|
||||
})
|
||||
a.Server.OnSettingsChange(a.applySettings)
|
||||
|
||||
systray.Run(a.onReady, a.onExit)
|
||||
}
|
||||
|
||||
func (a *App) onReady() {
|
||||
systray.SetIcon(iconICO)
|
||||
systray.SetTitle(config.AppName)
|
||||
systray.SetTooltip(config.AppName)
|
||||
|
||||
a.build()
|
||||
|
||||
go a.pollActive()
|
||||
go a.checkUpdatesOnStartup()
|
||||
}
|
||||
|
||||
func (a *App) onExit() {
|
||||
a.Server.Stop()
|
||||
log.Printf("%s exiting", config.AppName)
|
||||
}
|
||||
|
||||
// --- menu construction ---
|
||||
|
||||
func (a *App) rebuild() {
|
||||
systray.ResetMenu()
|
||||
a.build()
|
||||
a.refreshActive()
|
||||
}
|
||||
|
||||
func (a *App) build() {
|
||||
a.mu.Lock()
|
||||
if a.menuGen != nil {
|
||||
close(a.menuGen) // retire the previous generation's listeners
|
||||
}
|
||||
gen := make(chan struct{})
|
||||
a.menuGen = gen
|
||||
pending := a.pendingUpdate
|
||||
a.mu.Unlock()
|
||||
|
||||
// Update offer sits at the very top when there is one.
|
||||
updateItem := systray.AddMenuItem("", "A newer release is available")
|
||||
updateItem.Hide()
|
||||
if pending != nil {
|
||||
updateItem.SetTitle(fmt.Sprintf("Update available — v%s", pending.Version))
|
||||
updateItem.Show()
|
||||
}
|
||||
a.onClick(gen, updateItem, a.doUpdate)
|
||||
|
||||
titleItem := systray.AddMenuItem(config.AppName, "The preset currently applied")
|
||||
titleItem.Disable()
|
||||
systray.AddSeparator()
|
||||
|
||||
// Groups become submenus. A flat list of fifty presets is unusable, so
|
||||
// this is not optional even when there are only a handful.
|
||||
presets := a.Store.All()
|
||||
if len(presets) == 0 {
|
||||
empty := systray.AddMenuItem("No presets yet — use Manage presets…", "")
|
||||
empty.Disable()
|
||||
}
|
||||
|
||||
items := make(map[string]*systray.MenuItem, len(presets))
|
||||
var currentGroup string
|
||||
var sub *systray.MenuItem
|
||||
for _, p := range presets {
|
||||
g := p.GroupOrUngrouped()
|
||||
if g != currentGroup || sub == nil {
|
||||
currentGroup = g
|
||||
sub = systray.AddMenuItem(g, "")
|
||||
}
|
||||
item := sub.AddSubMenuItemCheckbox(p.Name, p.Notes, false)
|
||||
items[p.ID] = item
|
||||
|
||||
id := p.ID
|
||||
a.onClick(gen, item, func() { a.applyPreset(id) })
|
||||
}
|
||||
|
||||
// The menu is built on the UI goroutine but read by the active-preset
|
||||
// poller, so every handle it touches is published under the lock.
|
||||
a.mu.Lock()
|
||||
a.presetItems = items
|
||||
a.titleItem = titleItem
|
||||
a.updateItem = updateItem
|
||||
a.mu.Unlock()
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
manage := systray.AddMenuItem("Manage presets…", "Open the preset editor in your browser")
|
||||
a.onClick(gen, manage, a.openEditor)
|
||||
|
||||
check := systray.AddMenuItem("Check for updates", "")
|
||||
a.onClick(gen, check, a.checkUpdatesNow)
|
||||
|
||||
if !a.elevated {
|
||||
systray.AddSeparator()
|
||||
relaunch := systray.AddMenuItem("Relaunch as administrator", "Applying a preset needs an elevated process")
|
||||
a.onClick(gen, relaunch, a.relaunch)
|
||||
}
|
||||
|
||||
systray.AddSeparator()
|
||||
quit := systray.AddMenuItem("Exit", "")
|
||||
a.onClick(gen, quit, func() { systray.Quit() })
|
||||
}
|
||||
|
||||
// onClick runs fn on its own goroutine for every click, until this menu
|
||||
// generation is retired.
|
||||
func (a *App) onClick(gen <-chan struct{}, item *systray.MenuItem, fn func()) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-gen:
|
||||
return
|
||||
case _, ok := <-item.ClickedCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
go fn()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// --- actions ---
|
||||
|
||||
// applyPreset is the whole fast path: read the live config, show the diff,
|
||||
// and on a Yes run the netsh plan.
|
||||
func (a *App) applyPreset(id string) {
|
||||
p, ok := a.Store.Get(id)
|
||||
if !ok {
|
||||
dialog.Error(config.AppName, "That preset no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
// Read live rather than reusing the poll's cached view: this text is the
|
||||
// only safety net before a destructive change and it must not be stale.
|
||||
live, err := a.Manager.Current(p.Adapter)
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf(
|
||||
"Could not read the current configuration of adapter %q.\n\n%v", p.Adapter, err))
|
||||
return
|
||||
}
|
||||
|
||||
if !dialog.Confirm(config.AppName, netcfg.ConfirmText(p, live)) {
|
||||
log.Printf("apply of %q cancelled at the prompt", p.Name)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("applying preset %q to %q", p.Name, p.Adapter)
|
||||
plan := netcfg.Plan(p, live)
|
||||
|
||||
if err := a.Manager.Apply(plan); err != nil {
|
||||
log.Printf("apply of %q failed: %v", p.Name, err)
|
||||
|
||||
if isElevationErr(err) && !a.elevated {
|
||||
if dialog.ErrorWithRetryAsAdmin(config.AppName,
|
||||
fmt.Sprintf("Applying %q needs administrator rights.", p.Name)) {
|
||||
a.relaunch()
|
||||
}
|
||||
return
|
||||
}
|
||||
dialog.Error(config.AppName, fmt.Sprintf("Applying %q failed.\n\n%v", p.Name, err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("applied preset %q", p.Name)
|
||||
a.refreshActive()
|
||||
}
|
||||
|
||||
func (a *App) openEditor() {
|
||||
url, err := a.Server.Start()
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf("Could not start the preset editor.\n\n%v", err))
|
||||
return
|
||||
}
|
||||
if err := desktop.OpenURL(url); err != nil {
|
||||
// The server is up, so give the user the address rather than just failing.
|
||||
dialog.Error(config.AppName, fmt.Sprintf(
|
||||
"Could not open your browser. Paste this address into it:\n\n%s\n\n%v", url, err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) relaunch() {
|
||||
if err := elevate.RelaunchAsAdmin(); err != nil {
|
||||
log.Printf("relaunch as admin failed: %v", err)
|
||||
return
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
func (a *App) applySettings(c config.Settings) {
|
||||
a.mu.Lock()
|
||||
a.settings = c
|
||||
a.mu.Unlock()
|
||||
|
||||
if err := desktop.SetRunAtLogin(c.StartWithWindows); err != nil {
|
||||
log.Printf("updating the start-with-Windows setting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) currentSettings() config.Settings {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.settings
|
||||
}
|
||||
|
||||
// --- active preset detection ---
|
||||
|
||||
// pollActive re-reads the live adapter state on a timer so the check mark and
|
||||
// tooltip stay right when something outside ipswap changes the network.
|
||||
func (a *App) pollActive() {
|
||||
a.refreshActive()
|
||||
|
||||
interval := time.Duration(a.currentSettings().ActivePollSeconds) * time.Second
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
a.refreshActive()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) refreshActive() {
|
||||
presets := a.Store.All()
|
||||
|
||||
// One read per distinct adapter rather than one per preset: fifty presets
|
||||
// across three adapters is three reads, not fifty.
|
||||
live := map[string]netcfg.LiveConfig{}
|
||||
for _, p := range presets {
|
||||
if _, done := live[p.Adapter]; done {
|
||||
continue
|
||||
}
|
||||
cfg, err := a.Manager.Current(p.Adapter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
live[p.Adapter] = cfg
|
||||
}
|
||||
|
||||
activeID, activeName := "", ""
|
||||
for _, p := range presets {
|
||||
cfg, ok := live[p.Adapter]
|
||||
if ok && netcfg.Matches(p, cfg) {
|
||||
activeID, activeName = p.ID, p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.activeID = activeID
|
||||
items := a.presetItems
|
||||
title := a.titleItem
|
||||
a.mu.Unlock()
|
||||
|
||||
for id, item := range items {
|
||||
if id == activeID {
|
||||
item.Check()
|
||||
} else {
|
||||
item.Uncheck()
|
||||
}
|
||||
}
|
||||
|
||||
label := config.AppName + " — unmatched"
|
||||
if activeName != "" {
|
||||
label = config.AppName + " — " + activeName
|
||||
}
|
||||
systray.SetTooltip(label)
|
||||
if title != nil {
|
||||
title.SetTitle(label)
|
||||
}
|
||||
}
|
||||
|
||||
// --- updates ---
|
||||
|
||||
func (a *App) checkUpdatesOnStartup() {
|
||||
if !a.currentSettings().CheckUpdates {
|
||||
return
|
||||
}
|
||||
// Never block startup on the network, and never surface a failure: a
|
||||
// laptop on a customer site usually cannot reach the Gitea host.
|
||||
if _, err := a.checkUpdates(); err != nil {
|
||||
log.Printf("startup update check failed (ignored): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkUpdatesNow is the menu-driven check, which does report its result.
|
||||
func (a *App) checkUpdatesNow() {
|
||||
up, err := a.checkUpdates()
|
||||
switch {
|
||||
case err != nil:
|
||||
dialog.Error(config.AppName, fmt.Sprintf("Could not check for updates.\n\n%v", err))
|
||||
case up == nil:
|
||||
dialog.Info(config.AppName, fmt.Sprintf("%s v%s is up to date.", config.AppName, a.Version))
|
||||
default:
|
||||
dialog.Info(config.AppName, fmt.Sprintf(
|
||||
"%s v%s is available.\n\nChoose \"Update available\" in the tray menu to install it.", config.AppName, up.Version))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) checkUpdates() (*updater.Update, error) {
|
||||
s := a.currentSettings()
|
||||
if s.UpdateRepo == "" {
|
||||
return nil, fmt.Errorf("no update repository is configured (set one in Settings)")
|
||||
}
|
||||
|
||||
c := &updater.Checker{Repo: s.UpdateRepo, Current: a.Version}
|
||||
up, err := c.Check(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.pendingUpdate = up
|
||||
item := a.updateItem
|
||||
a.mu.Unlock()
|
||||
|
||||
if up != nil && item != nil {
|
||||
item.SetTitle(fmt.Sprintf("Update available — v%s", up.Version))
|
||||
item.Show()
|
||||
}
|
||||
return up, nil
|
||||
}
|
||||
|
||||
// doUpdate downloads, verifies and installs. Nothing here happens without the
|
||||
// user having clicked the update item first.
|
||||
func (a *App) doUpdate() {
|
||||
a.mu.Lock()
|
||||
up := a.pendingUpdate
|
||||
repo := a.settings.UpdateRepo
|
||||
a.mu.Unlock()
|
||||
if up == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !dialog.Confirm(config.AppName, fmt.Sprintf(
|
||||
"Download and install %s v%s?\n\n%s will close and reopen once the update is in place.",
|
||||
config.AppName, up.Version, config.AppName)) {
|
||||
return
|
||||
}
|
||||
|
||||
c := &updater.Checker{Repo: repo, Current: a.Version}
|
||||
path, err := c.Download(context.Background(), up, tempDir())
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf("The update could not be downloaded.\n\n%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.InstallAndRestart(path); err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf("The update could not be installed.\n\n%v", err))
|
||||
return
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
Reference in New Issue
Block a user