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>
This commit is contained in:
+34
-47
@@ -10,6 +10,7 @@ package tray
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -33,11 +35,16 @@ var iconICO []byte
|
||||
// App owns the tray and everything it talks to.
|
||||
type App struct {
|
||||
Store *preset.Store
|
||||
Manager netcfg.Manager
|
||||
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
|
||||
@@ -60,10 +67,10 @@ type App struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func New(store *preset.Store, sw *switcher.Switcher, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App {
|
||||
return &App{
|
||||
Store: store,
|
||||
Manager: mgr,
|
||||
Switcher: sw,
|
||||
Server: srv,
|
||||
Paths: paths,
|
||||
Version: version,
|
||||
@@ -79,6 +86,10 @@ func (a *App) Run() {
|
||||
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)
|
||||
}
|
||||
@@ -92,6 +103,12 @@ func (a *App) onReady() {
|
||||
|
||||
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() {
|
||||
@@ -165,7 +182,7 @@ func (a *App) build() {
|
||||
systray.AddSeparator()
|
||||
|
||||
manage := systray.AddMenuItem("Manage presets…", "Open the preset editor in your browser")
|
||||
a.onClick(gen, manage, a.openEditor)
|
||||
a.onClick(gen, manage, a.OpenEditor)
|
||||
|
||||
check := systray.AddMenuItem("Check for updates", "")
|
||||
a.onClick(gen, check, a.checkUpdatesNow)
|
||||
@@ -204,18 +221,13 @@ func (a *App) onClick(gen <-chan struct{}, item *systray.MenuItem, fn func()) {
|
||||
// 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)
|
||||
p, live, err := a.Switcher.Preview(id)
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf(
|
||||
"Could not read the current configuration of adapter %q.\n\n%v", p.Adapter, err))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -224,12 +236,7 @@ func (a *App) applyPreset(id string) {
|
||||
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 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)) {
|
||||
@@ -241,11 +248,13 @@ func (a *App) applyPreset(id string) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("applied preset %q", p.Name)
|
||||
a.refreshActive()
|
||||
}
|
||||
|
||||
func (a *App) openEditor() {
|
||||
// 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))
|
||||
@@ -298,30 +307,8 @@ func (a *App) pollActive() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
st := a.Switcher.Active()
|
||||
activeID, activeName := st.ActiveID, st.ActiveName
|
||||
|
||||
a.mu.Lock()
|
||||
a.activeID = activeID
|
||||
|
||||
Reference in New Issue
Block a user