Add installer, launch-to-browser, and switching from the web GUI
Three features plus the design-system swap. Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches the app's asInvoker manifest, and it is also what lets the in-app updater replace the .exe later without elevation — a Program Files install could not. It offers a desktop shortcut and an optional start-with-Windows entry, and reuses the app's own single-instance mutex as AppMutex so setup notices a running copy, since a running .exe cannot be overwritten. Uninstall leaves presets.json, config.json and the log in place. Launching. Opening ipswap opens the editor in a browser, starting the tray first if it is not already up; if it is, the running copy opens the browser and the second process exits. internal/instance does this with a loopback control listener whose port and token live in a mode-600 session.json, plus a session-local named mutex on Windows to settle a launch race. A stale session file from a crash is detected by a failed call and treated as "no primary", so it can never wedge startup. The Run-key entry now passes --background, because a browser tab at every login is not wanted. Switching from the browser. New endpoints for active state, apply preview and apply. The browser confirms against the same before/after text the tray's MessageBox shows, built from a live read at prompt time. Apply requires the session token in a header like every other mutation — it changes the machine's network, so it is not a weaker case than editing a preset. A netsh refusal for lack of elevation comes back as 409 with a flag, so the page can point at "Relaunch as administrator" instead of showing a generic failure. To avoid two copies of that sequence, internal/switcher now owns everything between "the user said yes" and "the adapter changed", and both front ends call it. It serialises applies: two interleaved netsh sequences on one adapter would leave it matching neither preset, and there are now two ways to start one. CSS. apointless.css is vendored from bsncubed/css and left untouched; the previous file was the boarding-pass stylesheet and its class names did not match this markup. ipswap.css adds only what the design system does not ship — page chrome, tables, modals, stat tiles, a success alert — on its tokens, so re-pulling apointless.css restyles the app. Not verified: the installer is uncompiled (no Inno Setup or wine on this box) and the UI is not visually rendered (headless Firefox hangs here). Both are checked as far as the tooling allows — assets and endpoints serve, and every DOM id the JS touches exists in the markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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-<version>-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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+39
-5
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
+235
-137
@@ -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 = `<div class="alert ${kind}">${escapeHTML(message)}</div>`;
|
||||
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 = `<div class="alert alert-${kind}">${escapeHTML(message)}</div>`;
|
||||
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 = `
|
||||
<div class="stat-card"><div class="label">Presets</div><div class="value">${state.presets.length}</div></div>
|
||||
<div class="stat-card"><div class="label">Groups</div><div class="value">${state.groups.length}</div></div>
|
||||
<div class="stat-card"><div class="label">Adapters in use</div><div class="value">${adapters.size}</div></div>
|
||||
`;
|
||||
$("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 = `<div class="card"><div class="empty">No presets yet. Create one, or import a pack from a colleague.</div></div>`;
|
||||
return;
|
||||
@@ -116,41 +128,45 @@ function renderPresets() {
|
||||
|
||||
let html = "";
|
||||
for (const [group, presets] of byGroup) {
|
||||
html += `
|
||||
<div class="group-head">
|
||||
html += `<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>${escapeHTML(group)}</h2>
|
||||
<span class="pill">${presets.length}</span>
|
||||
<span class="badge">${presets.length}</span>
|
||||
</div>
|
||||
<div class="card" style="padding:0">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th><th>Adapter</th><th>Address</th><th>Gateway</th><th>DNS</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th></th><th>Name</th><th>Adapter</th><th>Address</th><th>Gateway</th><th>DNS</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const p of presets) {
|
||||
const isDHCP = p.mode === "dhcp";
|
||||
const isActive = p.id === state.active.active_id;
|
||||
|
||||
const addr = isDHCP
|
||||
? `<span class="badge accent">DHCP</span>`
|
||||
? `<span class="badge badge-accent">DHCP</span>`
|
||||
: escapeHTML(addrText(p.primary)) +
|
||||
(p.secondary && p.secondary.length
|
||||
? ` <span class="pill">+${p.secondary.length}</span>`
|
||||
: "");
|
||||
(p.secondary && p.secondary.length ? ` <span class="badge">+${p.secondary.length}</span>` : "");
|
||||
|
||||
const dns =
|
||||
p.dns.mode === "dhcp"
|
||||
? `<span class="badge">from DHCP</span>`
|
||||
: escapeHTML((p.dns.servers || []).join(", "));
|
||||
|
||||
html += `<tr>
|
||||
<td>${escapeHTML(p.name)}${p.notes ? `<div class="dim">${escapeHTML(p.notes)}</div>` : ""}</td>
|
||||
<td class="mono">${escapeHTML(p.adapter)}</td>
|
||||
html += `<tr class="${isActive ? "is-active" : ""}">
|
||||
<td>${isActive ? '<span class="badge badge-ok"><span class="dot dot-pulse"></span>live</span>' : ""}</td>
|
||||
<td>${escapeHTML(p.name)}${p.notes ? `<div class="faint">${escapeHTML(p.notes)}</div>` : ""}</td>
|
||||
<td class="addr">${escapeHTML(p.adapter)}</td>
|
||||
<td class="addr">${addr}</td>
|
||||
<td class="addr">${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")}</td>
|
||||
<td class="addr">${dns}</td>
|
||||
<td style="text-align:right; white-space:nowrap">
|
||||
<button class="small" data-edit="${escapeHTML(p.id)}">Edit</button>
|
||||
<button class="small danger" data-del="${escapeHTML(p.id)}">Delete</button>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-primary" data-apply="${escapeHTML(p.id)}" ${isActive ? "disabled" : ""}>
|
||||
${isActive ? "In use" : "Use"}
|
||||
</button>
|
||||
<button class="btn btn-sm" data-edit="${escapeHTML(p.id)}">Edit</button>
|
||||
<button class="btn btn-sm btn-ghost" data-del="${escapeHTML(p.id)}">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
@@ -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 = `<tr><td colspan="4" class="dim">No adapters found.</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="4" class="faint">No adapters found.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = state.adapters
|
||||
.map(
|
||||
(a) => `<tr>
|
||||
<td class="mono">${escapeHTML(a.name)}</td>
|
||||
<td class="dim">${escapeHTML(a.description || "")}</td>
|
||||
<td class="addr">${escapeHTML(a.name)}</td>
|
||||
<td class="faint">${escapeHTML(a.description || "")}</td>
|
||||
<td class="addr">${escapeHTML(a.current || "—")}</td>
|
||||
<td>${a.up ? '<span class="badge ok">up</span>' : '<span class="badge">down</span>'}</td>
|
||||
<td>${a.up ? '<span class="badge badge-ok">up</span>' : '<span class="badge">down</span>'}</td>
|
||||
</tr>`
|
||||
)
|
||||
.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) => `<option value="${escapeHTML(g)}">`)
|
||||
.join("");
|
||||
document.getElementById("f-adapter").innerHTML = state.adapters
|
||||
$("group-list").innerHTML = state.groups.map((g) => `<option value="${escapeHTML(g)}">`).join("");
|
||||
$("f-adapter").innerHTML = state.adapters
|
||||
.map((a) => `<option value="${escapeHTML(a.name)}">${escapeHTML(a.name)}</option>`)
|
||||
.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 = `<span class="spinner"></span> 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 = `
|
||||
<input class="sec-addr" placeholder="10.0.10.50" value="${escapeHTML(addr)}">
|
||||
<input class="sec-prefix" placeholder="/24" value="${escapeHTML(prefix)}" style="max-width:150px">
|
||||
<button type="button" class="small ghost sec-del">✕</button>`;
|
||||
<input class="input sec-addr" placeholder="10.0.10.50" value="${escapeHTML(addr)}">
|
||||
<input class="input narrow sec-prefix" placeholder="/24" value="${escapeHTML(prefix)}">
|
||||
<button type="button" class="btn btn-sm btn-ghost sec-del">Remove</button>`;
|
||||
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}`));
|
||||
|
||||
+144
-104
@@ -4,77 +4,114 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ipswap — presets</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<!-- apointless.css is vendored from bsncubed/css; ipswap.css adds only what
|
||||
it does not ship. Keep that order. -->
|
||||
<link rel="stylesheet" href="/apointless.css">
|
||||
<link rel="stylesheet" href="/ipswap.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1>ipswap</h1>
|
||||
<div class="dim">Preset editor · this page closes itself when you stop using it</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="theme-toggle" class="ghost small">Light</button>
|
||||
<button id="btn-settings" class="ghost small">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<h1>ipswap</h1>
|
||||
<span class="version" id="version"></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="btn-theme" class="btn btn-ghost btn-sm">Light</button>
|
||||
<button id="btn-settings" class="btn btn-sm">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<div id="alerts"></div>
|
||||
|
||||
<div class="stat-row" id="stats"></div>
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="label">Active preset</span>
|
||||
<span class="value" id="stat-active">—</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">Presets</span>
|
||||
<span class="value" id="stat-count">—</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">Groups</span>
|
||||
<span class="value" id="stat-groups">—</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<div class="btn-row" style="justify-content: space-between;">
|
||||
<h2 style="margin:0">Presets</h2>
|
||||
<div class="btn-row">
|
||||
<button id="btn-import">Import…</button>
|
||||
<button id="btn-export">Export</button>
|
||||
<button id="btn-new" class="primary">New preset</button>
|
||||
</div>
|
||||
<div class="row-between">
|
||||
<h2>Presets</h2>
|
||||
<div class="row">
|
||||
<button id="btn-import" class="btn btn-sm">Import…</button>
|
||||
<button id="btn-export" class="btn btn-sm">Export</button>
|
||||
<button id="btn-new" class="btn btn-primary btn-sm">New preset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="groups"></div>
|
||||
<div id="groups" class="stack" style="margin-top:12px"></div>
|
||||
|
||||
<div class="section-head"><h2>Adapters</h2></div>
|
||||
<div class="card">
|
||||
<h2>Adapters on this machine</h2>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Description</th><th>Current IPv4</th><th>State</th></tr>
|
||||
</thead>
|
||||
<tbody id="adapters"><tr><td colspan="4"><span class="spinner"></span></td></tr></tbody>
|
||||
<tbody id="adapters">
|
||||
<tr><td colspan="4"><span class="busy"><span class="spinner"></span> Reading adapters…</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- preset editor -->
|
||||
<!-- Apply confirmation. This is the browser's equivalent of the tray's
|
||||
MessageBox and shows the identical before/after text. -->
|
||||
<dialog id="confirm">
|
||||
<div class="modal-head">
|
||||
<h2 id="confirm-title">Apply preset</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warn" style="margin-bottom:14px">
|
||||
Applying is destructive: afterwards the adapter has exactly these addresses and nothing else.
|
||||
</div>
|
||||
<pre class="code-block" id="confirm-diff"></pre>
|
||||
<div id="confirm-error" style="margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn btn-ghost" id="confirm-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="confirm-go">Apply</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Preset editor -->
|
||||
<dialog id="editor">
|
||||
<form method="dialog" id="editor-form">
|
||||
<div class="modal-head">
|
||||
<h2 id="editor-title">New preset</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="editor-error"></div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="f-name">Name</label>
|
||||
<input id="f-name" required placeholder="Artist frame — control">
|
||||
<label class="label" for="f-name">Name</label>
|
||||
<input class="input" id="f-name" placeholder="Artist frame — control">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-group">Group</label>
|
||||
<input id="f-group" list="group-list" placeholder="Riedel">
|
||||
<label class="label" for="f-group">Group</label>
|
||||
<input class="input" id="f-group" list="group-list" placeholder="Riedel">
|
||||
<datalist id="group-list"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="f-adapter">Adapter</label>
|
||||
<select id="f-adapter"></select>
|
||||
<label class="label" for="f-adapter">Adapter</label>
|
||||
<select class="input" id="f-adapter"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-mode">Mode</label>
|
||||
<select id="f-mode">
|
||||
<label class="label" for="f-mode">Mode</label>
|
||||
<select class="input" id="f-mode">
|
||||
<option value="static">Static</option>
|
||||
<option value="dhcp">DHCP</option>
|
||||
</select>
|
||||
@@ -82,109 +119,112 @@
|
||||
</div>
|
||||
|
||||
<div id="static-fields">
|
||||
<div class="field-row">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="f-address">Address</label>
|
||||
<input id="f-address" placeholder="192.168.42.100">
|
||||
<label class="label" for="f-address">Address</label>
|
||||
<input class="input" id="f-address" placeholder="192.168.42.100">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-prefix">Mask <span class="dim">(/24 or 255.255.255.0)</span></label>
|
||||
<input id="f-prefix" placeholder="/24">
|
||||
<label class="label" for="f-prefix">Mask — /24 or 255.255.255.0</label>
|
||||
<input class="input" id="f-prefix" placeholder="/24">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="f-gateway">Gateway <span class="dim">(optional)</span></label>
|
||||
<input id="f-gateway" placeholder="192.168.42.1">
|
||||
<label class="label" for="f-gateway">Gateway — optional</label>
|
||||
<input class="input" id="f-gateway" placeholder="192.168.42.1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-metric">Gateway metric <span class="dim">(0 = automatic)</span></label>
|
||||
<input id="f-metric" type="number" min="0" value="0">
|
||||
<label class="label" for="f-metric">Gateway metric — 0 is automatic</label>
|
||||
<input class="input" id="f-metric" type="number" min="0" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Secondary addresses</label>
|
||||
<div class="sec-list" id="secondaries"></div>
|
||||
<button type="button" id="btn-add-sec" class="small" style="margin-top:8px">Add secondary</button>
|
||||
<label class="label">Secondary addresses</label>
|
||||
<div id="secondaries"></div>
|
||||
<button type="button" id="btn-add-sec" class="btn btn-sm">Add secondary</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="f-dns-mode">DNS</label>
|
||||
<select id="f-dns-mode">
|
||||
<label class="label" for="f-dns-mode">DNS</label>
|
||||
<select class="input" id="f-dns-mode">
|
||||
<option value="static">Static</option>
|
||||
<option value="dhcp">From DHCP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field" id="dns-servers-field">
|
||||
<label for="f-dns">DNS servers <span class="dim">(comma separated, order matters)</span></label>
|
||||
<input id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
|
||||
<label class="label" for="f-dns">DNS servers — comma separated, order matters</label>
|
||||
<input class="input" id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="f-notes">Notes</label>
|
||||
<input id="f-notes" placeholder="Frame A, rack 3">
|
||||
<label class="label" for="f-notes">Notes</label>
|
||||
<input class="input" id="f-notes" placeholder="Frame A, rack 3">
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
|
||||
<button type="button" id="btn-cancel" class="ghost">Cancel</button>
|
||||
<button type="button" id="btn-save" class="primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn btn-ghost" id="btn-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save">Save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- settings -->
|
||||
<!-- Settings -->
|
||||
<dialog id="settings">
|
||||
<h2>Settings</h2>
|
||||
<div class="field">
|
||||
<label for="s-mask">Mask display</label>
|
||||
<select id="s-mask">
|
||||
<option value="prefix">Prefix (/24)</option>
|
||||
<option value="dotted">Dotted (255.255.255.0)</option>
|
||||
</select>
|
||||
<div class="modal-head"><h2>Settings</h2></div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label class="label" for="s-mask">Mask display</label>
|
||||
<select class="input" id="s-mask">
|
||||
<option value="prefix">Prefix — /24</option>
|
||||
<option value="dotted">Dotted — 255.255.255.0</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" id="s-startup">
|
||||
<label for="s-startup">Start ipswap when Windows starts</label>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" id="s-updates">
|
||||
<label for="s-updates">Check for updates on startup</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="s-repo">Update repository — blank disables</label>
|
||||
<input class="input" id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field check">
|
||||
<input type="checkbox" id="s-startup">
|
||||
<label for="s-startup">Start ipswap when Windows starts</label>
|
||||
</div>
|
||||
<div class="field check">
|
||||
<input type="checkbox" id="s-updates">
|
||||
<label for="s-updates">Check for updates on startup</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="s-repo">Update repository <span class="dim">(https://host/owner/repo — blank disables)</span></label>
|
||||
<input id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
|
||||
</div>
|
||||
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
|
||||
<button type="button" id="btn-settings-cancel" class="ghost">Cancel</button>
|
||||
<button type="button" id="btn-settings-save" class="primary">Save</button>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn btn-ghost" id="btn-settings-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-settings-save">Save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- import -->
|
||||
<!-- Import -->
|
||||
<dialog id="import">
|
||||
<h2>Import presets</h2>
|
||||
<div class="alert info">
|
||||
The file is validated in full before anything is written. If it is rejected, nothing changes.
|
||||
<div class="modal-head"><h2>Import presets</h2></div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" style="margin-bottom:14px">
|
||||
The file is validated in full before anything is written. If it is rejected, nothing changes.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="i-file">Preset pack (.json)</label>
|
||||
<input class="input" type="file" id="i-file" accept="application/json,.json">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="i-mode">Mode</label>
|
||||
<select class="input" id="i-mode">
|
||||
<option value="merge">Merge — keep what I have, add these</option>
|
||||
<option value="replace">Replace — discard my existing presets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="import-error"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="i-file">Preset pack (.json)</label>
|
||||
<input type="file" id="i-file" accept="application/json,.json">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="i-mode">Mode</label>
|
||||
<select id="i-mode">
|
||||
<option value="merge">Merge — keep what I have, add these</option>
|
||||
<option value="replace">Replace — discard my existing presets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="import-error"></div>
|
||||
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
|
||||
<button type="button" id="btn-import-cancel" class="ghost">Cancel</button>
|
||||
<button type="button" id="btn-import-go" class="primary">Import</button>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn btn-ghost" id="btn-import-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-import-go">Import</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package switcher is the one place a preset actually gets applied.
|
||||
//
|
||||
// It exists because there are now two front ends that can switch a preset —
|
||||
// the tray, which confirms with a native MessageBox, and the web editor, which
|
||||
// confirms in the browser — and the sequence between "user said yes" and "the
|
||||
// adapter changed" must not be duplicated. Confirmation is the caller's job;
|
||||
// everything after it is here.
|
||||
package switcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the id does not name a preset.
|
||||
var ErrNotFound = errors.New("no such preset")
|
||||
|
||||
// Switcher reads adapter state and applies presets.
|
||||
type Switcher struct {
|
||||
store *preset.Store
|
||||
mgr netcfg.Manager
|
||||
|
||||
// applying serialises applies. Two concurrent netsh sequences against one
|
||||
// adapter would interleave their deletes and sets and leave the adapter in
|
||||
// a state matching neither preset, and the tray and the browser can now
|
||||
// both start one.
|
||||
applying sync.Mutex
|
||||
}
|
||||
|
||||
// New builds a Switcher.
|
||||
func New(store *preset.Store, mgr netcfg.Manager) *Switcher {
|
||||
return &Switcher{store: store, mgr: mgr}
|
||||
}
|
||||
|
||||
// Preview returns a preset alongside the live configuration of its adapter,
|
||||
// read at call time. This is what both confirmation prompts are built from, so
|
||||
// it must never serve a cached view — a stale "CURRENT" block would undermine
|
||||
// the only safety net before a destructive change.
|
||||
func (s *Switcher) Preview(id string) (preset.Preset, netcfg.LiveConfig, error) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return preset.Preset{}, netcfg.LiveConfig{}, ErrNotFound
|
||||
}
|
||||
|
||||
live, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
return p, netcfg.LiveConfig{}, fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err)
|
||||
}
|
||||
return p, live, nil
|
||||
}
|
||||
|
||||
// Apply switches the adapter to the preset. The caller is responsible for
|
||||
// having confirmed with the user first.
|
||||
//
|
||||
// The live configuration is read again here rather than taken from a preceding
|
||||
// Preview: the plan's delete step is built from the addresses actually on the
|
||||
// adapter, and between the prompt appearing and the user clicking Yes, a DHCP
|
||||
// lease or another tool may have changed them.
|
||||
func (s *Switcher) Apply(id string) error {
|
||||
s.applying.Lock()
|
||||
defer s.applying.Unlock()
|
||||
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
live, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err)
|
||||
}
|
||||
|
||||
log.Printf("applying preset %q to %q", p.Name, p.Adapter)
|
||||
if err := s.mgr.Apply(netcfg.Plan(p, live)); err != nil {
|
||||
log.Printf("apply of %q failed: %v", p.Name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied preset %q", p.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// State is a snapshot of which preset is live.
|
||||
type State struct {
|
||||
// ActiveID is the preset exactly matching its adapter, empty if none does.
|
||||
ActiveID string `json:"active_id"`
|
||||
ActiveName string `json:"active_name"`
|
||||
Adapters map[string]netcfg.LiveConfig `json:"adapters"`
|
||||
}
|
||||
|
||||
// Active works out which preset, if any, is currently applied.
|
||||
//
|
||||
// One read per distinct adapter rather than one per preset: fifty presets
|
||||
// spread across three adapters is three reads, not fifty. This runs every 30
|
||||
// seconds, so the difference matters.
|
||||
func (s *Switcher) Active() State {
|
||||
presets := s.store.All()
|
||||
st := State{Adapters: make(map[string]netcfg.LiveConfig)}
|
||||
|
||||
for _, p := range presets {
|
||||
if _, done := st.Adapters[p.Adapter]; done {
|
||||
continue
|
||||
}
|
||||
cfg, err := s.mgr.Current(p.Adapter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
st.Adapters[p.Adapter] = cfg
|
||||
}
|
||||
|
||||
for _, p := range presets {
|
||||
if cfg, ok := st.Adapters[p.Adapter]; ok && netcfg.Matches(p, cfg) {
|
||||
st.ActiveID, st.ActiveName = p.ID, p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ConfirmText renders the before/after text for a preset, for callers that
|
||||
// want the prompt body without applying anything.
|
||||
func (s *Switcher) ConfirmText(p preset.Preset, live netcfg.LiveConfig) string {
|
||||
return netcfg.ConfirmText(p, live)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package switcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// fakeManager records what was applied and lets a test drive adapter state.
|
||||
type fakeManager struct {
|
||||
mu sync.Mutex
|
||||
live map[string]netcfg.LiveConfig
|
||||
applied [][]netcfg.Command
|
||||
err error
|
||||
// onApply runs inside Apply, for exercising concurrency.
|
||||
onApply func()
|
||||
}
|
||||
|
||||
func (f *fakeManager) Adapters() ([]netcfg.Adapter, error) {
|
||||
return []netcfg.Adapter{{Name: "Ethernet", Up: true}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeManager) Current(adapter string) (netcfg.LiveConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
cfg, ok := f.live[adapter]
|
||||
if !ok {
|
||||
return netcfg.LiveConfig{}, errors.New("adapter not found")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (f *fakeManager) Apply(plan []netcfg.Command) error {
|
||||
if f.onApply != nil {
|
||||
f.onApply()
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.applied = append(f.applied, plan)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*Switcher, *preset.Store, *fakeManager) {
|
||||
t.Helper()
|
||||
|
||||
store, err := preset.NewStore(filepath.Join(t.TempDir(), "presets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := &fakeManager{live: map[string]netcfg.LiveConfig{
|
||||
"Ethernet": {
|
||||
Adapter: "Ethernet",
|
||||
DHCP: true,
|
||||
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}},
|
||||
Gateways: []string{"192.168.1.1"},
|
||||
DNS: []string{"192.168.1.1"},
|
||||
DNSFromDHCP: true,
|
||||
},
|
||||
}}
|
||||
return New(store, mgr), store, mgr
|
||||
}
|
||||
|
||||
func staticPreset() preset.Preset {
|
||||
return preset.Preset{
|
||||
Name: "Control",
|
||||
Group: "Riedel",
|
||||
Adapter: "Ethernet",
|
||||
Mode: preset.ModeStatic,
|
||||
Primary: &preset.Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"},
|
||||
Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}},
|
||||
DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewReadsLiveState(t *testing.T) {
|
||||
sw, store, _ := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, live, err := sw.Preview(saved.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Name != "Control" {
|
||||
t.Errorf("preview returned preset %q", p.Name)
|
||||
}
|
||||
if len(live.Addresses) != 1 || live.Addresses[0].Address != "192.168.1.87" {
|
||||
t.Errorf("preview did not return the live config: %+v", live)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewUnknownID(t *testing.T) {
|
||||
sw, _, _ := setup(t)
|
||||
if _, _, err := sw.Preview("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Preview of an unknown id = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRunsThePlan(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := sw.Apply(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mgr.applied) != 1 {
|
||||
t.Fatalf("expected one plan to be applied, got %d", len(mgr.applied))
|
||||
}
|
||||
|
||||
joined := ""
|
||||
for _, c := range mgr.applied[0] {
|
||||
joined += c.String() + "\n"
|
||||
}
|
||||
for _, want := range []string{"192.168.42.100", "255.255.255.0", "10.0.10.50", "dnsservers"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The live read happens inside Apply, not carried over from Preview: between
|
||||
// the prompt appearing and the user clicking yes, the adapter can change.
|
||||
func TestApplyRereadsLiveState(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := sw.Preview(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The adapter picks up two static addresses after the preview.
|
||||
mgr.mu.Lock()
|
||||
mgr.live["Ethernet"] = netcfg.LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "10.1.1.1", Prefix: 24}, {Address: "10.2.2.2", Prefix: 24}},
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
if err := sw.Apply(saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deletes int
|
||||
for _, c := range mgr.applied[0] {
|
||||
if c.Args[2] == "delete" {
|
||||
deletes++
|
||||
}
|
||||
}
|
||||
if deletes != 2 {
|
||||
t.Errorf("expected 2 deletes for the addresses present at apply time, got %d", deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUnknownID(t *testing.T) {
|
||||
sw, _, _ := setup(t)
|
||||
if err := sw.Apply("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Apply of an unknown id = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two front ends can now start an apply. Interleaving two netsh sequences on
|
||||
// one adapter would leave it matching neither preset.
|
||||
func TestApplyIsSerialised(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
saved, err := store.Put(staticPreset())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var concurrent, maxConcurrent int
|
||||
var mu sync.Mutex
|
||||
mgr.onApply = func() {
|
||||
mu.Lock()
|
||||
concurrent++
|
||||
if concurrent > maxConcurrent {
|
||||
maxConcurrent = concurrent
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Hold the "apply" open long enough that an unserialised second one
|
||||
// would be observed overlapping. Without this the counter would go up
|
||||
// and straight back down and the test could never fail.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
concurrent--
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = sw.Apply(saved.ID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if maxConcurrent > 1 {
|
||||
t.Errorf("applies overlapped: max concurrency %d", maxConcurrent)
|
||||
}
|
||||
if len(mgr.applied) != 8 {
|
||||
t.Errorf("expected 8 applies, got %d", len(mgr.applied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveMatchesLivePreset(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
|
||||
dhcp := preset.Preset{
|
||||
Name: "DHCP", Adapter: "Ethernet",
|
||||
Mode: preset.ModeDHCP,
|
||||
DNS: preset.DNS{Mode: preset.ModeDHCP},
|
||||
}
|
||||
saved, err := store.Put(dhcp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Put(staticPreset()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := sw.Active()
|
||||
if st.ActiveID != saved.ID {
|
||||
t.Errorf("active id = %q, want %q (the DHCP preset matches the live state)", st.ActiveID, saved.ID)
|
||||
}
|
||||
if st.ActiveName != "DHCP" {
|
||||
t.Errorf("active name = %q", st.ActiveName)
|
||||
}
|
||||
if _, ok := st.Adapters["Ethernet"]; !ok {
|
||||
t.Error("Active should report the adapter state it read")
|
||||
}
|
||||
|
||||
// Nothing matches once the adapter moves to a static address.
|
||||
mgr.mu.Lock()
|
||||
mgr.live["Ethernet"] = netcfg.LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "172.16.0.9", Prefix: 24}},
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
if st := sw.Active(); st.ActiveID != "" {
|
||||
t.Errorf("expected no active preset, got %q", st.ActiveName)
|
||||
}
|
||||
}
|
||||
|
||||
// Fifty presets across three adapters should be three reads, not fifty.
|
||||
func TestActiveReadsEachAdapterOnce(t *testing.T) {
|
||||
sw, store, mgr := setup(t)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
p := staticPreset()
|
||||
p.Name = "preset"
|
||||
if _, err := store.Put(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
st := sw.Active()
|
||||
if len(st.Adapters) != 1 {
|
||||
t.Errorf("expected one adapter read, got %d", len(st.Adapters))
|
||||
}
|
||||
_ = mgr
|
||||
}
|
||||
+34
-47
@@ -10,6 +10,7 @@ package tray
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/server"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/switcher"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/updater"
|
||||
)
|
||||
|
||||
@@ -33,11 +35,16 @@ var iconICO []byte
|
||||
// App owns the tray and everything it talks to.
|
||||
type App struct {
|
||||
Store *preset.Store
|
||||
Manager netcfg.Manager
|
||||
Switcher *switcher.Switcher
|
||||
Server *server.Server
|
||||
Paths config.Paths
|
||||
Version string
|
||||
|
||||
// OpenOnStart opens the editor as soon as the tray is up. Set for a
|
||||
// launch from the shortcut, cleared for the start-with-Windows launch —
|
||||
// nobody wants a browser tab at login.
|
||||
OpenOnStart bool
|
||||
|
||||
mu sync.Mutex
|
||||
settings config.Settings
|
||||
// activeID is the preset currently matching the live adapter state, empty
|
||||
@@ -60,10 +67,10 @@ type App struct {
|
||||
}
|
||||
|
||||
// New builds the tray application.
|
||||
func New(store *preset.Store, mgr netcfg.Manager, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App {
|
||||
func New(store *preset.Store, sw *switcher.Switcher, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App {
|
||||
return &App{
|
||||
Store: store,
|
||||
Manager: mgr,
|
||||
Switcher: sw,
|
||||
Server: srv,
|
||||
Paths: paths,
|
||||
Version: version,
|
||||
@@ -79,6 +86,10 @@ func (a *App) Run() {
|
||||
go a.rebuild()
|
||||
})
|
||||
a.Server.OnSettingsChange(a.applySettings)
|
||||
a.Server.OnApplied(func() {
|
||||
// A switch made in the browser must move the tray's check mark too.
|
||||
go a.refreshActive()
|
||||
})
|
||||
|
||||
systray.Run(a.onReady, a.onExit)
|
||||
}
|
||||
@@ -92,6 +103,12 @@ func (a *App) onReady() {
|
||||
|
||||
go a.pollActive()
|
||||
go a.checkUpdatesOnStartup()
|
||||
|
||||
if a.OpenOnStart {
|
||||
// After the menu exists, so a failure to open the browser still leaves
|
||||
// a usable tray icon behind.
|
||||
go a.OpenEditor()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) onExit() {
|
||||
@@ -165,7 +182,7 @@ func (a *App) build() {
|
||||
systray.AddSeparator()
|
||||
|
||||
manage := systray.AddMenuItem("Manage presets…", "Open the preset editor in your browser")
|
||||
a.onClick(gen, manage, a.openEditor)
|
||||
a.onClick(gen, manage, a.OpenEditor)
|
||||
|
||||
check := systray.AddMenuItem("Check for updates", "")
|
||||
a.onClick(gen, check, a.checkUpdatesNow)
|
||||
@@ -204,18 +221,13 @@ func (a *App) onClick(gen <-chan struct{}, item *systray.MenuItem, fn func()) {
|
||||
// applyPreset is the whole fast path: read the live config, show the diff,
|
||||
// and on a Yes run the netsh plan.
|
||||
func (a *App) applyPreset(id string) {
|
||||
p, ok := a.Store.Get(id)
|
||||
if !ok {
|
||||
dialog.Error(config.AppName, "That preset no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
// Read live rather than reusing the poll's cached view: this text is the
|
||||
// only safety net before a destructive change and it must not be stale.
|
||||
live, err := a.Manager.Current(p.Adapter)
|
||||
p, live, err := a.Switcher.Preview(id)
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf(
|
||||
"Could not read the current configuration of adapter %q.\n\n%v", p.Adapter, err))
|
||||
if errors.Is(err, switcher.ErrNotFound) {
|
||||
dialog.Error(config.AppName, "That preset no longer exists.")
|
||||
return
|
||||
}
|
||||
dialog.Error(config.AppName, fmt.Sprintf("Could not read the adapter.\n\n%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,12 +236,7 @@ func (a *App) applyPreset(id string) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("applying preset %q to %q", p.Name, p.Adapter)
|
||||
plan := netcfg.Plan(p, live)
|
||||
|
||||
if err := a.Manager.Apply(plan); err != nil {
|
||||
log.Printf("apply of %q failed: %v", p.Name, err)
|
||||
|
||||
if err := a.Switcher.Apply(id); err != nil {
|
||||
if isElevationErr(err) && !a.elevated {
|
||||
if dialog.ErrorWithRetryAsAdmin(config.AppName,
|
||||
fmt.Sprintf("Applying %q needs administrator rights.", p.Name)) {
|
||||
@@ -241,11 +248,13 @@ func (a *App) applyPreset(id string) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("applied preset %q", p.Name)
|
||||
a.refreshActive()
|
||||
}
|
||||
|
||||
func (a *App) openEditor() {
|
||||
// OpenEditor starts the editor server if needed and opens the browser at it.
|
||||
// Exported because a second launch of ipswap reaches this through the control
|
||||
// channel rather than starting a tray of its own.
|
||||
func (a *App) OpenEditor() {
|
||||
url, err := a.Server.Start()
|
||||
if err != nil {
|
||||
dialog.Error(config.AppName, fmt.Sprintf("Could not start the preset editor.\n\n%v", err))
|
||||
@@ -298,30 +307,8 @@ func (a *App) pollActive() {
|
||||
}
|
||||
|
||||
func (a *App) refreshActive() {
|
||||
presets := a.Store.All()
|
||||
|
||||
// One read per distinct adapter rather than one per preset: fifty presets
|
||||
// across three adapters is three reads, not fifty.
|
||||
live := map[string]netcfg.LiveConfig{}
|
||||
for _, p := range presets {
|
||||
if _, done := live[p.Adapter]; done {
|
||||
continue
|
||||
}
|
||||
cfg, err := a.Manager.Current(p.Adapter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
live[p.Adapter] = cfg
|
||||
}
|
||||
|
||||
activeID, activeName := "", ""
|
||||
for _, p := range presets {
|
||||
cfg, ok := live[p.Adapter]
|
||||
if ok && netcfg.Matches(p, cfg) {
|
||||
activeID, activeName = p.ID, p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
st := a.Switcher.Active()
|
||||
activeID, activeName := st.ActiveID, st.ActiveName
|
||||
|
||||
a.mu.Lock()
|
||||
a.activeID = activeID
|
||||
|
||||
Reference in New Issue
Block a user