d93ae63156
Settles the first open question in claude.md: the update endpoint is gitea.apointless.space/bsncubed/ipswap. It was left empty — which disables the checker — only because the host was unconfirmed. Load unmarshals over the defaults, so an absent update_repo takes the new default while an explicitly empty one stays empty. That distinction is the off switch, and forks depend on it, so it is now pinned by a test. Adds the config package's first tests while here: the defaulting rules above, the repair path for a nonsensical mask style or poll interval, and the guarantee that a corrupt config.json still yields usable defaults rather than stopping the app from starting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.1 KiB
Go
135 lines
4.1 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 what a fork or a private build should set it to.
|
|
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.
|
|
// DefaultUpdateRepo is where release builds look for updates. Set it to "" in
|
|
// config.json (or clear the field in Settings) to turn the check off entirely.
|
|
const DefaultUpdateRepo = "https://gitea.apointless.space/bsncubed/ipswap"
|
|
|
|
func Default() Settings {
|
|
return Settings{
|
|
MaskStyle: MaskPrefix,
|
|
StartWithWindows: false,
|
|
CheckUpdates: true,
|
|
UpdateRepo: DefaultUpdateRepo,
|
|
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
|
|
}
|