feat(updater): wire startup check for desktop production builds

maybeEnableUpdater inits app.Updater (CurrentVersion from wails.json, embedded public key, accent-themed builtin window) behind two gates: -tags production (wails3 build sets it, dev does not) and application.System.IsDesktop (mobile stays on Obtainium). The startup check is headless; the window opens via a second CheckAndInstall only when an update is found. ANITRACK_UPDATER_CHANNEL=beta opts into -rc pre-releases for testing. updater_flow_test.go drives the REAL framework verifier headless: good signature stages byte-identical, tampered signature rejected (both phases green). Works around the framework process-wide app singleton with a single two-phase test.
This commit is contained in:
John O'Keefe
2026-09-16 08:52:04 -04:00
parent e9b04c0a84
commit 52fc656669
5 changed files with 224 additions and 0 deletions
+140
View File
@@ -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)
}
}