Files
ipswap/internal/tray/tray.go
T
bsncubed 23dcfb393f 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>
2026-08-20 13:29:35 +10:00

431 lines
11 KiB
Go

// 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()
}