23dcfb393f
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>
431 lines
11 KiB
Go
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()
|
|
}
|