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>
131 lines
3.9 KiB
Go
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
|
|
}
|