Files
bsncubed f267c430d9 Add installer, launch-to-browser, and switching from the web GUI
Three features plus the design-system swap.

Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap
with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches
the app's asInvoker manifest, and it is also what lets the in-app updater
replace the .exe later without elevation — a Program Files install could not.
It offers a desktop shortcut and an optional start-with-Windows entry, and
reuses the app's own single-instance mutex as AppMutex so setup notices a
running copy, since a running .exe cannot be overwritten. Uninstall leaves
presets.json, config.json and the log in place.

Launching. Opening ipswap opens the editor in a browser, starting the tray
first if it is not already up; if it is, the running copy opens the browser and
the second process exits. internal/instance does this with a loopback control
listener whose port and token live in a mode-600 session.json, plus a
session-local named mutex on Windows to settle a launch race. A stale session
file from a crash is detected by a failed call and treated as "no primary", so
it can never wedge startup. The Run-key entry now passes --background, because
a browser tab at every login is not wanted.

Switching from the browser. New endpoints for active state, apply preview and
apply. The browser confirms against the same before/after text the tray's
MessageBox shows, built from a live read at prompt time. Apply requires the
session token in a header like every other mutation — it changes the machine's
network, so it is not a weaker case than editing a preset. A netsh refusal for
lack of elevation comes back as 409 with a flag, so the page can point at
"Relaunch as administrator" instead of showing a generic failure.

To avoid two copies of that sequence, internal/switcher now owns everything
between "the user said yes" and "the adapter changed", and both front ends call
it. It serialises applies: two interleaved netsh sequences on one adapter would
leave it matching neither preset, and there are now two ways to start one.

CSS. apointless.css is vendored from bsncubed/css and left untouched; the
previous file was the boarding-pass stylesheet and its class names did not
match this markup. ipswap.css adds only what the design system does not ship —
page chrome, tables, modals, stat tiles, a success alert — on its tokens, so
re-pulling apointless.css restyles the app.

Not verified: the installer is uncompiled (no Inno Setup or wine on this box)
and the UI is not visually rendered (headless Firefox hangs here). Both are
checked as far as the tooling allows — assets and endpoints serve, and every
DOM id the JS touches exists in the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:32:15 +10:00

418 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"
"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()
}