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>
This commit is contained in:
2026-08-20 13:37:38 +10:00
parent 23dcfb393f
commit d93ae63156
3 changed files with 112 additions and 6 deletions
+8 -4
View File
@@ -106,10 +106,14 @@ localised and parsing it breaks on a non-English Windows.
## Updates ## Updates
Set an update repository in Settings (`https://host/owner/repo`) and ipswap ipswap checks `https://gitea.apointless.space/bsncubed/ipswap` by default. The
checks its Gitea releases API at startup, in a goroutine, behind a short check runs at startup in a goroutine, behind a short timeout, and fails
timeout, failing silently to the log — a laptop on a customer site usually silently to the log — a laptop on a customer site usually cannot reach the
cannot reach the host, and that is not an error worth showing. 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. 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 Clicking it downloads the `.exe`, verifies its SHA256 against the release, and
+6 -2
View File
@@ -64,7 +64,7 @@ type Settings struct {
// UpdateRepo is the Gitea repo polled for releases, as // UpdateRepo is the Gitea repo polled for releases, as
// "https://host/owner/repo". Empty disables the update checker entirely, // "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"` UpdateRepo string `json:"update_repo"`
// ActivePollSeconds is how often the live adapter state is re-read to work // 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. // 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 { func Default() Settings {
return Settings{ return Settings{
MaskStyle: MaskPrefix, MaskStyle: MaskPrefix,
StartWithWindows: false, StartWithWindows: false,
CheckUpdates: true, CheckUpdates: true,
UpdateRepo: "", UpdateRepo: DefaultUpdateRepo,
ActivePollSeconds: 30, ActivePollSeconds: 30,
} }
} }
+98
View File
@@ -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")
}
}