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:
@@ -0,0 +1,129 @@
|
||||
// Package switcher is the one place a preset actually gets applied.
|
||||
//
|
||||
// It exists because there are now two front ends that can switch a preset —
|
||||
// the tray, which confirms with a native MessageBox, and the web editor, which
|
||||
// confirms in the browser — and the sequence between "user said yes" and "the
|
||||
// adapter changed" must not be duplicated. Confirmation is the caller's job;
|
||||
// everything after it is here.
|
||||
package switcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the id does not name a preset.
|
||||
var ErrNotFound = errors.New("no such preset")
|
||||
|
||||
// Switcher reads adapter state and applies presets.
|
||||
type Switcher struct {
|
||||
store *preset.Store
|
||||
mgr netcfg.Manager
|
||||
|
||||
// applying serialises applies. Two concurrent netsh sequences against one
|
||||
// adapter would interleave their deletes and sets and leave the adapter in
|
||||
// a state matching neither preset, and the tray and the browser can now
|
||||
// both start one.
|
||||
applying sync.Mutex
|
||||
}
|
||||
|
||||
// New builds a Switcher.
|
||||
func New(store *preset.Store, mgr netcfg.Manager) *Switcher {
|
||||
return &Switcher{store: store, mgr: mgr}
|
||||
}
|
||||
|
||||
// Preview returns a preset alongside the live configuration of its adapter,
|
||||
// read at call time. This is what both confirmation prompts are built from, so
|
||||
// it must never serve a cached view — a stale "CURRENT" block would undermine
|
||||
// the only safety net before a destructive change.
|
||||
func (s *Switcher) Preview(id string) (preset.Preset, netcfg.LiveConfig, error) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return preset.Preset{}, netcfg.LiveConfig{}, ErrNotFound
|
||||
}
|
||||
|
||||
live, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
return p, netcfg.LiveConfig{}, fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err)
|
||||
}
|
||||
return p, live, nil
|
||||
}
|
||||
|
||||
// Apply switches the adapter to the preset. The caller is responsible for
|
||||
// having confirmed with the user first.
|
||||
//
|
||||
// The live configuration is read again here rather than taken from a preceding
|
||||
// Preview: the plan's delete step is built from the addresses actually on the
|
||||
// adapter, and between the prompt appearing and the user clicking Yes, a DHCP
|
||||
// lease or another tool may have changed them.
|
||||
func (s *Switcher) Apply(id string) error {
|
||||
s.applying.Lock()
|
||||
defer s.applying.Unlock()
|
||||
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
live, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err)
|
||||
}
|
||||
|
||||
log.Printf("applying preset %q to %q", p.Name, p.Adapter)
|
||||
if err := s.mgr.Apply(netcfg.Plan(p, live)); err != nil {
|
||||
log.Printf("apply of %q failed: %v", p.Name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied preset %q", p.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// State is a snapshot of which preset is live.
|
||||
type State struct {
|
||||
// ActiveID is the preset exactly matching its adapter, empty if none does.
|
||||
ActiveID string `json:"active_id"`
|
||||
ActiveName string `json:"active_name"`
|
||||
Adapters map[string]netcfg.LiveConfig `json:"adapters"`
|
||||
}
|
||||
|
||||
// Active works out which preset, if any, is currently applied.
|
||||
//
|
||||
// One read per distinct adapter rather than one per preset: fifty presets
|
||||
// spread across three adapters is three reads, not fifty. This runs every 30
|
||||
// seconds, so the difference matters.
|
||||
func (s *Switcher) Active() State {
|
||||
presets := s.store.All()
|
||||
st := State{Adapters: make(map[string]netcfg.LiveConfig)}
|
||||
|
||||
for _, p := range presets {
|
||||
if _, done := st.Adapters[p.Adapter]; done {
|
||||
continue
|
||||
}
|
||||
cfg, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
st.Adapters[p.Adapter] = cfg
|
||||
}
|
||||
|
||||
for _, p := range presets {
|
||||
if cfg, ok := st.Adapters[p.Adapter]; ok && netcfg.Matches(p, cfg) {
|
||||
st.ActiveID, st.ActiveName = p.ID, p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ConfirmText renders the before/after text for a preset, for callers that
|
||||
// want the prompt body without applying anything.
|
||||
func (s *Switcher) ConfirmText(p preset.Preset, live netcfg.LiveConfig) string {
|
||||
return netcfg.ConfirmText(p, live)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package switcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// fakeManager records what was applied and lets a test drive adapter state.
|
||||
type fakeManager struct {
|
||||
mu sync.Mutex
|
||||
live map[string]netcfg.LiveConfig
|
||||
applied [][]netcfg.Command
|
||||
err error
|
||||
// onApply runs inside Apply, for exercising concurrency.
|
||||
onApply func()
|
||||
}
|
||||
|
||||
func (f *fakeManager) Adapters() ([]netcfg.Adapter, error) {
|
||||
return []netcfg.Adapter{{Name: "Ethernet", Up: true}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeManager) Current(adapter string) (netcfg.LiveConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
cfg, ok := f.live[adapter]
|
||||
if !ok {
|
||||
return netcfg.LiveConfig{}, errors.New("adapter not found")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (f *fakeManager) Apply(plan []netcfg.Command) error {
|
||||
if f.onApply != nil {
|
||||
f.onApply()
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.applied = append(f.applied, plan)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*Switcher, *preset.Store, *fakeManager) {
|
||||
t.Helper()
|
||||
|
||||
store, err := preset.NewStore(filepath.Join(t.TempDir(), "presets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := &fakeManager{live: map[string]netcfg.LiveConfig{
|
||||
"Ethernet": {
|
||||
Adapter: "Ethernet",
|
||||
DHCP: true,
|
||||
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}},
|
||||
Gateways: []string{"192.168.1.1"},
|
||||
DNS: []string{"192.168.1.1"},
|
||||
DNSFromDHCP: true,
|
||||
},
|
||||
}}
|
||||
return New(store, mgr), store, mgr
|
||||
}
|
||||
|
||||
func staticPreset() preset.Preset {
|
||||
return preset.Preset{
|
||||
Name: "Control",
|
||||
Group: "Riedel",
|
||||
Adapter: "Ethernet",
|
||||
Mode: preset.ModeStatic,
|
||||
Primary: &preset.Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"},
|
||||
Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}},
|
||||
DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewReadsLiveState(t *testing.T) {
|
||||
sw, store, _ := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, live, err := sw.Preview(saved.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Name != "Control" {
|
||||
t.Errorf("preview returned preset %q", p.Name)
|
||||
}
|
||||
if len(live.Addresses) != 1 || live.Addresses[0].Address != "192.168.1.87" {
|
||||
t.Errorf("preview did not return the live config: %+v", live)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewUnknownID(t *testing.T) {
|
||||
sw, _, _ := setup(t)
|
||||
if _, _, err := sw.Preview("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Preview of an unknown id = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRunsThePlan(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := sw.Apply(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mgr.applied) != 1 {
|
||||
t.Fatalf("expected one plan to be applied, got %d", len(mgr.applied))
|
||||
}
|
||||
|
||||
joined := ""
|
||||
for _, c := range mgr.applied[0] {
|
||||
joined += c.String() + "\n"
|
||||
}
|
||||
for _, want := range []string{"192.168.42.100", "255.255.255.0", "10.0.10.50", "dnsservers"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The live read happens inside Apply, not carried over from Preview: between
|
||||
// the prompt appearing and the user clicking yes, the adapter can change.
|
||||
func TestApplyRereadsLiveState(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := sw.Preview(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The adapter picks up two static addresses after the preview.
|
||||
mgr.mu.Lock()
|
||||
mgr.live["Ethernet"] = netcfg.LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "10.1.1.1", Prefix: 24}, {Address: "10.2.2.2", Prefix: 24}},
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
if err := sw.Apply(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deletes int
|
||||
for _, c := range mgr.applied[0] {
|
||||
if c.Args[2] == "delete" {
|
||||
deletes++
|
||||
}
|
||||
}
|
||||
if deletes != 2 {
|
||||
t.Errorf("expected 2 deletes for the addresses present at apply time, got %d", deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUnknownID(t *testing.T) {
|
||||
sw, _, _ := setup(t)
|
||||
if err := sw.Apply("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Apply of an unknown id = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two front ends can now start an apply. Interleaving two netsh sequences on
|
||||
// one adapter would leave it matching neither preset.
|
||||
func TestApplyIsSerialised(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var concurrent, maxConcurrent int
|
||||
var mu sync.Mutex
|
||||
mgr.onApply = func() {
|
||||
mu.Lock()
|
||||
concurrent++
|
||||
if concurrent > maxConcurrent {
|
||||
maxConcurrent = concurrent
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Hold the "apply" open long enough that an unserialised second one
|
||||
// would be observed overlapping. Without this the counter would go up
|
||||
// and straight back down and the test could never fail.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
concurrent--
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = sw.Apply(saved.ID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if maxConcurrent > 1 {
|
||||
t.Errorf("applies overlapped: max concurrency %d", maxConcurrent)
|
||||
}
|
||||
if len(mgr.applied) != 8 {
|
||||
t.Errorf("expected 8 applies, got %d", len(mgr.applied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveMatchesLivePreset(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
|
||||
dhcp := preset.Preset{
|
||||
Name: "DHCP", Adapter: "Ethernet",
|
||||
Mode: preset.ModeDHCP,
|
||||
DNS: preset.DNS{Mode: preset.ModeDHCP},
|
||||
}
|
||||
saved, err := store.Put(dhcp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Put(staticPreset()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := sw.Active()
|
||||
if st.ActiveID != saved.ID {
|
||||
t.Errorf("active id = %q, want %q (the DHCP preset matches the live state)", st.ActiveID, saved.ID)
|
||||
}
|
||||
if st.ActiveName != "DHCP" {
|
||||
t.Errorf("active name = %q", st.ActiveName)
|
||||
}
|
||||
if _, ok := st.Adapters["Ethernet"]; !ok {
|
||||
t.Error("Active should report the adapter state it read")
|
||||
}
|
||||
|
||||
// Nothing matches once the adapter moves to a static address.
|
||||
mgr.mu.Lock()
|
||||
mgr.live["Ethernet"] = netcfg.LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "172.16.0.9", Prefix: 24}},
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
if st := sw.Active(); st.ActiveID != "" {
|
||||
t.Errorf("expected no active preset, got %q", st.ActiveName)
|
||||
}
|
||||
}
|
||||
|
||||
// Fifty presets across three adapters should be three reads, not fifty.
|
||||
func TestActiveReadsEachAdapterOnce(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
p := staticPreset()
|
||||
p.Name = "preset"
|
||||
if _, err := store.Put(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
st := sw.Active()
|
||||
if len(st.Adapters) != 1 {
|
||||
t.Errorf("expected one adapter read, got %d", len(st.Adapters))
|
||||
}
|
||||
_ = mgr
|
||||
}
|
||||
Reference in New Issue
Block a user