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,130 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user