Scaffold ipswap: tray-driven Windows IP preset switcher
Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ImportMode is what to do with the presets already on disk.
|
||||
type ImportMode string
|
||||
|
||||
const (
|
||||
// ImportMerge keeps existing presets and adds the incoming ones.
|
||||
ImportMerge ImportMode = "merge"
|
||||
// ImportReplace discards everything already saved.
|
||||
ImportReplace ImportMode = "replace"
|
||||
)
|
||||
|
||||
// Export renders the presets as a shippable preset pack. Passing one or more
|
||||
// group names filters to just those groups; passing none exports everything.
|
||||
//
|
||||
// The output is the same shape as presets.json so a pack can be dropped
|
||||
// straight in as a starting file, and it stays hand-editable.
|
||||
func (s *Store) Export(groups ...string) ([]byte, error) {
|
||||
want := make(map[string]bool, len(groups))
|
||||
for _, g := range groups {
|
||||
want[strings.ToLower(strings.TrimSpace(g))] = true
|
||||
}
|
||||
|
||||
out := File{Version: SchemaVersion}
|
||||
for _, p := range s.All() {
|
||||
if len(want) > 0 && !want[strings.ToLower(p.GroupOrUngrouped())] {
|
||||
continue
|
||||
}
|
||||
out.Presets = append(out.Presets, p)
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encoding export: %w", err)
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// ImportResult reports what an import did, for the summary shown afterwards.
|
||||
type ImportResult struct {
|
||||
Added int `json:"added"`
|
||||
Replaced int `json:"replaced"`
|
||||
Renamed []string `json:"renamed,omitempty"`
|
||||
}
|
||||
|
||||
// Import validates an incoming preset file in full before touching anything,
|
||||
// then applies it. A file that fails validation changes nothing on disk.
|
||||
//
|
||||
// On merge, an incoming preset whose id already exists is kept alongside the
|
||||
// existing one: it gets a fresh id and "(imported)" appended to its name, so a
|
||||
// colleague's pack can never silently overwrite what you already had.
|
||||
func (s *Store) Import(data []byte, mode ImportMode) (ImportResult, error) {
|
||||
incoming, err := Decode(data)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
var res ImportResult
|
||||
|
||||
switch mode {
|
||||
case ImportReplace:
|
||||
s.mu.Lock()
|
||||
s.file = File{Version: SchemaVersion, Presets: incoming.Presets}
|
||||
res.Added = len(incoming.Presets)
|
||||
s.mu.Unlock()
|
||||
|
||||
case ImportMerge:
|
||||
s.mu.Lock()
|
||||
existing := make(map[string]bool, len(s.file.Presets))
|
||||
for _, p := range s.file.Presets {
|
||||
existing[p.ID] = true
|
||||
}
|
||||
for _, p := range incoming.Presets {
|
||||
if existing[p.ID] {
|
||||
p.ID = NewID()
|
||||
p.Name = p.Name + " (imported)"
|
||||
res.Renamed = append(res.Renamed, p.Name)
|
||||
}
|
||||
existing[p.ID] = true
|
||||
s.file.Presets = append(s.file.Presets, p)
|
||||
res.Added++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
default:
|
||||
return ImportResult{}, fmt.Errorf("unknown import mode %q", mode)
|
||||
}
|
||||
|
||||
if err := s.Save(); err != nil {
|
||||
// Reload so the in-memory copy matches whatever is actually on disk.
|
||||
_ = s.Load()
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsePrefix accepts every spelling of a subnet mask the editor is required to
|
||||
// take — "24", "/24" and "255.255.255.0" — and returns the prefix length.
|
||||
//
|
||||
// Only contiguous masks are accepted. 255.255.0.255 is a typo, not a netmask,
|
||||
// and letting it through would produce a netsh call that fails confusingly.
|
||||
func ParsePrefix(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty mask")
|
||||
}
|
||||
s = strings.TrimPrefix(s, "/")
|
||||
|
||||
if strings.Contains(s, ".") {
|
||||
addr, err := netip.ParseAddr(s)
|
||||
if err != nil || !addr.Is4() {
|
||||
return 0, fmt.Errorf("%q is not a dotted-quad netmask", s)
|
||||
}
|
||||
b := addr.As4()
|
||||
mask := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
|
||||
ones := 0
|
||||
for ones < 32 && mask&(1<<uint(31-ones)) != 0 {
|
||||
ones++
|
||||
}
|
||||
// Everything below the leading run of ones must be zero.
|
||||
if ones < 32 && mask<<uint(ones) != 0 {
|
||||
return 0, fmt.Errorf("%q is not a contiguous netmask", s)
|
||||
}
|
||||
return ones, nil
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q is not a prefix length", s)
|
||||
}
|
||||
if n < 0 || n > 32 {
|
||||
return 0, fmt.Errorf("prefix /%d out of range", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MaskString renders a prefix length as the dotted-quad netmask netsh expects
|
||||
// on the command line. netsh will not take "/24".
|
||||
func MaskString(prefix int) string {
|
||||
if prefix < 0 || prefix > 32 {
|
||||
prefix = 32
|
||||
}
|
||||
var mask uint32
|
||||
if prefix > 0 {
|
||||
mask = ^uint32(0) << uint(32-prefix)
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d", mask>>24&0xff, mask>>16&0xff, mask>>8&0xff, mask&0xff)
|
||||
}
|
||||
|
||||
// FormatPrefix renders a prefix in whichever notation the user last chose.
|
||||
// dotted controls the style; see config.Settings.MaskStyle.
|
||||
func FormatPrefix(prefix int, dotted bool) string {
|
||||
if dotted {
|
||||
return MaskString(prefix)
|
||||
}
|
||||
return "/" + strconv.Itoa(prefix)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package preset
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePrefix(t *testing.T) {
|
||||
ok := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"24", 24},
|
||||
{"/24", 24},
|
||||
{" /24 ", 24},
|
||||
{"255.255.255.0", 24},
|
||||
{"255.255.255.255", 32},
|
||||
{"0.0.0.0", 0},
|
||||
{"255.255.254.0", 23},
|
||||
{"128.0.0.0", 1},
|
||||
{"32", 32},
|
||||
{"0", 0},
|
||||
}
|
||||
for _, tc := range ok {
|
||||
got, err := ParsePrefix(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParsePrefix(%q) returned error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParsePrefix(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []string{"", "33", "-1", "255.255.0.255", "1.2.3", "abc", "255.0.255.0", "/", "24.5"}
|
||||
for _, in := range bad {
|
||||
if got, err := ParsePrefix(in); err == nil {
|
||||
t.Errorf("ParsePrefix(%q) = %d, want error", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskStringRoundTrip(t *testing.T) {
|
||||
for p := 0; p <= 32; p++ {
|
||||
got, err := ParsePrefix(MaskString(p))
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrefix(MaskString(%d)) errored: %v", p, err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("round trip of /%d gave /%d (mask %s)", p, got, MaskString(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskStringKnown(t *testing.T) {
|
||||
cases := map[int]string{
|
||||
0: "0.0.0.0",
|
||||
8: "255.0.0.0",
|
||||
16: "255.255.0.0",
|
||||
24: "255.255.255.0",
|
||||
30: "255.255.255.252",
|
||||
32: "255.255.255.255",
|
||||
}
|
||||
for prefix, want := range cases {
|
||||
if got := MaskString(prefix); got != want {
|
||||
t.Errorf("MaskString(%d) = %s, want %s", prefix, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package preset holds the on-disk preset model and everything that reads or
|
||||
// writes it. It is deliberately free of Windows-specific code so the whole
|
||||
// model can be exercised by tests on any platform.
|
||||
package preset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SchemaVersion is written into every file we save and checked on every file we
|
||||
// load. Bump it only for a breaking change, and add a migration when you do.
|
||||
const SchemaVersion = 1
|
||||
|
||||
// Mode is how an adapter gets its addresses.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeStatic Mode = "static"
|
||||
ModeDHCP Mode = "dhcp"
|
||||
)
|
||||
|
||||
// File is the top-level shape of presets.json.
|
||||
type File struct {
|
||||
Version int `json:"version"`
|
||||
Presets []Preset `json:"presets"`
|
||||
}
|
||||
|
||||
// Preset is one saved adapter configuration.
|
||||
type Preset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Group drives the tray submenus. Empty means "Ungrouped".
|
||||
Group string `json:"group,omitempty"`
|
||||
// Adapter is the Windows friendly name, e.g. "Ethernet" or "Wi-Fi 2".
|
||||
Adapter string `json:"adapter"`
|
||||
Mode Mode `json:"mode"`
|
||||
|
||||
// Primary is required when Mode is static and ignored when it is dhcp.
|
||||
Primary *Address `json:"primary,omitempty"`
|
||||
// Secondary addresses are added after the primary is set.
|
||||
Secondary []Address `json:"secondary,omitempty"`
|
||||
|
||||
DNS DNS `json:"dns"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Address is a single IPv4 address with its prefix length, and optionally the
|
||||
// default gateway reached through it.
|
||||
type Address struct {
|
||||
Address string `json:"address"`
|
||||
// Prefix is the mask stored as a prefix length. The editor accepts
|
||||
// dotted-quad on input and converts here; see ParsePrefix.
|
||||
Prefix int `json:"prefix"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
// GatewayMetric 0 means automatic, which is what netsh wants for "let
|
||||
// Windows decide".
|
||||
GatewayMetric int `json:"gateway_metric,omitempty"`
|
||||
}
|
||||
|
||||
// DNS is the resolver configuration for a preset.
|
||||
type DNS struct {
|
||||
Mode Mode `json:"mode"`
|
||||
Servers []string `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
// GroupOrUngrouped is the submenu a preset belongs under.
|
||||
func (p Preset) GroupOrUngrouped() string {
|
||||
if strings.TrimSpace(p.Group) == "" {
|
||||
return "Ungrouped"
|
||||
}
|
||||
return p.Group
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a preset. Import rejects
|
||||
// a whole file rather than partially applying it, so this needs to be strict.
|
||||
func (p Preset) Validate() error {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
return fmt.Errorf("preset has no id")
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return fmt.Errorf("preset %s has no name", p.ID)
|
||||
}
|
||||
if strings.TrimSpace(p.Adapter) == "" {
|
||||
return fmt.Errorf("preset %q has no adapter", p.Name)
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
case ModeDHCP:
|
||||
// Nothing else to check: any addresses present are ignored on apply.
|
||||
case ModeStatic:
|
||||
if p.Primary == nil {
|
||||
return fmt.Errorf("preset %q is static but has no primary address", p.Name)
|
||||
}
|
||||
if err := p.Primary.validate(); err != nil {
|
||||
return fmt.Errorf("preset %q primary: %w", p.Name, err)
|
||||
}
|
||||
for i, s := range p.Secondary {
|
||||
if err := s.validate(); err != nil {
|
||||
return fmt.Errorf("preset %q secondary %d: %w", p.Name, i+1, err)
|
||||
}
|
||||
// A gateway on a secondary address is silently dropped by
|
||||
// `netsh add address`, so reject it rather than pretend.
|
||||
if s.Gateway != "" {
|
||||
return fmt.Errorf("preset %q secondary %d: secondary addresses cannot carry a gateway", p.Name, i+1)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("preset %q has unknown mode %q", p.Name, p.Mode)
|
||||
}
|
||||
|
||||
switch p.DNS.Mode {
|
||||
case ModeDHCP:
|
||||
case ModeStatic:
|
||||
if len(p.DNS.Servers) == 0 {
|
||||
return fmt.Errorf("preset %q has static DNS but no servers", p.Name)
|
||||
}
|
||||
for _, s := range p.DNS.Servers {
|
||||
if _, err := parseV4(s); err != nil {
|
||||
return fmt.Errorf("preset %q dns server %q: %w", p.Name, s, err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("preset %q has unknown dns mode %q", p.Name, p.DNS.Mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Address) validate() error {
|
||||
if _, err := parseV4(a.Address); err != nil {
|
||||
return fmt.Errorf("address %q: %w", a.Address, err)
|
||||
}
|
||||
if a.Prefix < 0 || a.Prefix > 32 {
|
||||
return fmt.Errorf("prefix /%d out of range", a.Prefix)
|
||||
}
|
||||
if a.Gateway != "" {
|
||||
if _, err := parseV4(a.Gateway); err != nil {
|
||||
return fmt.Errorf("gateway %q: %w", a.Gateway, err)
|
||||
}
|
||||
}
|
||||
if a.GatewayMetric < 0 {
|
||||
return fmt.Errorf("gateway metric %d is negative", a.GatewayMetric)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String renders an address the way the confirmation dialog shows it.
|
||||
func (a Address) String() string { return fmt.Sprintf("%s/%d", a.Address, a.Prefix) }
|
||||
|
||||
func parseV4(s string) (netip.Addr, error) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("not an IP address")
|
||||
}
|
||||
if !addr.Is4() {
|
||||
// v1 is IPv4 only; see "Out of scope" in claude.md.
|
||||
return netip.Addr{}, fmt.Errorf("not an IPv4 address")
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store owns presets.json. Every mutation goes through it so the file is
|
||||
// written atomically and the in-memory copy never drifts from disk.
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
file File
|
||||
}
|
||||
|
||||
// NewStore opens (or creates) the preset file at path.
|
||||
func NewStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, file: File{Version: SchemaVersion}}
|
||||
if err := s.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Load re-reads the file from disk. A missing file is not an error: a fresh
|
||||
// install has no presets yet and should start with an empty list rather than
|
||||
// refusing to launch.
|
||||
func (s *Store) Load() error {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
s.mu.Lock()
|
||||
s.file = File{Version: SchemaVersion}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
f, err := Decode(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.file = f
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode parses and fully validates a preset file. Import uses it too, which is
|
||||
// why it rejects the whole document on the first bad preset rather than
|
||||
// dropping the offender and continuing.
|
||||
func Decode(b []byte) (File, error) {
|
||||
var f File
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return File{}, fmt.Errorf("not a valid preset file: %w", err)
|
||||
}
|
||||
|
||||
if f.Version != SchemaVersion {
|
||||
return File{}, fmt.Errorf("preset file is version %d, this build understands version %d", f.Version, SchemaVersion)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(f.Presets))
|
||||
for _, p := range f.Presets {
|
||||
if err := p.Validate(); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if seen[p.ID] {
|
||||
return File{}, fmt.Errorf("duplicate preset id %q", p.ID)
|
||||
}
|
||||
seen[p.ID] = true
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Save writes the file atomically: a temp file in the same directory, then a
|
||||
// rename. A half-written presets.json after a crash would lose every preset,
|
||||
// and this is the user's only copy.
|
||||
func (s *Store) Save() error {
|
||||
s.mu.RLock()
|
||||
b, err := json.MarshalIndent(s.file, "", " ")
|
||||
s.mu.RUnlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding presets: %w", err)
|
||||
}
|
||||
b = append(b, '\n')
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("creating preset directory: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".presets-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op once the rename below succeeds
|
||||
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("writing temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("syncing temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("closing temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
return fmt.Errorf("replacing %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All returns a copy of the presets, sorted by group then name so the tray menu
|
||||
// and the editor agree on ordering.
|
||||
func (s *Store) All() []Preset {
|
||||
s.mu.RLock()
|
||||
out := make([]Preset, len(s.file.Presets))
|
||||
copy(out, s.file.Presets)
|
||||
s.mu.RUnlock()
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
gi, gj := out[i].GroupOrUngrouped(), out[j].GroupOrUngrouped()
|
||||
if gi != gj {
|
||||
// Ungrouped sinks to the bottom of the tray menu.
|
||||
if gi == "Ungrouped" {
|
||||
return false
|
||||
}
|
||||
if gj == "Ungrouped" {
|
||||
return true
|
||||
}
|
||||
return strings.ToLower(gi) < strings.ToLower(gj)
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns the preset with the given id.
|
||||
func (s *Store) Get(id string) (Preset, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, p := range s.file.Presets {
|
||||
if p.ID == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Preset{}, false
|
||||
}
|
||||
|
||||
// Put inserts or replaces a preset and saves. An empty ID means "new".
|
||||
func (s *Store) Put(p Preset) (Preset, error) {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
p.ID = NewID()
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return Preset{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
replaced := false
|
||||
for i := range s.file.Presets {
|
||||
if s.file.Presets[i].ID == p.ID {
|
||||
s.file.Presets[i] = p
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
s.file.Presets = append(s.file.Presets, p)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return p, s.Save()
|
||||
}
|
||||
|
||||
// Delete removes a preset by id and saves.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
kept := s.file.Presets[:0]
|
||||
found := false
|
||||
for _, p := range s.file.Presets {
|
||||
if p.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
s.file.Presets = kept
|
||||
s.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("no preset with id %q", id)
|
||||
}
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
// Groups lists the distinct groups in tray-menu order.
|
||||
func (s *Store) Groups() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range s.All() {
|
||||
g := p.GroupOrUngrouped()
|
||||
if !seen[g] {
|
||||
seen[g] = true
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewID mints a preset id. claude.md shows ULID-shaped ids in the example file,
|
||||
// but nothing depends on them being sortable or on their length — an id only
|
||||
// has to be unique and stable — so this stays dependency-free: a millisecond
|
||||
// timestamp for rough ordering plus 64 bits of randomness.
|
||||
func NewID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand does not fail in practice on Windows; if it somehow
|
||||
// does, a timestamp-only id is still unique enough to save with.
|
||||
return fmt.Sprintf("%013x", time.Now().UnixMilli())
|
||||
}
|
||||
return fmt.Sprintf("%013x%s", time.Now().UnixMilli(), hex.EncodeToString(b[:]))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "presets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sample() Preset {
|
||||
return Preset{
|
||||
Name: "Artist frame — control",
|
||||
Group: "Riedel",
|
||||
Adapter: "Ethernet",
|
||||
Mode: ModeStatic,
|
||||
Primary: &Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"},
|
||||
DNS: DNS{Mode: ModeStatic, Servers: []string{"192.168.42.1"}},
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped example doubles as the documented export format, so a change
|
||||
// that breaks it breaks every preset pack already in circulation.
|
||||
func TestExamplePackDecodes(t *testing.T) {
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "examples", "preset-pack.json"))
|
||||
if err != nil {
|
||||
t.Skipf("example pack not present: %v", err)
|
||||
}
|
||||
f, err := Decode(b)
|
||||
if err != nil {
|
||||
t.Fatalf("examples/preset-pack.json does not validate: %v", err)
|
||||
}
|
||||
if len(f.Presets) == 0 {
|
||||
t.Fatal("example pack is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileStartsEmpty(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if len(s.All()) != 0 {
|
||||
t.Errorf("a fresh store should have no presets, got %d", len(s.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAssignsIDAndPersists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "presets.json")
|
||||
s, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
saved, err := s.Put(sample())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ID == "" {
|
||||
t.Fatal("Put should have assigned an id")
|
||||
}
|
||||
|
||||
reopened, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("saved file did not reload: %v", err)
|
||||
}
|
||||
if got, ok := reopened.Get(saved.ID); !ok || got.Name != saved.Name {
|
||||
t.Errorf("preset did not survive a reload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejectsInvalid(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
bad := sample()
|
||||
bad.Primary.Address = "not-an-ip"
|
||||
if _, err := s.Put(bad); err == nil {
|
||||
t.Error("expected a validation error for a bad address")
|
||||
}
|
||||
|
||||
// A secondary carrying a gateway is silently dropped by netsh, so it is
|
||||
// rejected rather than saved as a lie.
|
||||
withGW := sample()
|
||||
withGW.Secondary = []Address{{Address: "10.0.0.5", Prefix: 24, Gateway: "10.0.0.1"}}
|
||||
if _, err := s.Put(withGW); err == nil {
|
||||
t.Error("expected a validation error for a secondary with a gateway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsWrongVersion(t *testing.T) {
|
||||
_, err := Decode([]byte(`{"version": 99, "presets": []}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "version") {
|
||||
t.Errorf("expected a version error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsDuplicateIDs(t *testing.T) {
|
||||
doc := `{"version":1,"presets":[
|
||||
{"id":"a","name":"one","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}},
|
||||
{"id":"a","name":"two","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := Decode([]byte(doc)); err == nil {
|
||||
t.Error("expected a duplicate-id error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportMergeKeepsBothOnIDCollision(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
saved, err := s.Put(sample())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pack, err := s.Export()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := s.Import(pack, ImportMerge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Added != 1 || len(res.Renamed) != 1 {
|
||||
t.Fatalf("expected 1 added and 1 renamed, got %+v", res)
|
||||
}
|
||||
if len(s.All()) != 2 {
|
||||
t.Fatalf("merge should keep both, got %d presets", len(s.All()))
|
||||
}
|
||||
|
||||
// The original must be untouched: a colleague's pack silently overwriting
|
||||
// a working preset is the failure this guards against.
|
||||
orig, ok := s.Get(saved.ID)
|
||||
if !ok || orig.Name != saved.Name {
|
||||
t.Errorf("original preset was modified: %+v", orig)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, p := range s.All() {
|
||||
if strings.HasSuffix(p.Name, "(imported)") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the colliding import should have been suffixed with (imported)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportReplaceDiscardsExisting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
doc := `{"version":1,"presets":[{"id":"z","name":"only","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := s.Import([]byte(doc), ImportReplace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := s.All()
|
||||
if len(all) != 1 || all[0].ID != "z" {
|
||||
t.Errorf("replace should leave exactly the incoming presets, got %+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected import must change nothing: partial application would leave the
|
||||
// user with a half-imported pack and no way to tell what landed.
|
||||
func TestImportRejectsWithoutMutating(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(s.All())
|
||||
|
||||
bad := `{"version":1,"presets":[{"id":"x","name":"broken","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := s.Import([]byte(bad), ImportMerge); err == nil {
|
||||
t.Fatal("expected the import to be rejected")
|
||||
}
|
||||
if len(s.All()) != before {
|
||||
t.Errorf("a rejected import changed the store: %d -> %d", before, len(s.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFiltersByGroup(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := sample()
|
||||
other.Name = "Management"
|
||||
other.Group = "Herespace"
|
||||
if _, err := s.Put(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := s.Export("Riedel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := Decode(b)
|
||||
if err != nil {
|
||||
t.Fatalf("a filtered export must still be a valid pack: %v", err)
|
||||
}
|
||||
if len(f.Presets) != 1 || f.Presets[0].Group != "Riedel" {
|
||||
t.Errorf("group filter returned %+v", f.Presets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUngroupedSortsLast(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
loose := sample()
|
||||
loose.Name = "Loose"
|
||||
loose.Group = ""
|
||||
if _, err := s.Put(loose); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
groups := s.Groups()
|
||||
if len(groups) != 2 || groups[len(groups)-1] != "Ungrouped" {
|
||||
t.Errorf("Ungrouped should sort last, got %v", groups)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user