From d93ae63156c3a43f4666fac6ff9f87124853451c Mon Sep 17 00:00:00 2001 From: bsncubed Date: Thu, 20 Aug 2026 13:37:38 +1000 Subject: [PATCH] Default the update repo to the Gitea host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 12 +++-- internal/config/config.go | 8 ++- internal/config/config_test.go | 98 ++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index b2bb020..998aed1 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,14 @@ localised and parsing it breaks on a non-English Windows. ## Updates -Set an update repository in Settings (`https://host/owner/repo`) and ipswap -checks its Gitea releases API at startup, in a goroutine, behind a short -timeout, failing silently to the log — a laptop on a customer site usually -cannot reach the host, and that is not an error worth showing. +ipswap checks `https://gitea.apointless.space/bsncubed/ipswap` by default. The +check runs at startup in a goroutine, behind a short timeout, and fails +silently to the log — a laptop on a customer site usually cannot reach the +host, and that is not an error worth showing. + +Point it elsewhere, or clear it to switch the check off entirely, in Settings +(`update_repo` in `config.json`). Forks should change it: otherwise they will +offer their users an upstream binary. A newer tag adds "Update available — vX.Y.Z" to the top of the tray menu. Clicking it downloads the `.exe`, verifies its SHA256 against the release, and diff --git a/internal/config/config.go b/internal/config/config.go index 426fd62..1de0a9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,7 +64,7 @@ type Settings struct { // 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. + // 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 @@ -73,12 +73,16 @@ type Settings struct { } // 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: "", + UpdateRepo: DefaultUpdateRepo, ActivePollSeconds: 30, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..59ed17f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingFileReturnsDefaults(t *testing.T) { + s, err := Load(filepath.Join(t.TempDir(), "config.json")) + if err != nil { + t.Fatalf("a missing config must not be an error: %v", err) + } + if s != Default() { + t.Errorf("expected defaults, got %+v", s) + } + if s.UpdateRepo != DefaultUpdateRepo { + t.Errorf("update repo default = %q, want %q", s.UpdateRepo, DefaultUpdateRepo) + } +} + +// An absent field takes the default, but an explicitly empty one must stay +// empty — that is how the update check is turned off. +func TestExplicitEmptyUpdateRepoDisablesTheCheck(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"update_repo": ""}`), 0o644); err != nil { + t.Fatal(err) + } + + s, err := Load(path) + if err != nil { + t.Fatal(err) + } + if s.UpdateRepo != "" { + t.Errorf("an explicit empty update_repo must be preserved, got %q", s.UpdateRepo) + } + // The rest of the file was absent, so it should still be defaulted. + if s.ActivePollSeconds != Default().ActivePollSeconds { + t.Errorf("unset fields should keep their defaults, got %+v", s) + } +} + +func TestLoadRepairsNonsense(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"mask_style":"wat","active_poll_seconds":0}`), 0o644); err != nil { + t.Fatal(err) + } + + s, err := Load(path) + if err != nil { + t.Fatal(err) + } + if s.MaskStyle != MaskPrefix { + t.Errorf("an unknown mask style should fall back to prefix, got %q", s.MaskStyle) + } + if s.ActivePollSeconds != 30 { + t.Errorf("a zero poll interval should be repaired, got %d", s.ActivePollSeconds) + } +} + +// A corrupt config must not stop the app from starting. +func TestLoadCorruptFileReturnsDefaultsAndError(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{not json`), 0o644); err != nil { + t.Fatal(err) + } + + s, err := Load(path) + if err == nil { + t.Error("expected an error for a corrupt config") + } + if s != Default() { + t.Errorf("a corrupt config should still yield usable defaults, got %+v", s) + } +} + +func TestSaveRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + + want := Default() + want.MaskStyle = MaskDotted + want.StartWithWindows = true + want.UpdateRepo = "https://example.com/o/r" + if err := want.Save(path); err != nil { + t.Fatal(err) + } + + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("round trip changed the settings:\n got %+v\nwant %+v", got, want) + } + if !got.DottedMasks() { + t.Error("DottedMasks should be true for MaskDotted") + } +}