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:
@@ -38,6 +38,9 @@ func main() {
|
|||||||
svc = NewApp(app)
|
svc = NewApp(app)
|
||||||
app.RegisterService(application.NewService(svc))
|
app.RegisterService(application.NewService(svc))
|
||||||
|
|
||||||
|
// Self-updates: desktop production builds only (no-op elsewhere).
|
||||||
|
maybeEnableUpdater(app)
|
||||||
|
|
||||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: appTitle(),
|
Title: appTitle(),
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
|
|||||||
+66
@@ -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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user