// 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 }