23dcfb393f
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>
239 lines
5.8 KiB
Go
239 lines
5.8 KiB
Go
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[:]))
|
|
}
|