diff --git a/main.go b/main.go index 1ee36b0..d44f0fc 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,9 @@ func main() { svc = NewApp(app) app.RegisterService(application.NewService(svc)) + // Self-updates: desktop production builds only (no-op elsewhere). + maybeEnableUpdater(app) + app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: appTitle(), Width: 1024, diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..c11c11b --- /dev/null +++ b/updater.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + _ "embed" + "log" + "os" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + + giteaprovider "AniTrack/updater/gitea" +) + +//go:embed updater.pub +var updaterPublicKey []byte + +// maybeEnableUpdater wires self-updates: desktop production builds only. +// Mobile stays on Obtainium; dev builds never phone home. The startup check +// is headless — the builtin window opens only when an update is found, via a +// second CheckAndInstall (one redundant index round-trip, negligible). +func maybeEnableUpdater(app *application.App) { + if !updaterAutoCheck { + return + } + if !application.System.IsDesktop() { + return + } + gh, err := giteaprovider.New(giteaprovider.Config{ + BaseURL: "https://git.linuxhg.com", + Owner: "john-okeefe", + Repo: "Anitrack", + AssetName: "AniTrack-linux-amd64", + // Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta. + AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta", + }) + if err != nil { + log.Printf("updater: %s", err) + return + } + if err := app.Updater.Init(updater.Config{ + CurrentVersion: appVersion(), + Providers: []updater.Provider{gh}, + PublicKey: updaterPublicKey, + Window: &updater.BuiltinWindow{ + CSS: ":root { --accent: #4d9fff; }", + }, + }); err != nil { + log.Printf("updater: init: %s", err) + return + } + go func() { + rel, err := app.Updater.Check(context.Background()) + if err != nil { + log.Printf("updater: check: %s", err) + return + } + if rel == nil { + return + } + log.Printf("updater: %s available, opening installer", rel.Version) + if err := app.Updater.CheckAndInstall(context.Background()); err != nil { + log.Printf("updater: install: %s", err) + } + }() +} diff --git a/updater_dev.go b/updater_dev.go new file mode 100644 index 0000000..67b192f --- /dev/null +++ b/updater_dev.go @@ -0,0 +1,7 @@ +//go:build !production + +package main + +// updaterAutoCheck is false outside production builds (dev, vet, plain +// go build): the updater is compiled in but never runs. +const updaterAutoCheck = false diff --git a/updater_flow_test.go b/updater_flow_test.go new file mode 100644 index 0000000..52de866 --- /dev/null +++ b/updater_flow_test.go @@ -0,0 +1,140 @@ +// End-to-end updater flow test (headless): Gitea fixture provider -> +// framework Check -> DownloadAndInstall -> REAL signature verification +// against a test key. Proves our sidecar shape is exactly what the +// framework verifier accepts. No network, no display, no Run(). +package main + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + + giteaprovider "AniTrack/updater/gitea" +) + +const flowAsset = "AniTrack-linux-amd64" +const flowData = "flow-test-binary-bytes" + +// flowServer serves one signed release for version 1.99.0. When *tampered +// is true the .sig sidecar is corrupted so verification must fail. The flag +// is read per-request so one server covers both phases (the framework keeps +// a process-wide app singleton, so only one application.New per test binary). +func flowServer(t *testing.T, pub ed25519.PublicKey, priv ed25519.PrivateKey, tampered *bool) *httptest.Server { + t.Helper() + data := []byte(flowData) + sum := sha512.Sum512(data) + sigBytes, err := priv.Sign(rand.Reader, sum[:], &ed25519.Options{Hash: crypto.SHA512}) + if err != nil { + t.Fatal(err) + } + sha := hex.EncodeToString(sum[:]) + " " + flowAsset + "\n" + mkSig := func() string { + s := sigBytes + if *tampered { + s = append([]byte(nil), sigBytes...) + s[0] ^= 0xff + } + return base64.StdEncoding.EncodeToString(s) + "\n" + } + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases"): + fmt.Fprintf(w, `[{"tag_name":"1.99.0","name":"1.99.0","body":"n","draft":false,"prerelease":false,"published_at":"2026-09-16T00:00:00Z","assets":[{"name":%q,"size":%d,"browser_download_url":%q}]}]`, + flowAsset, len(data), srv.URL+"/dl/"+flowAsset) + case r.URL.Path == "/dl/"+flowAsset+".sha512": + fmt.Fprint(w, sha) + case r.URL.Path == "/dl/"+flowAsset+".sig": + fmt.Fprint(w, mkSig()) + case r.URL.Path == "/dl/"+flowAsset: + w.Write(data) + default: + http.NotFound(w, r) + } + })) + _ = pub + return srv +} + +func TestUpdaterFlowDownloadsAndVerifies(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _ = priv + tampered := false + srv := flowServer(t, pub, priv, &tampered) + defer srv.Close() + + gh, err := giteaprovider.New(giteaprovider.Config{ + BaseURL: srv.URL, + Owner: "o", + Repo: "r", + AssetName: flowAsset, + }) + if err != nil { + t.Fatal(err) + } + + app := application.New(application.Options{Name: "AniTrackFlowTest"}) + if err := app.Updater.Init(updater.Config{ + CurrentVersion: "1.6.8", + Providers: []updater.Provider{gh}, + PublicKey: []byte(pub), + Window: updater.WindowNone, + }); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Phase 1: good signature -> staged bytes match the fixture. + rel, err := app.Updater.Check(ctx) + if err != nil { + t.Fatal(err) + } + if rel == nil { + t.Fatal("expected an update from 1.6.8 to 1.99.0") + } + if err := app.Updater.DownloadAndInstall(ctx); err != nil { + t.Fatalf("DownloadAndInstall (incl. real signature verify): %s", err) + } + staged := app.Updater.DownloadedPath() + if staged == "" { + t.Fatal("no staged path after install") + } + got, err := os.ReadFile(staged) + if err != nil { + t.Fatal(err) + } + if string(got) != flowData { + t.Errorf("staged bytes = %q, want fixture", got) + } + os.Remove(staged) + + // Phase 2: tampered signature -> install must fail closed. + tampered = true + if _, err := app.Updater.Check(ctx); err != nil { + t.Fatal(err) + } + if err := app.Updater.DownloadAndInstall(ctx); err == nil { + t.Fatal("expected verification failure for tampered signature, got nil") + } else { + t.Logf("rejected as expected: %s", err) + } +} diff --git a/updater_prod.go b/updater_prod.go new file mode 100644 index 0000000..c4bea1e --- /dev/null +++ b/updater_prod.go @@ -0,0 +1,8 @@ +//go:build production + +package main + +// updaterAutoCheck gates the startup update check to production builds: +// `wails3 build` sets -tags production, `wails3 dev` does not, so dev +// binaries in build/bin never phone home. +const updaterAutoCheck = true