Files
ipswap/internal/config/config.go
T
bsncubed d93ae63156 Default the update repo to the Gitea host
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>
2026-08-20 13:37:38 +10:00

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
}