// 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" "errors" "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/switcher" "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 Switcher *switcher.Switcher Server *server.Server Paths config.Paths Version string // OpenOnStart opens the editor as soon as the tray is up. Set for a // launch from the shortcut, cleared for the start-with-Windows launch — // nobody wants a browser tab at login. OpenOnStart bool 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, sw *switcher.Switcher, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App { return &App{ Store: store, Switcher: sw, 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) a.Server.OnApplied(func() { // A switch made in the browser must move the tray's check mark too. go a.refreshActive() }) 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() if a.OpenOnStart { // After the menu exists, so a failure to open the browser still leaves // a usable tray icon behind. go a.OpenEditor() } } 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, live, err := a.Switcher.Preview(id) if err != nil { if errors.Is(err, switcher.ErrNotFound) { dialog.Error(config.AppName, "That preset no longer exists.") return } dialog.Error(config.AppName, fmt.Sprintf("Could not read the adapter.\n\n%v", err)) return } if !dialog.Confirm(config.AppName, netcfg.ConfirmText(p, live)) { log.Printf("apply of %q cancelled at the prompt", p.Name) return } if err := a.Switcher.Apply(id); err != nil { 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 } a.refreshActive() } // OpenEditor starts the editor server if needed and opens the browser at it. // Exported because a second launch of ipswap reaches this through the control // channel rather than starting a tray of its own. 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() { st := a.Switcher.Active() activeID, activeName := st.ActiveID, st.ActiveName 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() }