diff --git a/Inno-compile.iss b/Inno-compile.iss new file mode 100644 index 0000000..3d2fc3e --- /dev/null +++ b/Inno-compile.iss @@ -0,0 +1,111 @@ +; ipswap installer. +; +; Compile with Inno Setup 6: +; ISCC.exe /DAppVersion=0.1.0 Inno-compile.iss +; or from the repo root on Linux: +; make installer VERSION=0.1.0 +; +; This is a per-user install. Nothing here needs administrator rights, which is +; deliberate and matches the application: ipswap ships an asInvoker manifest and +; degrades to offering "Relaunch as administrator" rather than demanding +; elevation up front. An installer that popped UAC would undo that. + +#define AppName "ipswap" +#define AppExeName "ipswap.exe" +#define AppPublisher "apointless.space" +#define AppURL "https://gitea.apointless.space/bsncubed/ipswap" + +; Overridden by the Makefile with /DAppVersion=... +#ifndef AppVersion + #define AppVersion "0.0.0-dev" +#endif + +[Setup] +; Never change AppId: it is what lets a new version upgrade an old one in place +; rather than installing alongside it. +AppId={{17223C0B-73DA-457E-9AF7-22220467538A} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL} +AppUpdatesURL={#AppURL}/releases + +; The app installs into its own data directory under %APPDATA%, so the binary +; sits beside presets.json, config.json and the log. That keeps the whole +; installation inside the user profile — no Program Files, no admin, and the +; in-app updater can replace the .exe without a privilege prompt. +DefaultDirName={userappdata}\{#AppName} +DisableDirPage=auto +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes + +; lowest, not admin: see the header. PrivilegesRequiredOverridesAllowed is +; deliberately not set, so there is no path to a machine-wide install that +; would break the updater's ability to swap the binary. +PrivilegesRequired=lowest + +; ipswap holds this mutex for its lifetime. Inno uses it to notice a running +; copy and ask the user to close it, which matters because a running .exe on +; Windows cannot be overwritten. +AppMutex=Local\ipswap-single-instance + +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +OutputDir=Output +OutputBaseFilename={#AppName}-{#AppVersion}-setup +SetupIconFile=internal\tray\icon.ico +UninstallDisplayIcon={app}\{#AppExeName} +UninstallDisplayName={#AppName} {#AppVersion} + +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +ShowLanguageDialog=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Shortcuts:" +Name: "startup"; Description: "Start {#AppName} when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked + +[Files] +Source: "bin\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}" +Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Registry] +; --background suppresses the browser tab at login. A launch from a shortcut +; has no flag and does open the editor, which is the behaviour the shortcuts +; above rely on. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: string; ValueName: "{#AppName}"; \ + ValueData: """{app}\{#AppExeName}"" --background"; \ + Flags: uninsdeletevalue; Tasks: startup + +; Even when the startup task is not chosen, remove any Run entry the app itself +; wrote from its settings screen — otherwise uninstalling leaves Windows trying +; to launch a binary that is gone. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: none; ValueName: "{#AppName}"; \ + Flags: uninsdeletevalue; Tasks: not startup + +[Run] +; No --background here: finishing setup should show the user the editor, which +; is also what starts the tray. +Filename: "{app}\{#AppExeName}"; Description: "Start {#AppName} and open the preset editor"; \ + Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; The control channel's session file is runtime state, not user data. +Type: files; Name: "{app}\session.json" + +; presets.json, config.json and ipswap.log are deliberately NOT listed. They are +; the user's data — preset packs represent real work — and Inno only removes +; files it installed, so {app} survives uninstall with that data intact. diff --git a/Makefile b/Makefile index 3b2245e..a38df86 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ GOVERSIONINFO ?= $(GO) run github.com/josephspurrier/goversioninfo/cmd/goversion SYSO := cmd/ipswap/resource_windows.syso -.PHONY: build syso test vet check dist sums clean help +.PHONY: build syso test vet check dist sums installer clean help help: ## List targets @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-14s %s\n", $$1, $$2}' @@ -57,6 +57,24 @@ check: test vet ## Typecheck both targets and run tests dist: build sums ## Build the release artifacts into dist/ @echo "dist/ contains:" && ls -1 dist +# Inno Setup is a Windows tool. On Windows use the ISCC on PATH; on Linux it +# runs perfectly well under wine, which is why this looks for both. Note that +# the installer is the ONLY part of the build that wants wine — the .exe itself +# still cross-compiles natively, so CI never needs it. +ISCC ?= $(shell command -v iscc 2>/dev/null || command -v ISCC 2>/dev/null) + +installer: build ## Compile Output/ipswap-$(VERSION)-setup.exe (needs Inno Setup 6) +ifeq ($(ISCC),) + @echo "Inno Setup not found."; \ + echo " Windows: install Inno Setup 6 and put ISCC.exe on PATH"; \ + echo " Linux: apt install wine && download innosetup-6.x.exe, then"; \ + echo " make installer ISCC='wine $$HOME/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe'"; \ + exit 1 +else + $(ISCC) /DAppVersion=$(VERSION) Inno-compile.iss + @echo "built Output/ipswap-$(VERSION)-setup.exe" +endif + sums: ## Write dist/SHA256SUMS for the built binary @mkdir -p dist @cp bin/ipswap.exe dist/ipswap.exe @@ -64,4 +82,4 @@ sums: ## Write dist/SHA256SUMS for the built binary @cat dist/SHA256SUMS clean: ## Remove build artifacts - rm -rf bin dist $(SYSO) + rm -rf bin dist Output $(SYSO) diff --git a/README.md b/README.md index 998aed1..ca82a32 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,28 @@ sequence. The tray tooltip then names the preset that is actually applied. **Slow path (browser).** "Manage presets…" starts a local HTTP server on a random loopback port and opens your default browser to it. Full CRUD, adapter -picker, import/export, settings. The server shuts itself down five minutes -after the browser stops sending heartbeats, and on exit. +picker, import/export, settings, and switching. The server shuts itself down +five minutes after the browser stops sending heartbeats, and on exit. + +Switching works from either side. The tray confirms with a native MessageBox; +the browser shows the identical before/after text in a modal. Both read the +adapter live at prompt time and both go through the same code — see +`internal/switcher`, which exists so that path is written once. + +## Launching + +Opening ipswap — from the desktop shortcut, the Start menu, or by running the +.exe — opens the editor in your browser. If ipswap is not already running that +also starts the tray; if it is, the running copy opens the browser and the +second process exits. You never get two tray icons. + +That is done with a loopback control listener owned by the running instance, +whose port and token live in `%APPDATA%\ipswap\session.json` (mode 600). A +second launch reads that file, calls the listener and exits. On Windows a +session-local named mutex settles who is primary when two launches race. + +The start-with-Windows entry passes `--background`, which starts the tray +without opening a browser. Nobody wants a browser tab at login. ## Building @@ -26,9 +46,10 @@ Pure Go, no cgo anywhere, so a Linux or macOS host cross-compiles the Windows binary directly — no Wine, no mingw, no fyne-cross. ``` -make build # -> bin/ipswap.exe -make check # tests + vet + typecheck for both targets -make dist # bin/ipswap.exe plus dist/SHA256SUMS +make build # -> bin/ipswap.exe +make check # tests + vet + typecheck for both targets +make dist # bin/ipswap.exe plus dist/SHA256SUMS +make installer # -> Output/ipswap--setup.exe (needs Inno Setup) ``` `make build` regenerates `cmd/ipswap/resource_windows.syso` first. That object @@ -39,12 +60,39 @@ part that matters, because it is what asks for `asInvoker` and per-monitor DPI. Windows-only files (netsh, user32, the registry) are most of the risk and a plain Linux build never looks at them. `make vet` does both targets. +`go run ./cmd/devserver` starts the editor alone against a throwaway preset +file, so the web UI can be opened on a non-Windows box. It is a development +aid; the shipped binary is `cmd/ipswap` only. + +### Installer + +`Inno-compile.iss` builds a **per-user** installer: it installs to +`%APPDATA%\ipswap` with `PrivilegesRequired=lowest`, so it never shows a UAC +prompt. That is deliberate and matches the app — ipswap ships an `asInvoker` +manifest, and an installer demanding elevation would undo the point. It also +means the in-app updater can replace the .exe without a privilege prompt, +which a Program Files install would not allow. + +It offers a desktop shortcut and an optional start-with-Windows entry, and it +uses the app's own single-instance mutex as `AppMutex` so setup notices a +running copy — a running .exe on Windows cannot be overwritten. + +Uninstalling leaves `presets.json`, `config.json` and the log alone. Inno only +removes what it installed, and preset packs represent real work. + +Inno Setup is a Windows tool, so this is the one build step that is not +cross-platform; it runs fine under wine if you would rather not use Windows. +The `.exe` itself still cross-compiles natively, so CI never needs wine. + ## Layout ``` cmd/ipswap/ entrypoint, manifest, version resource +cmd/devserver/ runs the editor alone for development internal/preset/ data model, store, prefix parsing, import/export internal/netcfg/ adapter reads (Win32) and the netsh command plan +internal/switcher/ the one place a preset is applied; both front ends use it +internal/instance/ single-instance lock and the launch control channel internal/tray/ tray menu, confirm-then-apply, icon internal/server/ the local editor server and its embedded web app internal/updater/ Gitea release check, download, verify, swap @@ -55,6 +103,12 @@ internal/config/ paths and settings internal/applog/ rotating log ``` +`internal/server/web/apointless.css` is vendored verbatim from +[bsncubed/css](https://gitea.apointless.space/bsncubed/css) — re-pull it to +update, do not edit it here. `ipswap.css` beside it adds only what the design +system does not ship (page chrome, tables, modals, stat tiles) and is built +strictly on its tokens, so a refreshed apointless.css restyles the app too. + Two rules shape the split. Anything portable lives in portable code and is tested on any host — most usefully `netcfg.Plan`, which builds the exact netsh sequence and is covered without a Windows box. Anything Windows-specific has a diff --git a/cmd/devserver/main.go b/cmd/devserver/main.go new file mode 100644 index 0000000..d55b942 --- /dev/null +++ b/cmd/devserver/main.go @@ -0,0 +1,61 @@ +// Command devserver runs the editor server alone, against a throwaway preset +// file, so the web UI can be opened during development on a non-Windows box. +// +// It is a development aid, not part of the shipped product: the real server is +// started on demand by the tray. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "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" +) + +func main() { + dir := flag.String("dir", "", "data directory (default: a temp dir seeded with examples/preset-pack.json)") + flag.Parse() + + d := *dir + if d == "" { + var err error + d, err = os.MkdirTemp("", "ipswap-dev-") + if err != nil { + log.Fatal(err) + } + seed, err := os.ReadFile(filepath.Join("examples", "preset-pack.json")) + if err == nil { + _ = os.WriteFile(filepath.Join(d, "presets.json"), seed, 0o644) + } + } + + paths := config.Paths{ + Dir: d, + Presets: filepath.Join(d, "presets.json"), + Config: filepath.Join(d, "config.json"), + Log: filepath.Join(d, "ipswap.log"), + } + + store, err := preset.NewStore(paths.Presets) + if err != nil { + log.Fatal(err) + } + + mgr := netcfg.New() + srv := server.New(store, switcher.New(store, mgr), mgr, paths, config.Default()) + + url, err := srv.Start() + if err != nil { + log.Fatal(err) + } + + fmt.Println(url) + select {} // serve until killed +} diff --git a/cmd/ipswap/main.go b/cmd/ipswap/main.go index 7cf4680..a70ef41 100644 --- a/cmd/ipswap/main.go +++ b/cmd/ipswap/main.go @@ -8,6 +8,7 @@ package main import ( + "errors" "flag" "fmt" "log" @@ -18,9 +19,11 @@ import ( "gitea.apointless.space/bsncubed/ipswap/internal/config" "gitea.apointless.space/bsncubed/ipswap/internal/desktop" "gitea.apointless.space/bsncubed/ipswap/internal/elevate" + "gitea.apointless.space/bsncubed/ipswap/internal/instance" "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/tray" ) @@ -29,6 +32,7 @@ var version = "dev" func main() { showVersion := flag.Bool("version", false, "print the version and exit") + background := flag.Bool("background", false, "start the tray without opening the editor (used by the start-with-Windows entry)") flag.Parse() if *showVersion { @@ -36,14 +40,14 @@ func main() { return } - if err := run(); err != nil { + if err := run(*background); err != nil { // With -H windowsgui there is no stderr to read, so anything fatal has // to be shown rather than printed. fatal(err) } } -func run() error { +func run(background bool) error { paths, err := config.ResolvePaths() if err != nil { return err @@ -57,7 +61,20 @@ func run() error { } defer logw.Close() - log.Printf("--- %s %s starting (elevated=%v) ---", config.AppName, version, elevate.IsElevated()) + log.Printf("--- %s %s starting (elevated=%v, background=%v) ---", + config.AppName, version, elevate.IsElevated(), background) + + // Opening ipswap when it is already running should show the editor, not + // start a second tray icon. Hand the request to the running copy and stop. + if background { + if instance.IsRunning(paths) { + log.Printf("another instance is already running; exiting") + return nil + } + } else if err := instance.Signal(paths); err == nil { + log.Printf("another instance is running; it will open the editor") + return nil + } settings, err := config.Load(paths.Config) if err != nil { @@ -81,8 +98,25 @@ func run() error { } mgr := netcfg.New() - srv := server.New(store, mgr, paths, settings) - app := tray.New(store, mgr, srv, paths, settings, version) + sw := switcher.New(store, mgr) + srv := server.New(store, sw, mgr, paths, settings) + app := tray.New(store, sw, srv, paths, settings, version) + app.OpenOnStart = !background + + // Claim the single-instance slot and publish the control address, so a + // later launch can find this process instead of starting its own tray. + primary, err := instance.Acquire(paths, app.OpenEditor) + if err != nil { + if errors.Is(err, instance.ErrAlreadyRunning) { + // Lost a race with a simultaneous launch. That copy owns the tray; + // let it open the editor. + log.Printf("lost the single-instance race; deferring to the other process") + _ = instance.Signal(paths) + return nil + } + return err + } + defer primary.Release() // systray.Run takes over this goroutine and does not return until Exit. app.Run() diff --git a/internal/desktop/desktop_windows.go b/internal/desktop/desktop_windows.go index c993a2c..10a3dda 100644 --- a/internal/desktop/desktop_windows.go +++ b/internal/desktop/desktop_windows.go @@ -78,10 +78,13 @@ func SetRunAtLogin(enabled bool) error { return err } - // Quoted: the path routinely contains spaces (Program Files, or a user - // profile with a space in the name) and the Run key value is a command - // line, not a path. - if err := key.SetStringValue(config.AppName, `"`+exe+`"`); err != nil { + // Quoted: the path routinely contains spaces (the install lives under + // %APPDATA%, and a user profile can have a space in its name) and the Run + // key value is a command line, not a path. + // + // --background is what stops a browser tab appearing at every login. A + // launch from the shortcut has no such flag and does open the editor. + if err := key.SetStringValue(config.AppName, `"`+exe+`" --background`); err != nil { return fmt.Errorf("writing the Run key value: %w", err) } return nil diff --git a/internal/instance/instance.go b/internal/instance/instance.go new file mode 100644 index 0000000..df9fc87 --- /dev/null +++ b/internal/instance/instance.go @@ -0,0 +1,265 @@ +// Package instance makes ipswap single-instance and gives a second launch a +// way to talk to the first. +// +// The behaviour it implements: double-clicking the shortcut should open the +// editor in a browser. If ipswap is not running yet, that means starting the +// tray and opening the browser. If it is already running, it means asking the +// running copy to open the browser — not starting a second tray icon, and not +// silently doing nothing. +// +// The mechanism is a small loopback control listener owned by the primary +// instance, whose port and token are published in a session file. A second +// launch reads that file, calls the listener and exits. The session file is +// authoritative about *how to talk to* the primary; on Windows a named mutex +// additionally settles *who is* the primary, closing the race between two +// launches starting at the same moment. +package instance + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" +) + +// dialTimeout bounds every call to a possibly-dead primary. A stale session +// file must not add a visible delay to startup. +const dialTimeout = 1500 * time.Millisecond + +// ErrNoPrimary means no other instance is running. +var ErrNoPrimary = errors.New("no running ipswap instance") + +// session is the contents of session.json. +type session struct { + Port int `json:"port"` + Token string `json:"token"` + PID int `json:"pid"` +} + +func sessionPath(paths config.Paths) string { + return filepath.Join(paths.Dir, "session.json") +} + +// Primary is the control listener owned by the running instance. +type Primary struct { + path string + token string + + mu sync.Mutex + ln net.Listener + srv *http.Server + lock releaser +} + +// releaser is whatever the platform uses to claim single-instance ownership. +type releaser interface{ release() } + +// Acquire makes this process the primary instance and starts its control +// listener. onOpenEditor is called when another launch asks for the editor. +// +// It returns ErrAlreadyRunning if another instance already holds the slot; the +// caller should then call Signal and exit. +func Acquire(paths config.Paths, onOpenEditor func()) (*Primary, error) { + // Take the OS-level lock first: it is the only thing that makes two + // simultaneous launches deterministic. + lock, err := lockProcess() + if err != nil { + return nil, err + } + + if err := os.MkdirAll(paths.Dir, 0o755); err != nil { + lock.release() + return nil, fmt.Errorf("creating the data directory: %w", err) + } + + token, err := newToken() + if err != nil { + lock.release() + return nil, err + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + lock.release() + return nil, fmt.Errorf("binding the control port: %w", err) + } + + p := &Primary{path: sessionPath(paths), token: token, ln: ln, lock: lock} + + mux := http.NewServeMux() + mux.HandleFunc("POST /ping", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("POST /open", func(w http.ResponseWriter, r *http.Request) { + // Answer first, then act: the caller is a process waiting to exit, and + // opening a browser can take a moment. + w.WriteHeader(http.StatusOK) + if onOpenEditor != nil { + go onOpenEditor() + } + }) + + p.srv = &http.Server{ + Handler: p.withToken(mux), + ReadHeaderTimeout: 5 * time.Second, + } + + if err := p.writeSession(ln.Addr().(*net.TCPAddr).Port, token); err != nil { + ln.Close() + lock.release() + return nil, err + } + + // Capture the handles before serving: Release nils the fields under the + // mutex, and the serving goroutine must not read them concurrently. + srv := p.srv + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Printf("control listener stopped: %v", err) + } + }() + + log.Printf("control listener on 127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port) + return p, nil +} + +func (p *Primary) withToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Loopback plus a per-run token: enough to stop a stray page in the + // browser poking the control channel, which is all it needs to resist. + if r.Header.Get("X-Ipswap-Control") != p.token { + http.Error(w, "unauthorised", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// writeSession publishes the control address atomically, so a second launch +// can never read a half-written file. +func (p *Primary) writeSession(port int, token string) error { + b, err := json.MarshalIndent(session{Port: port, Token: token, PID: os.Getpid()}, "", " ") + if err != nil { + return err + } + + tmp, err := os.CreateTemp(filepath.Dir(p.path), ".session-*.json") + if err != nil { + return fmt.Errorf("creating the session file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + // 0600: the token in here is what authorises control calls. + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, p.path) +} + +// Release shuts the control listener down and removes the session file. +func (p *Primary) Release() { + p.mu.Lock() + srv, lock := p.srv, p.lock + p.srv, p.ln, p.lock = nil, nil, nil + p.mu.Unlock() + + if srv != nil { + _ = srv.Close() + } + // Remove the session file before dropping the lock, so a launch that gets + // the lock next never sees this instance's stale address. + _ = os.Remove(p.path) + if lock != nil { + lock.release() + } +} + +// Signal asks an already-running instance to open the editor. It returns +// ErrNoPrimary when nothing is listening, which is the caller's cue to start +// up normally. +func Signal(paths config.Paths) error { + s, err := readSession(sessionPath(paths)) + if err != nil { + return err + } + return call(s, "/open") +} + +// IsRunning reports whether another instance answers on the control channel. +func IsRunning(paths config.Paths) bool { + s, err := readSession(sessionPath(paths)) + if err != nil { + return false + } + return call(s, "/ping") == nil +} + +func readSession(path string) (session, error) { + b, err := os.ReadFile(path) + if err != nil { + return session{}, ErrNoPrimary + } + var s session + if err := json.Unmarshal(b, &s); err != nil || s.Port == 0 || s.Token == "" { + return session{}, ErrNoPrimary + } + return s, nil +} + +func call(s session, path string) error { + url := fmt.Sprintf("http://127.0.0.1:%d%s", s.Port, path) + + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return err + } + req.Header.Set("X-Ipswap-Control", s.Token) + + client := &http.Client{Timeout: dialTimeout} + resp, err := client.Do(req) + if err != nil { + // A session file left behind by a crash points at a port nothing is + // listening on, or one since reused by an unrelated process. + return ErrNoPrimary + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + + if resp.StatusCode != http.StatusOK { + return ErrNoPrimary + } + return nil +} + +func newToken() (string, error) { + var b [32]byte + if _, err := readRandom(b[:]); err != nil { + return "", fmt.Errorf("generating a control token: %w", err) + } + const hex = "0123456789abcdef" + var sb strings.Builder + for _, c := range b { + sb.WriteByte(hex[c>>4]) + sb.WriteByte(hex[c&0x0f]) + } + return sb.String(), nil +} diff --git a/internal/instance/instance_test.go b/internal/instance/instance_test.go new file mode 100644 index 0000000..25363b6 --- /dev/null +++ b/internal/instance/instance_test.go @@ -0,0 +1,169 @@ +package instance + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" +) + +func testPaths(t *testing.T) config.Paths { + t.Helper() + dir := t.TempDir() + return config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json")} +} + +func TestSignalWithNoPrimary(t *testing.T) { + paths := testPaths(t) + + if err := Signal(paths); !errors.Is(err, ErrNoPrimary) { + t.Errorf("Signal with no session file = %v, want ErrNoPrimary", err) + } + if IsRunning(paths) { + t.Error("IsRunning should be false with no session file") + } +} + +func TestAcquireThenSignalOpensEditor(t *testing.T) { + paths := testPaths(t) + + var opened atomic.Int32 + p, err := Acquire(paths, func() { opened.Add(1) }) + if err != nil { + t.Fatal(err) + } + defer p.Release() + + if !IsRunning(paths) { + t.Fatal("IsRunning should be true once a primary is up") + } + + if err := Signal(paths); err != nil { + t.Fatalf("Signal returned %v", err) + } + + // The handler answers before running the callback, so give it a moment. + deadline := time.Now().Add(2 * time.Second) + for opened.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if opened.Load() != 1 { + t.Errorf("expected the open callback to fire once, got %d", opened.Load()) + } +} + +func TestSessionFileIsPrivateAndWellFormed(t *testing.T) { + paths := testPaths(t) + + p, err := Acquire(paths, nil) + if err != nil { + t.Fatal(err) + } + defer p.Release() + + path := filepath.Join(paths.Dir, "session.json") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + // The token in here authorises control calls, so it must not be readable + // by other users on a shared machine. + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("session file mode = %o, want 600", perm) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var s session + if err := json.Unmarshal(b, &s); err != nil { + t.Fatal(err) + } + if s.Port == 0 || s.Token == "" || s.PID != os.Getpid() { + t.Errorf("session file looks wrong: %+v", s) + } +} + +func TestReleaseRemovesSessionFile(t *testing.T) { + paths := testPaths(t) + + p, err := Acquire(paths, nil) + if err != nil { + t.Fatal(err) + } + p.Release() + + if _, err := os.Stat(filepath.Join(paths.Dir, "session.json")); !os.IsNotExist(err) { + t.Error("Release should remove the session file") + } + if IsRunning(paths) { + t.Error("IsRunning should be false after Release") + } +} + +// A crash leaves a session file pointing at a dead port. Startup must treat +// that as "no primary" rather than hanging or refusing to start. +func TestStaleSessionFileIsIgnored(t *testing.T) { + paths := testPaths(t) + + stale, _ := json.Marshal(session{Port: 1, Token: "dead", PID: 999999}) + if err := os.WriteFile(filepath.Join(paths.Dir, "session.json"), stale, 0o600); err != nil { + t.Fatal(err) + } + + start := time.Now() + if IsRunning(paths) { + t.Error("a stale session file must not look like a running instance") + } + if err := Signal(paths); !errors.Is(err, ErrNoPrimary) { + t.Errorf("Signal against a stale file = %v, want ErrNoPrimary", err) + } + // Startup waits on this, so it must fail fast rather than block. + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("stale session detection took %s, too slow for startup", elapsed) + } +} + +func TestGarbageSessionFileIsIgnored(t *testing.T) { + paths := testPaths(t) + + if err := os.WriteFile(filepath.Join(paths.Dir, "session.json"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if IsRunning(paths) { + t.Error("a corrupt session file must not look like a running instance") + } +} + +// The control channel must not answer without the token. +func TestControlRequiresToken(t *testing.T) { + paths := testPaths(t) + + var opened atomic.Int32 + p, err := Acquire(paths, func() { opened.Add(1) }) + if err != nil { + t.Fatal(err) + } + defer p.Release() + + s, err := readSession(filepath.Join(paths.Dir, "session.json")) + if err != nil { + t.Fatal(err) + } + + wrong := session{Port: s.Port, Token: "not-the-token"} + if err := call(wrong, "/open"); !errors.Is(err, ErrNoPrimary) { + t.Errorf("call with a bad token = %v, want it rejected", err) + } + + time.Sleep(100 * time.Millisecond) + if opened.Load() != 0 { + t.Error("an unauthorised control call must not open the editor") + } +} diff --git a/internal/instance/lock_other.go b/internal/instance/lock_other.go new file mode 100644 index 0000000..6c22e99 --- /dev/null +++ b/internal/instance/lock_other.go @@ -0,0 +1,18 @@ +//go:build !windows + +package instance + +import "errors" + +// ErrAlreadyRunning means another instance holds the single-instance slot. +var ErrAlreadyRunning = errors.New("another instance of ipswap is already running") + +type noLock struct{} + +func (noLock) release() {} + +// lockProcess is a no-op off Windows: there is no named mutex, and the control +// listener alone is enough to detect a running instance during development. +// The consequence is only that two launches racing in the same millisecond +// could both become primary, which does not matter on a dev box. +func lockProcess() (releaser, error) { return noLock{}, nil } diff --git a/internal/instance/lock_windows.go b/internal/instance/lock_windows.go new file mode 100644 index 0000000..7f3fd1c --- /dev/null +++ b/internal/instance/lock_windows.go @@ -0,0 +1,51 @@ +//go:build windows + +package instance + +import ( + "errors" + "fmt" + + "golang.org/x/sys/windows" +) + +// ErrAlreadyRunning means another instance holds the single-instance mutex. +var ErrAlreadyRunning = errors.New("another instance of ipswap is already running") + +// mutexName is session-local rather than Global\: ipswap is a per-user tray +// app, and two users logged into the same machine should each get their own. +const mutexName = `Local\ipswap-single-instance` + +type winLock struct{ h windows.Handle } + +func (l winLock) release() { + if l.h != 0 { + windows.ReleaseMutex(l.h) + windows.CloseHandle(l.h) + } +} + +// lockProcess claims the single-instance slot. +// +// CreateMutexW succeeds even when the mutex already exists, so the existing +// object is detected via GetLastError rather than the return value. The handle +// is kept for the process lifetime; Windows releases it automatically if we +// crash, which is what stops a hard kill from locking the user out. +func lockProcess() (releaser, error) { + name, err := windows.UTF16PtrFromString(mutexName) + if err != nil { + return nil, err + } + + h, err := windows.CreateMutex(nil, false, name) + if err != nil { + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + if h != 0 { + windows.CloseHandle(h) + } + return nil, ErrAlreadyRunning + } + return nil, fmt.Errorf("creating the single-instance mutex: %w", err) + } + return winLock{h: h}, nil +} diff --git a/internal/instance/random.go b/internal/instance/random.go new file mode 100644 index 0000000..c4a6c1d --- /dev/null +++ b/internal/instance/random.go @@ -0,0 +1,8 @@ +package instance + +import "crypto/rand" + +// readRandom exists so newToken stays free of a direct crypto/rand import in +// the file that handles the control protocol, keeping that file about the +// protocol rather than about entropy. +func readRandom(b []byte) (int, error) { return rand.Read(b) } diff --git a/internal/server/server.go b/internal/server/server.go index e466f4a..6140fa8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "embed" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -30,6 +31,7 @@ import ( "gitea.apointless.space/bsncubed/ipswap/internal/config" "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" "gitea.apointless.space/bsncubed/ipswap/internal/preset" + "gitea.apointless.space/bsncubed/ipswap/internal/switcher" ) //go:embed web @@ -47,6 +49,7 @@ const ( // Server hosts the editor. type Server struct { store *preset.Store + sw *switcher.Switcher mgr netcfg.Manager paths config.Paths @@ -55,6 +58,9 @@ type Server struct { onSettingsChange func(config.Settings) // onPresetsChange asks the tray to rebuild its menu. onPresetsChange func() + // onApplied tells the tray to re-read the active preset after the browser + // switched one, so the check mark and tooltip do not go stale. + onApplied func() mu sync.Mutex settings config.Settings @@ -66,8 +72,8 @@ type Server struct { } // New builds a server. It does not listen until Start is called. -func New(store *preset.Store, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server { - return &Server{store: store, mgr: mgr, paths: paths, settings: settings} +func New(store *preset.Store, sw *switcher.Switcher, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server { + return &Server{store: store, sw: sw, mgr: mgr, paths: paths, settings: settings} } // OnSettingsChange registers a callback fired after settings are saved. @@ -76,6 +82,9 @@ func (s *Server) OnSettingsChange(f func(config.Settings)) { s.onSettingsChange // OnPresetsChange registers a callback fired after any preset mutation. func (s *Server) OnPresetsChange(f func()) { s.onPresetsChange = f } +// OnApplied registers a callback fired after a successful apply. +func (s *Server) OnApplied(f func()) { s.onApplied = f } + // URL returns the address to open, including the session token. It is empty // when the server is not running. func (s *Server) URL() string { @@ -228,6 +237,13 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("PUT /api/presets/{id}", s.handlePutPreset) mux.HandleFunc("DELETE /api/presets/{id}", s.handleDeletePreset) + // Switching from the browser. The preview is a separate GET so the + // confirmation the user sees is built from a live read, exactly like the + // tray's MessageBox, rather than from whatever the list happened to say. + mux.HandleFunc("GET /api/active", s.handleActive) + mux.HandleFunc("GET /api/presets/{id}/preview", s.handlePreview) + mux.HandleFunc("POST /api/presets/{id}/apply", s.handleApply) + mux.HandleFunc("GET /api/adapters", s.handleAdapters) mux.HandleFunc("GET /api/settings", s.handleGetSettings) mux.HandleFunc("PUT /api/settings", s.handlePutSettings) @@ -327,6 +343,62 @@ func (s *Server) handleDeletePreset(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +// handleActive reports which preset is currently applied, so the editor can +// mark it and so a switch initiated there updates without a reload. +func (s *Server) handleActive(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.sw.Active()) +} + +// handlePreview returns the before/after the browser confirms against. +// +// The tray blocks on a native MessageBox before applying. The browser cannot, +// so it gets the same diff as data and confirms in the page. Same safety net, +// same live read — only the widget differs. +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + p, live, err := s.sw.Preview(r.PathValue("id")) + if err != nil { + if errors.Is(err, switcher.ErrNotFound) { + writeErr(w, http.StatusNotFound, err) + return + } + writeErr(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "preset": p, + "current": live, + "text": netcfg.ConfirmText(p, live), + "matches": netcfg.Matches(p, live), + }) +} + +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + if err := s.sw.Apply(id); err != nil { + switch { + case errors.Is(err, switcher.ErrNotFound): + writeErr(w, http.StatusNotFound, err) + case errors.Is(err, netcfg.ErrElevationRequired): + // 409 rather than 500: the request was fine, the process just is + // not elevated. The page turns this into the relaunch hint. + writeJSON(w, http.StatusConflict, map[string]any{ + "error": err.Error(), + "elevation_required": true, + }) + default: + writeErr(w, http.StatusInternalServerError, err) + } + return + } + + if s.onApplied != nil { + s.onApplied() + } + writeJSON(w, http.StatusOK, s.sw.Active()) +} + func (s *Server) handleAdapters(w http.ResponseWriter, r *http.Request) { adapters, err := s.mgr.Adapters() if err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 96b03b5..d46d357 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -12,6 +12,7 @@ import ( "gitea.apointless.space/bsncubed/ipswap/internal/config" "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" "gitea.apointless.space/bsncubed/ipswap/internal/preset" + "gitea.apointless.space/bsncubed/ipswap/internal/switcher" ) func newTestServer(t *testing.T) (*Server, string, string) { @@ -24,7 +25,8 @@ func newTestServer(t *testing.T) (*Server, string, string) { } paths := config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json"), Config: filepath.Join(dir, "config.json")} - s := New(store, netcfg.New(), paths, config.Default()) + mgr := netcfg.New() + s := New(store, switcher.New(store, mgr), mgr, paths, config.Default()) raw, err := s.Start() if err != nil { t.Fatal(err) @@ -190,6 +192,108 @@ func TestInvalidPresetIsRejected(t *testing.T) { } } +// createPreset returns the id of a freshly saved DHCP preset. +func createPreset(t *testing.T, base, token string) string { + t.Helper() + body := `{"name":"DHCP","group":"General","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}` + resp := do(t, "POST", base+"/api/presets", token, token, body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("could not create preset: %d %s", resp.StatusCode, b) + } + var p preset.Preset + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatal(err) + } + return p.ID +} + +func TestActiveReportsAdapterState(t *testing.T) { + _, base, token := newTestServer(t) + createPreset(t, base, token) + + resp := do(t, "GET", base+"/api/active", token, "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var st struct { + ActiveID string `json:"active_id"` + Adapters map[string]netcfg.LiveConfig `json:"adapters"` + } + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatal(err) + } + if _, ok := st.Adapters["Ethernet"]; !ok { + t.Errorf("expected the preset's adapter to be reported, got %+v", st.Adapters) + } +} + +// The preview is what the browser confirms against, so it must carry the same +// before/after text the tray's MessageBox shows. +func TestPreviewReturnsConfirmText(t *testing.T) { + _, base, token := newTestServer(t) + id := createPreset(t, base, token) + + resp := do(t, "GET", base+"/api/presets/"+id+"/preview", token, "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var out struct { + Text string `json:"text"` + Current netcfg.LiveConfig `json:"current"` + Preset preset.Preset `json:"preset"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + for _, want := range []string{"CURRENT", "NEW", `Apply preset "DHCP"`} { + if !strings.Contains(out.Text, want) { + t.Errorf("preview text missing %q:\n%s", want, out.Text) + } + } + if out.Preset.ID != id { + t.Errorf("preview returned preset %q, want %q", out.Preset.ID, id) + } +} + +func TestPreviewUnknownPresetIs404(t *testing.T) { + _, base, token := newTestServer(t) + + resp := do(t, "GET", base+"/api/presets/nope/preview", token, "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +// Applying changes the machine's network, so it must be as protected as any +// other mutation — a cookie alone must not be enough. +func TestApplyRequiresTokenHeader(t *testing.T) { + _, base, token := newTestServer(t) + id := createPreset(t, base, token) + + resp := do(t, "POST", base+"/api/presets/"+id+"/apply", token, "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("cookie-only apply should be 403, got %d", resp.StatusCode) + } +} + +func TestApplyUnknownPresetIs404(t *testing.T) { + _, base, token := newTestServer(t) + + resp := do(t, "POST", base+"/api/presets/nope/apply", token, token, "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + func TestStopRejectsSubsequentRequests(t *testing.T) { s, base, token := newTestServer(t) s.Stop() diff --git a/internal/server/web/apointless.css b/internal/server/web/apointless.css new file mode 100644 index 0000000..fa9711d --- /dev/null +++ b/internal/server/web/apointless.css @@ -0,0 +1,293 @@ +/* apointless.css — dark-first design system + JetBrains Mono + DM Sans, blue accent, subtle grid. */ + +:root { + --bg: #0b0d11; + --surface-1: #12151b; + --surface-2: #1a1e28; + --surface-3: #232838; + + --border: #232838; + --border-strong: #313850; + + --text: #e6e9ef; + --text-muted: #949cad; + --text-faint: #626a7a; + + --accent: #3b82f6; + --accent-hover: #2f6fd8; + --accent-soft: rgba(59, 130, 246, 0.14); + --accent-line: rgba(59, 130, 246, 0.05); + + --ok: #22c55e; + --warn: #f59e0b; + --error: #ef4444; + --info: #38bdf8; + + --ok-soft: rgba(34, 197, 94, 0.13); + --warn-soft: rgba(245, 158, 11, 0.13); + --error-soft: rgba(239, 68, 68, 0.13); + --info-soft: rgba(56, 189, 248, 0.13); + + --font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 12px; + + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.28); +} + +html.light { + --bg: #f6f7f9; + --surface-1: #ffffff; + --surface-2: #f0f2f6; + --surface-3: #e3e7ee; + + --border: #dfe3ea; + --border-strong: #c3cad6; + + --text: #131720; + --text-muted: #5b6472; + --text-faint: #8a93a3; + + --accent-soft: rgba(59, 130, 246, 0.11); + --accent-line: rgba(59, 130, 246, 0.06); + + --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.07); +} + +* { box-sizing: border-box; } + +/* Any component with an explicit `display` beats the UA stylesheet's + [hidden] { display: none }, so .alert, .card etc. would stay visible when + marked hidden. Restore the expected behaviour once, globally. */ +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + linear-gradient(var(--accent-line) 1px, transparent 1px), + linear-gradient(90deg, var(--accent-line) 1px, transparent 1px); + background-size: 32px 32px; +} + +h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.015em; } +h1 { font-size: 1.25rem; } +h2 { font-size: 1.05rem; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---------------------------------------------------------------- card --- */ + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.card-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.card-body { padding: 16px; } + +/* -------------------------------------------------------------- badges --- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.badge-ok { background: var(--ok-soft); border-color: transparent; color: var(--ok); } +.badge-warn { background: var(--warn-soft); border-color: transparent; color: var(--warn); } +.badge-error { background: var(--error-soft); border-color: transparent; color: var(--error); } +.badge-accent { background: var(--accent-soft); border-color: transparent; color: var(--accent); } + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex: none; +} + +.dot-pulse { animation: pulse 2.4s ease-in-out infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* --------------------------------------------------------------- pills --- */ + +.pill { + display: inline-flex; + align-items: center; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.75rem; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +.pill:hover { background: var(--surface-3); color: var(--text); } +.pill[aria-pressed="true"] { + background: var(--accent-soft); + border-color: transparent; + color: var(--accent); +} + +/* ------------------------------------------------------------- buttons --- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 16px; + border-radius: var(--radius); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, opacity 0.12s; +} + +.btn:hover:not(:disabled) { background: var(--surface-3); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } + +.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); } +.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } + +.btn-sm { padding: 5px 11px; font-size: 0.8rem; } + +/* -------------------------------------------------------------- inputs --- */ + +.input { + width: 100%; + padding: 10px 13px; + border-radius: var(--radius); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.88rem; +} + +.input::placeholder { color: var(--text-faint); } +.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + +.label { + display: block; + margin-bottom: 6px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* -------------------------------------------------------------- alerts --- */ + +.alert { + display: flex; + gap: 10px; + padding: 11px 14px; + border-radius: var(--radius); + border: 1px solid transparent; + font-size: 0.87rem; +} + +.alert-error { background: var(--error-soft); border-color: rgba(239, 68, 68, 0.3); color: var(--error); } +.alert-warn { background: var(--warn-soft); border-color: rgba(245, 158, 11, 0.3); color: var(--warn); } +.alert-info { background: var(--info-soft); border-color: rgba(56, 189, 248, 0.3); color: var(--info); } + +/* ---------------------------------------------------------------- code --- */ + +code, .mono { font-family: var(--font-mono); font-size: 0.85em; } + +.code-block { + padding: 12px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + font-family: var(--font-mono); + font-size: 0.82rem; + white-space: pre-wrap; + overflow-x: auto; +} + +/* ------------------------------------------------------------- spinner --- */ + +.spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + animation: spin 0.7s linear infinite; + flex: none; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/internal/server/web/app.css b/internal/server/web/app.css deleted file mode 100644 index ef16a97..0000000 --- a/internal/server/web/app.css +++ /dev/null @@ -1,258 +0,0 @@ -/* Design tokens - kept as CSS custom properties under these exact names so a - real apointless.css can drop in later without touching any template. */ -:root { - --bg: #0b0d11; - --surface-1: #12151b; - --surface-2: #1a1e28; - --surface-3: #232838; - --border: #2a3040; - --text-primary: #e6e9ef; - --text-secondary: #9aa3b2; - --accent: #3b82f6; - --accent-hover: #5b95f7; - --success: #22c55e; - --danger: #ef4444; - --warning: #f59e0b; - --font-body: "DM Sans", system-ui, -apple-system, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; - --radius: 10px; -} - -html.light { - --bg: #f5f6f8; - --surface-1: #ffffff; - --surface-2: #f0f1f4; - --surface-3: #e5e7eb; - --border: #d7dae0; - --text-primary: #14161a; - --text-secondary: #565d6b; - --accent: #2563eb; - --accent-hover: #1d4ed8; -} - -* { box-sizing: border-box; } - -html, body { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--text-primary); - font-family: var(--font-body); - min-height: 100%; -} - -a { color: var(--accent); text-decoration: none; } -a:hover { color: var(--accent-hover); } - -.muted { color: var(--text-secondary); } - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.9rem 1.5rem; - background: var(--surface-1); - border-bottom: 1px solid var(--border); -} - -.topbar-nav { display: flex; align-items: center; gap: 1.25rem; } -.topbar-nav a { color: var(--text-secondary); font-weight: 500; } -.topbar-nav a:hover { color: var(--text-primary); } -.brand { color: var(--text-primary) !important; font-weight: 700; } - -.icon-btn { - background: var(--surface-2); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: 50%; - width: 2.2rem; - height: 2.2rem; - cursor: pointer; - font-size: 1rem; -} -.icon-btn:hover { background: var(--surface-3); } - -.page { - max-width: 900px; - margin: 0 auto; - padding: 2rem 1.5rem 4rem; -} - -h1 { margin-top: 0; } - -.card { - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1.25rem 1.5rem; - margin-bottom: 1.25rem; -} - -.alert { - padding: 0.8rem 1rem; - border-radius: var(--radius); - margin-bottom: 1.25rem; - border: 1px solid transparent; -} -.alert-danger { background: rgba(239, 68, 68, 0.12); border-color: var(--danger); color: var(--danger); } -.alert-warning { background: rgba(245, 158, 11, 0.12); border-color: var(--warning); color: var(--warning); } -.alert-success { background: rgba(34, 197, 94, 0.12); border-color: var(--success); color: var(--success); } -.alert ul { margin: 0.4rem 0 0; padding-left: 1.2rem; } - -.btn { - display: inline-block; - padding: 0.6rem 1.1rem; - border-radius: var(--radius); - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--text-primary); - font-family: inherit; - font-size: 0.95rem; - cursor: pointer; -} -.btn:hover { background: var(--surface-3); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.btn-primary:hover { background: var(--accent-hover); } -.btn-success { background: var(--success); border-color: var(--success); color: #06210f; } -.btn-danger { background: var(--danger); border-color: var(--danger); color: #2a0a0a; } -.btn-secondary { background: var(--surface-2); } - -.upload-form { display: flex; flex-direction: column; gap: 1rem; align-items: flex-start; } -.upload-drop { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 10rem; - border: 2px dashed var(--border); - border-radius: var(--radius); - color: var(--text-secondary); - cursor: pointer; - text-align: center; - padding: 1rem; -} -.upload-drop:hover { border-color: var(--accent); color: var(--text-primary); } -.upload-drop input { display: none; } - -.tabs { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 1.25rem; - border-bottom: 1px solid var(--border); - padding-bottom: 0.75rem; -} -.tab { - padding: 0.4rem 0.9rem; - border-radius: 999px; - color: var(--text-secondary); - font-size: 0.9rem; -} -.tab:hover { background: var(--surface-2); color: var(--text-primary); } -.tab-active { background: var(--accent); color: #fff; } -.tab-active:hover { background: var(--accent-hover); color: #fff; } - -.flight-list { display: flex; flex-direction: column; gap: 0.6rem; } -.flight-row { - display: grid; - grid-template-columns: auto auto auto 1fr auto; - align-items: center; - gap: 1rem; - color: var(--text-primary); -} -.flight-row:hover { border-color: var(--accent); } -.flight-route { font-family: var(--font-mono); } -.flight-number { font-family: var(--font-mono); color: var(--text-secondary); } - -.badge { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 500; - text-transform: capitalize; - background: var(--surface-3); - color: var(--text-secondary); -} -.badge-pending_review { background: rgba(59, 130, 246, 0.15); color: var(--accent); } -.badge-track_unavailable { background: rgba(245, 158, 11, 0.15); color: var(--warning); } -.badge-decode_failed { background: rgba(239, 68, 68, 0.15); color: var(--danger); } -.badge-approved { background: rgba(34, 197, 94, 0.15); color: var(--success); } -.badge-rejected { background: rgba(239, 68, 68, 0.15); color: var(--danger); } -.badge-queued { background: rgba(154, 163, 178, 0.15); color: var(--text-secondary); } - -.back-link { display: inline-block; margin-bottom: 1rem; color: var(--text-secondary); } - -.detail-header h1 { display: flex; align-items: center; gap: 0.75rem; font-size: 1.5rem; } - -.detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.25rem; -} -.map-card { grid-column: 1 / -1; } - -@media (max-width: 720px) { - .detail-grid { grid-template-columns: 1fr; } - .flight-row { grid-template-columns: auto 1fr; grid-template-rows: auto auto; } -} - -.edit-form { display: flex; flex-direction: column; gap: 0.85rem; } -.edit-form label { - display: flex; - flex-direction: column; - gap: 0.3rem; - font-size: 0.85rem; - color: var(--text-secondary); -} -.field-row { display: flex; gap: 1rem; } -.field-row label { flex: 1; } - -input { - font-family: var(--font-mono); - font-size: 0.95rem; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.6rem; - color: var(--text-primary); -} -input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } - -.meta-list { - margin: 1rem 0 0; - display: grid; - grid-template-columns: auto 1fr; - gap: 0.35rem 1rem; - font-size: 0.9rem; -} -.meta-list dt { color: var(--text-secondary); } -.meta-list dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; } - -.bp-photo { width: 100%; border-radius: var(--radius); display: block; } -.raw-bcbp { margin-top: 1rem; } -.raw-bcbp pre { - font-family: var(--font-mono); - font-size: 0.8rem; - background: var(--surface-2); - padding: 0.75rem; - border-radius: 6px; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; -} - -#track-map { height: 360px; border-radius: var(--radius); overflow: hidden; } - -.action-bar { display: flex; gap: 0.75rem; flex-wrap: wrap; } - -.settings-form { display: flex; flex-direction: column; gap: 1rem; max-width: 480px; } -.settings-form label { - display: flex; - flex-direction: column; - gap: 0.3rem; - font-size: 0.9rem; - color: var(--text-secondary); -} -.settings-actions { display: flex; gap: 0.75rem; } diff --git a/internal/server/web/app.js b/internal/server/web/app.js index 8caed6a..7bd3404 100644 --- a/internal/server/web/app.js +++ b/internal/server/web/app.js @@ -1,9 +1,9 @@ /* * ipswap preset editor. * - * Plain ES modules-free JavaScript on purpose: the whole app is served from an - * embed.FS inside a single .exe, and a build step for the front end would mean - * a Node toolchain in CI for a few hundred lines of DOM code. + * Plain DOM code with no build step: the whole app is served from an embed.FS + * inside a single .exe, and a Node toolchain in CI for a few hundred lines of + * JavaScript is not a trade worth making. */ // The token arrives once in the query string. The server also sets it as a @@ -16,21 +16,23 @@ const state = { groups: [], adapters: [], settings: null, + active: { active_id: "", active_name: "" }, editing: null, + pendingApply: null, }; +const $ = (id) => document.getElementById(id); + // --- theme --- -const themeToggle = document.getElementById("theme-toggle"); - function applyTheme(light) { document.documentElement.classList.toggle("light", light); - themeToggle.textContent = light ? "Dark" : "Light"; + $("btn-theme").textContent = light ? "Dark" : "Light"; localStorage.setItem("ipswap-theme", light ? "light" : "dark"); } applyTheme(localStorage.getItem("ipswap-theme") === "light"); -themeToggle.addEventListener("click", () => +$("btn-theme").addEventListener("click", () => applyTheme(!document.documentElement.classList.contains("light")) ); @@ -40,68 +42,78 @@ async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers); if (opts.method && opts.method !== "GET") { headers["X-Ipswap-Token"] = TOKEN; - if (opts.body && !headers["Content-Type"]) { - headers["Content-Type"] = "application/json"; - } + if (opts.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; } const res = await fetch(path, Object.assign({}, opts, { headers })); const text = await res.text(); + let data = null; try { data = text ? JSON.parse(text) : null; } catch { - // Non-JSON bodies come from the auth middleware, which writes plain text. + // The auth middleware writes plain text, not JSON. if (!res.ok) throw new Error(text || res.statusText); } - if (!res.ok) throw new Error((data && data.error) || text || res.statusText); + + if (!res.ok) { + const err = new Error((data && data.error) || text || res.statusText); + err.status = res.status; + err.data = data; + throw err; + } return data; } -// The server shuts down after five minutes without one of these. -setInterval(() => { - api("/api/heartbeat", { method: "POST" }).catch(() => {}); -}, 30_000); +// The server shuts down five minutes after the last one of these. +setInterval(() => api("/api/heartbeat", { method: "POST" }).catch(() => {}), 30_000); // --- alerts --- -function alertBox(kind, message, container = "alerts") { - const host = document.getElementById(container); - host.innerHTML = `
${escapeHTML(message)}
`; - if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000); -} - function escapeHTML(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ); } -// --- rendering --- +// kind is one of ok, error, warn, info — matching apointless.css's alert +// modifiers, with alert-ok supplied by ipswap.css. +function alertBox(kind, message, container = "alerts") { + const host = $(container); + host.innerHTML = `
${escapeHTML(message)}
`; + if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000); +} + +function clearAlert(container) { + $(container).innerHTML = ""; +} + +// --- formatting --- function maskOf(prefix) { if (state.settings && state.settings.mask_style === "dotted") { - let m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; - return [m >>> 24, (m >>> 16) & 255, (m >>> 8) & 255, m & 255].join("."); + const m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return `${m >>> 24}.${(m >>> 16) & 255}.${(m >>> 8) & 255}.${m & 255}`; } return "/" + prefix; } function addrText(a) { - return a.address + maskOf(a.prefix).replace(/^(?!\/)/, " "); + const m = maskOf(a.prefix); + return m.startsWith("/") ? `${a.address}${m}` : `${a.address} ${m}`; } +// --- rendering --- + function renderStats() { - const adapters = new Set(state.presets.map((p) => p.adapter)); - document.getElementById("stats").innerHTML = ` -
Presets
${state.presets.length}
-
Groups
${state.groups.length}
-
Adapters in use
${adapters.size}
- `; + $("stat-active").textContent = state.active.active_name || "unmatched"; + $("stat-count").textContent = state.presets.length; + $("stat-groups").textContent = state.groups.length; } function renderPresets() { - const host = document.getElementById("groups"); + const host = $("groups"); + if (!state.presets.length) { host.innerHTML = `
No presets yet. Create one, or import a pack from a colleague.
`; return; @@ -116,41 +128,45 @@ function renderPresets() { let html = ""; for (const [group, presets] of byGroup) { - html += ` -
+ html += `
+

${escapeHTML(group)}

- ${presets.length} + ${presets.length}
-
-
- - - - - `; +
+
NameAdapterAddressGatewayDNS
+ + + + `; for (const p of presets) { const isDHCP = p.mode === "dhcp"; + const isActive = p.id === state.active.active_id; + const addr = isDHCP - ? `DHCP` + ? `DHCP` : escapeHTML(addrText(p.primary)) + - (p.secondary && p.secondary.length - ? ` +${p.secondary.length}` - : ""); + (p.secondary && p.secondary.length ? ` +${p.secondary.length}` : ""); + const dns = p.dns.mode === "dhcp" ? `from DHCP` : escapeHTML((p.dns.servers || []).join(", ")); - html += ` - - + html += ` + + + - `; } @@ -158,6 +174,9 @@ function renderPresets() { } host.innerHTML = html; + host.querySelectorAll("[data-apply]").forEach((b) => + b.addEventListener("click", () => openConfirm(b.dataset.apply)) + ); host.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => openEditor(b.dataset.edit)) ); @@ -167,18 +186,18 @@ function renderPresets() { } function renderAdapters() { - const body = document.getElementById("adapters"); + const body = $("adapters"); if (!state.adapters.length) { - body.innerHTML = ``; + body.innerHTML = ``; return; } body.innerHTML = state.adapters .map( (a) => ` - - + + - + ` ) .join(""); @@ -187,39 +206,119 @@ function renderAdapters() { // --- loading --- async function loadAll() { - const [presets, adapters, settings] = await Promise.all([ + const [presets, adapters, settings, active] = await Promise.all([ api("/api/presets"), api("/api/adapters"), api("/api/settings"), + api("/api/active"), ]); + state.presets = presets.presets || []; state.groups = presets.groups || []; state.adapters = adapters.adapters || []; state.settings = settings; + state.active = active || { active_id: "", active_name: "" }; renderStats(); renderPresets(); renderAdapters(); - document.getElementById("group-list").innerHTML = state.groups - .map((g) => ``) .join(""); } +// Keep the "live" marker honest without a reload: something outside ipswap can +// change the network at any time. +setInterval(async () => { + try { + const active = await api("/api/active"); + if (active.active_id !== state.active.active_id) { + state.active = active; + renderStats(); + renderPresets(); + } + } catch { + // A dropped poll is not worth reporting; the next one will tell us. + } +}, 15_000); + +// --- apply --- + +const confirmDlg = $("confirm"); + +async function openConfirm(id) { + const p = state.presets.find((x) => x.id === id); + state.pendingApply = id; + + $("confirm-title").textContent = `Apply "${p ? p.name : id}"`; + $("confirm-diff").textContent = "Reading the adapter…"; + clearAlert("confirm-error"); + $("confirm-go").disabled = true; + confirmDlg.showModal(); + + try { + // Read live at prompt time, exactly as the tray does. A stale CURRENT + // block would undermine the only check before a destructive change. + const prev = await api(`/api/presets/${encodeURIComponent(id)}/preview`); + $("confirm-diff").textContent = prev.text; + $("confirm-go").disabled = false; + } catch (e) { + $("confirm-diff").textContent = ""; + alertBox("error", e.message, "confirm-error"); + } +} + +$("confirm-cancel").addEventListener("click", () => { + state.pendingApply = null; + confirmDlg.close(); +}); + +$("confirm-go").addEventListener("click", async () => { + const id = state.pendingApply; + if (!id) return; + + const btn = $("confirm-go"); + btn.disabled = true; + btn.innerHTML = ` Applying…`; + clearAlert("confirm-error"); + + try { + const active = await api(`/api/presets/${encodeURIComponent(id)}/apply`, { method: "POST" }); + state.active = active; + confirmDlg.close(); + await loadAll(); + alertBox("ok", `Applied "${active.active_name || "preset"}".`); + } catch (e) { + // The server answers 409 when netsh refused for lack of an elevated token, + // which has a specific next step rather than being a generic failure. + if (e.data && e.data.elevation_required) { + alertBox( + "warn", + "This needs administrator rights. Use \"Relaunch as administrator\" in the tray menu, then try again.", + "confirm-error" + ); + } else { + alertBox("error", e.message, "confirm-error"); + } + btn.disabled = false; + } finally { + btn.textContent = "Apply"; + } +}); + // --- editor --- -const editor = document.getElementById("editor"); +const editor = $("editor"); function secondaryRow(addr = "", prefix = "") { const div = document.createElement("div"); div.className = "sec-row"; div.innerHTML = ` - - - `; + + + `; div.querySelector(".sec-del").addEventListener("click", () => div.remove()); return div; } @@ -228,52 +327,48 @@ function openEditor(id) { const p = id ? state.presets.find((x) => x.id === id) : null; state.editing = p ? p.id : null; - document.getElementById("editor-title").textContent = p ? "Edit preset" : "New preset"; - document.getElementById("editor-error").innerHTML = ""; - document.getElementById("f-name").value = p ? p.name : ""; - document.getElementById("f-group").value = p ? p.group || "" : ""; - document.getElementById("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || ""; - document.getElementById("f-mode").value = p ? p.mode : "static"; - document.getElementById("f-notes").value = p ? p.notes || "" : ""; + $("editor-title").textContent = p ? "Edit preset" : "New preset"; + clearAlert("editor-error"); + $("f-name").value = p ? p.name : ""; + $("f-group").value = p ? p.group || "" : ""; + $("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || ""; + $("f-mode").value = p ? p.mode : "static"; + $("f-notes").value = p ? p.notes || "" : ""; const prim = (p && p.primary) || {}; - document.getElementById("f-address").value = prim.address || ""; - document.getElementById("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : ""; - document.getElementById("f-gateway").value = prim.gateway || ""; - document.getElementById("f-metric").value = prim.gateway_metric || 0; + $("f-address").value = prim.address || ""; + $("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : ""; + $("f-gateway").value = prim.gateway || ""; + $("f-metric").value = prim.gateway_metric || 0; - const secs = document.getElementById("secondaries"); + const secs = $("secondaries"); secs.innerHTML = ""; for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix))); - document.getElementById("f-dns-mode").value = p ? p.dns.mode : "static"; - document.getElementById("f-dns").value = p ? (p.dns.servers || []).join(", ") : ""; + $("f-dns-mode").value = p ? p.dns.mode : "static"; + $("f-dns").value = p ? (p.dns.servers || []).join(", ") : ""; syncModeVisibility(); editor.showModal(); } function syncModeVisibility() { - const isDHCP = document.getElementById("f-mode").value === "dhcp"; - document.getElementById("static-fields").classList.toggle("hidden", isDHCP); - const dnsStatic = document.getElementById("f-dns-mode").value === "static"; - document.getElementById("dns-servers-field").classList.toggle("hidden", !dnsStatic); + $("static-fields").hidden = $("f-mode").value === "dhcp"; + $("dns-servers-field").hidden = $("f-dns-mode").value !== "static"; } -document.getElementById("f-mode").addEventListener("change", syncModeVisibility); -document.getElementById("f-dns-mode").addEventListener("change", syncModeVisibility); -document.getElementById("btn-add-sec").addEventListener("click", () => - document.getElementById("secondaries").appendChild(secondaryRow()) -); -document.getElementById("btn-new").addEventListener("click", () => openEditor(null)); -document.getElementById("btn-cancel").addEventListener("click", () => editor.close()); +$("f-mode").addEventListener("change", syncModeVisibility); +$("f-dns-mode").addEventListener("change", syncModeVisibility); +$("btn-add-sec").addEventListener("click", () => $("secondaries").appendChild(secondaryRow())); +$("btn-new").addEventListener("click", () => openEditor(null)); +$("btn-cancel").addEventListener("click", () => editor.close()); -// Masks are parsed here so the editor accepts both spellings, matching the Go -// side's ParsePrefix. Anything malformed is reported before the request goes -// out rather than coming back as a server-side validation error. +// Masks are parsed here so both spellings are accepted client-side, mirroring +// ParsePrefix in Go. Malformed input is caught before the request goes out. function parsePrefix(s) { s = (s || "").trim().replace(/^\//, ""); if (!s) throw new Error("mask is required"); + if (s.includes(".")) { const parts = s.split(".").map(Number); if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { @@ -285,39 +380,40 @@ function parsePrefix(s) { if (ones < 32 && (m << ones) >>> 0) throw new Error(`"${s}" is not a contiguous netmask`); return ones; } + const n = Number(s); if (!Number.isInteger(n) || n < 0 || n > 32) throw new Error(`"${s}" is not a prefix length`); return n; } async function savePreset() { - const mode = document.getElementById("f-mode").value; - const dnsMode = document.getElementById("f-dns-mode").value; + const mode = $("f-mode").value; + const dnsMode = $("f-dns-mode").value; let body; try { body = { id: state.editing || "", - name: document.getElementById("f-name").value.trim(), - group: document.getElementById("f-group").value.trim(), - adapter: document.getElementById("f-adapter").value, + name: $("f-name").value.trim(), + group: $("f-group").value.trim(), + adapter: $("f-adapter").value, mode, - notes: document.getElementById("f-notes").value.trim(), + notes: $("f-notes").value.trim(), dns: { mode: dnsMode, servers: dnsMode === "static" - ? document.getElementById("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean) + ? $("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean) : undefined, }, }; if (mode === "static") { body.primary = { - address: document.getElementById("f-address").value.trim(), - prefix: parsePrefix(document.getElementById("f-prefix").value), - gateway: document.getElementById("f-gateway").value.trim() || undefined, - gateway_metric: Number(document.getElementById("f-metric").value) || undefined, + address: $("f-address").value.trim(), + prefix: parsePrefix($("f-prefix").value), + gateway: $("f-gateway").value.trim() || undefined, + gateway_metric: Number($("f-metric").value) || undefined, }; const secs = []; for (const row of document.querySelectorAll("#secondaries .sec-row")) { @@ -328,7 +424,7 @@ async function savePreset() { if (secs.length) body.secondary = secs; } } catch (e) { - alertBox("danger", e.message, "editor-error"); + alertBox("error", e.message, "editor-error"); return; } @@ -339,11 +435,11 @@ async function savePreset() { await loadAll(); alertBox("ok", "Preset saved."); } catch (e) { - alertBox("danger", e.message, "editor-error"); + alertBox("error", e.message, "editor-error"); } } -document.getElementById("btn-save").addEventListener("click", savePreset); +$("btn-save").addEventListener("click", savePreset); async function deletePreset(id) { const p = state.presets.find((x) => x.id === id); @@ -353,83 +449,85 @@ async function deletePreset(id) { await loadAll(); alertBox("ok", "Preset deleted."); } catch (e) { - alertBox("danger", e.message); + alertBox("error", e.message); } } // --- settings --- -const settingsDlg = document.getElementById("settings"); +const settingsDlg = $("settings"); -document.getElementById("btn-settings").addEventListener("click", () => { +$("btn-settings").addEventListener("click", () => { const s = state.settings || {}; - document.getElementById("s-mask").value = s.mask_style || "prefix"; - document.getElementById("s-startup").checked = !!s.start_with_windows; - document.getElementById("s-updates").checked = !!s.check_updates; - document.getElementById("s-repo").value = s.update_repo || ""; + $("s-mask").value = s.mask_style || "prefix"; + $("s-startup").checked = !!s.start_with_windows; + $("s-updates").checked = !!s.check_updates; + $("s-repo").value = s.update_repo || ""; settingsDlg.showModal(); }); -document.getElementById("btn-settings-cancel").addEventListener("click", () => settingsDlg.close()); +$("btn-settings-cancel").addEventListener("click", () => settingsDlg.close()); -document.getElementById("btn-settings-save").addEventListener("click", async () => { +$("btn-settings-save").addEventListener("click", async () => { try { await api("/api/settings", { method: "PUT", body: JSON.stringify({ - mask_style: document.getElementById("s-mask").value, - start_with_windows: document.getElementById("s-startup").checked, - check_updates: document.getElementById("s-updates").checked, - update_repo: document.getElementById("s-repo").value.trim(), + mask_style: $("s-mask").value, + start_with_windows: $("s-startup").checked, + check_updates: $("s-updates").checked, + update_repo: $("s-repo").value.trim(), }), }); settingsDlg.close(); await loadAll(); alertBox("ok", "Settings saved."); } catch (e) { - alertBox("danger", e.message); + alertBox("error", e.message); } }); // --- import / export --- -document.getElementById("btn-export").addEventListener("click", () => { +$("btn-export").addEventListener("click", () => { // A plain navigation, so the browser's own download UI picks the location. location.href = "/api/export"; }); -const importDlg = document.getElementById("import"); -document.getElementById("btn-import").addEventListener("click", () => { - document.getElementById("import-error").innerHTML = ""; +const importDlg = $("import"); + +$("btn-import").addEventListener("click", () => { + clearAlert("import-error"); importDlg.showModal(); }); -document.getElementById("btn-import-cancel").addEventListener("click", () => importDlg.close()); -document.getElementById("btn-import-go").addEventListener("click", async () => { - const file = document.getElementById("i-file").files[0]; +$("btn-import-cancel").addEventListener("click", () => importDlg.close()); + +$("btn-import-go").addEventListener("click", async () => { + const file = $("i-file").files[0]; if (!file) { - alertBox("danger", "Choose a file first.", "import-error"); - return; - } - const mode = document.getElementById("i-mode").value; - if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) { + alertBox("error", "Choose a file first.", "import-error"); return; } + const mode = $("i-mode").value; + if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) return; + try { const res = await api(`/api/import?mode=${mode}`, { method: "POST", body: await file.text() }); importDlg.close(); await loadAll(); + let msg = `Imported ${res.added} preset${res.added === 1 ? "" : "s"}.`; if (res.renamed && res.renamed.length) { msg += ` ${res.renamed.length} had an id collision and were kept alongside the originals.`; } alertBox("ok", msg); } catch (e) { - alertBox("danger", e.message, "import-error"); + alertBox("error", e.message, "import-error"); } }); // --- go --- -loadAll().catch((e) => alertBox("danger", `Could not load: ${e.message}`)); +loadAll().catch((e) => alertBox("error", `Could not load: ${e.message}`)); diff --git a/internal/server/web/index.html b/internal/server/web/index.html index a0697ca..977c71e 100644 --- a/internal/server/web/index.html +++ b/internal/server/web/index.html @@ -4,77 +4,114 @@ ipswap — presets - + + + -
-
-
-

ipswap

-
Preset editor · this page closes itself when you stop using it
-
-
- - -
-
+
+
+

ipswap

+ +
+
+ + +
+
+
-
+
+
+ Active preset + — +
+
+ Presets + — +
+
+ Groups + — +
+
-
-
-

Presets

-
- - - -
+
+

Presets

+
+ + +
-
+
+

Adapters

-

Adapters on this machine

NameAdapterAddressGatewayDNS
${escapeHTML(p.name)}${p.notes ? `
${escapeHTML(p.notes)}
` : ""}
${escapeHTML(p.adapter)}
${isActive ? 'live' : ""}${escapeHTML(p.name)}${p.notes ? `
${escapeHTML(p.notes)}
` : ""}
${escapeHTML(p.adapter)} ${addr} ${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")} ${dns} - - + + + +
No adapters found.
No adapters found.
${escapeHTML(a.name)}${escapeHTML(a.description || "")}${escapeHTML(a.name)}${escapeHTML(a.description || "")} ${escapeHTML(a.current || "—")}${a.up ? 'up' : 'down'}${a.up ? 'up' : 'down'}
- + + +
NameDescriptionCurrent IPv4State
Reading adapters…
-
+ - + + + + + + + + -
+ +
- + -

Settings

-
- - + + -
- - -
-
- - -
-
- - -
-
- - +
- + -

Import presets

-
- The file is validated in full before anything is written. If it is rejected, nothing changes. + + -
- - -
-
- - -
-
-
- - +
diff --git a/internal/server/web/ipswap.css b/internal/server/web/ipswap.css new file mode 100644 index 0000000..0070d77 --- /dev/null +++ b/internal/server/web/ipswap.css @@ -0,0 +1,228 @@ +/* + * ipswap-specific styles. + * + * apointless.css is vendored verbatim from bsncubed/css and must not be edited + * here — re-pull it to update. This file adds only what the design system does + * not ship (page chrome, tables, modals, stat tiles) and is built strictly on + * its tokens, so a refreshed apointless.css restyles this too. + */ + +/* ------------------------------------------------------------- layout --- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 14px 24px; + background: var(--surface-1); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: baseline; + gap: 10px; +} + +.brand .version { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-faint); +} + +.page { + max-width: 1080px; + margin: 0 auto; + padding: 24px 24px 72px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.stack { display: flex; flex-direction: column; gap: 16px; } + +.section-head { + display: flex; + align-items: center; + gap: 10px; + margin: 24px 0 10px; +} + +.muted { color: var(--text-muted); } +.faint { color: var(--text-faint); font-size: 0.82rem; } + +/* --------------------------------------------------------- stat tiles --- */ + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.stat { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 16px; +} + +.stat .label { + display: block; + margin-bottom: 4px; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.stat .value { + font-family: var(--font-mono); + font-size: 1.05rem; + overflow-wrap: anywhere; +} + +/* ------------------------------------------------------------- tables --- */ + +/* Wide content scrolls inside its own container so the page body never does. */ +.table-scroll { overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; } + +th { + padding: 9px 14px; + text-align: left; + border-bottom: 1px solid var(--border-strong); + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +td { + padding: 11px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; + font-size: 0.88rem; +} + +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: var(--surface-2); } + +/* The row whose preset is currently live. */ +tbody tr.is-active { background: var(--accent-soft); } +tbody tr.is-active:hover { background: var(--accent-soft); } + +td.addr { font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; } +td.actions { text-align: right; white-space: nowrap; } +td.actions .btn + .btn { margin-left: 6px; } + +/* -------------------------------------------------------------- forms --- */ + +.field { margin-bottom: 14px; } + +.field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; +} + +.check { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 12px; +} + +.check input { accent-color: var(--accent); width: 16px; height: 16px; } +.check label { margin: 0; text-transform: none; letter-spacing: 0; font-family: var(--font-sans); font-size: 0.88rem; } + +select.input { font-family: var(--font-sans); } + +.sec-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.sec-row .input { flex: 1; } +.sec-row .input.narrow { flex: 0 0 150px; } + +/* ------------------------------------------------------------- modals --- */ + +dialog { + padding: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--surface-1); + color: var(--text); + box-shadow: var(--shadow); + width: min(720px, 94vw); +} + +dialog::backdrop { background: rgba(0, 0, 0, 0.62); } + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.modal-body { padding: 18px; max-height: 68vh; overflow-y: auto; } + +.modal-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} + +/* ------------------------------------------------------------- alerts --- */ + +/* apointless.css ships error/warn/info but no success variant. */ +.alert-ok { + background: var(--ok-soft); + border-color: rgba(34, 197, 94, 0.3); + color: var(--ok); +} + +.alert + .alert { margin-top: 8px; } +#alerts:not(:empty) { margin-bottom: 16px; } + +/* -------------------------------------------------------------- misc --- */ + +.empty { + padding: 36px 18px; + text-align: center; + color: var(--text-faint); +} + +.busy { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 0.85rem; +} + +@media (max-width: 640px) { + .page { padding: 16px 14px 56px; } + .topbar { padding: 12px 14px; } +} diff --git a/internal/switcher/switcher.go b/internal/switcher/switcher.go new file mode 100644 index 0000000..3f4638e --- /dev/null +++ b/internal/switcher/switcher.go @@ -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) +} diff --git a/internal/switcher/switcher_test.go b/internal/switcher/switcher_test.go new file mode 100644 index 0000000..0b51a23 --- /dev/null +++ b/internal/switcher/switcher_test.go @@ -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 +} diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 3f13b51..8cf6c29 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -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