Files
ipswap/internal/config/config.go
T
bsncubed 23dcfb393f 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>
2026-08-20 13:29:35 +10:00

131 lines
3.9 KiB
Go

// Package config resolves where ipswap keeps its files and loads the settings
// that are not presets.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// AppName is the single place the product name appears. It decides the data
// directory, the log file name and the tray tooltip prefix, so renaming the app
// is a one-line change here.
const AppName = "ipswap"
// Paths are the absolute locations of everything ipswap reads or writes.
type Paths struct {
Dir string
Presets string
Config string
Log string
}
// ResolvePaths returns the data locations under %APPDATA%\ipswap on Windows.
//
// os.UserConfigDir maps to %APPDATA% on Windows and to ~/.config elsewhere,
// which keeps development on Linux from scribbling in odd places.
func ResolvePaths() (Paths, error) {
base, err := os.UserConfigDir()
if err != nil {
return Paths{}, fmt.Errorf("locating the application data directory: %w", err)
}
dir := filepath.Join(base, AppName)
return Paths{
Dir: dir,
Presets: filepath.Join(dir, "presets.json"),
Config: filepath.Join(dir, "config.json"),
Log: filepath.Join(dir, AppName+".log"),
}, nil
}
// MaskStyle is how subnet masks are displayed in the editor. Input always
// accepts both spellings; this only controls what is rendered back.
type MaskStyle string
const (
MaskPrefix MaskStyle = "prefix" // /24
MaskDotted MaskStyle = "dotted" // 255.255.255.0
)
// Settings is config.json.
type Settings struct {
// MaskStyle is global rather than per-preset: a per-preset display
// preference would be more state than the feature is worth.
MaskStyle MaskStyle `json:"mask_style"`
// StartWithWindows drives the HKCU Run key. Assumed default off; see the
// open questions at the bottom of claude.md.
StartWithWindows bool `json:"start_with_windows"`
// CheckUpdates gates the startup call to the Gitea releases API.
CheckUpdates bool `json:"check_updates"`
// UpdateRepo is the Gitea repo polled for releases, as
// "https://host/owner/repo". Empty disables the update checker entirely,
// which is the default until the host is confirmed.
UpdateRepo string `json:"update_repo"`
// ActivePollSeconds is how often the live adapter state is re-read to work
// out which preset is currently applied.
ActivePollSeconds int `json:"active_poll_seconds"`
}
// Default is the settings a fresh install starts with.
func Default() Settings {
return Settings{
MaskStyle: MaskPrefix,
StartWithWindows: false,
CheckUpdates: true,
UpdateRepo: "",
ActivePollSeconds: 30,
}
}
// DottedMasks reports whether masks should render as dotted quads.
func (s Settings) DottedMasks() bool { return s.MaskStyle == MaskDotted }
// Load reads config.json, falling back to defaults for a missing file or any
// field left unset. Settings are conveniences, so a corrupt config should not
// stop the app from starting — the caller logs the error and carries on.
func Load(path string) (Settings, error) {
s := Default()
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return s, fmt.Errorf("reading %s: %w", path, err)
}
if err := json.Unmarshal(b, &s); err != nil {
return Default(), fmt.Errorf("parsing %s: %w", path, err)
}
// Repair anything nonsensical rather than refusing to run.
if s.MaskStyle != MaskPrefix && s.MaskStyle != MaskDotted {
s.MaskStyle = MaskPrefix
}
if s.ActivePollSeconds < 5 {
s.ActivePollSeconds = 30
}
return s, nil
}
// Save writes config.json.
func (s Settings) Save(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("encoding settings: %w", err)
}
if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}