diff --git a/.gitignore b/.gitignore index 3c10957..3561cef 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ frontend/package.json.md5 .idea .env environment.go +# Updater signing private key. The public half (updater.pub) is committed and +# embedded; the private key lives in the password manager + CI secrets only. +updater.key # REST (http files) http-client.private.env.json @@ -41,3 +44,5 @@ http-client.private.env.json *.tar *.tar.gz /AniTrack +# Updater CI staging (bare binary + sidecars, runner-ephemeral) +updater-dist/ diff --git a/updater.pub b/updater.pub new file mode 100644 index 0000000..198cd1f --- /dev/null +++ b/updater.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAWWF3cDxJKM668FqWsFsA9e7r7aXU2uDTUF+ZKKcxLP0= +-----END PUBLIC KEY----- diff --git a/updater/gitea/gitea.go b/updater/gitea/gitea.go new file mode 100644 index 0000000..be70789 --- /dev/null +++ b/updater/gitea/gitea.go @@ -0,0 +1,329 @@ +// Package gitea implements updater.Provider for Gitea releases. +// +// Releases live on a self-hosted Gitea instance, for which Wails ships no +// in-tree provider, so this mirrors the in-tree github provider against +// Gitea's releases API. Everything else (verification, staging, swap, +// restart, window) stays the framework's job. +// +// Asset layout per release (see .gitea/workflows/release.yml): +// +// AniTrack-linux-amd64 bare updater binary, exact name match +// AniTrack-linux-amd64.sha512 " " (sha512sum format) +// AniTrack-linux-amd64.sig base64 ed25519ph signature from +// `wails3 updater sign` +// +// A release missing any of the three is skipped: verification fails closed. +package gitea + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/updater" +) + +// Config configures the Gitea provider. +type Config struct { + // BaseURL is the instance root, e.g. https://git.linuxhg.com. + BaseURL string + // Owner and Repo identify the repository, e.g. john-okeefe/Anitrack. + Owner string + Repo string + // AssetName is the exact updater artifact name, e.g. AniTrack-linux-amd64. + AssetName string + // HTTPClient overrides the default client (30s timeout). Optional. + HTTPClient *http.Client + // AllowPrerelease includes pre-release-tagged releases. Default false: + // -rc tags stay invisible (stable channel). Beta/test flows opt in. + AllowPrerelease bool +} + +// Provider is an updater.Provider backed by Gitea releases. +type Provider struct { + cfg Config + client *http.Client +} + +// New validates config and returns a Provider. +func New(cfg Config) (*Provider, error) { + cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/") + if cfg.BaseURL == "" || cfg.Owner == "" || cfg.Repo == "" || cfg.AssetName == "" { + return nil, fmt.Errorf("gitea: BaseURL, Owner, Repo and AssetName are all required") + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return &Provider{cfg: cfg, client: client}, nil +} + +// Name identifies the provider in logs and event payloads. +func (p *Provider) Name() string { return "gitea" } + +// giteaRelease mirrors the fields we read from the Gitea releases API. +type giteaRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + PublishedAt string `json:"published_at"` + Assets []giteaAsset `json:"assets"` +} + +// giteaAsset mirrors one release attachment. +type giteaAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Check finds the newest usable release newer than req.CurrentVersion. +// (nil, nil) means up-to-date. +func (p *Provider) Check(ctx context.Context, req updater.CheckRequest) (*updater.Release, error) { + releases, err := p.listReleases(ctx) + if err != nil { + return nil, err + } + + var best *giteaRelease + var bestAsset *giteaAsset + for i := range releases { + r := &releases[i] + if r.Draft { + continue + } + if r.Prerelease && !p.cfg.AllowPrerelease { + continue + } + if compareSemver(r.TagName, req.CurrentVersion) <= 0 { + continue + } + asset := findAsset(r.Assets, p.cfg.AssetName) + if asset == nil { + continue + } + if best == nil || compareSemver(r.TagName, best.TagName) > 0 { + best = r + bestAsset = asset + } + } + if best == nil { + return nil, nil + } + + verification, err := p.fetchVerification(ctx, *bestAsset) + if err != nil { + return nil, err + } + + rel := &updater.Release{ + Version: strings.TrimPrefix(strings.TrimPrefix(best.TagName, "v"), "V"), + Channel: "stable", + Name: best.Name, + Notes: best.Body, + Artifact: updater.Artifact{ + Filename: bestAsset.Name, + Size: bestAsset.Size, + Platform: req.Platform, + Arch: req.Arch, + }, + Verification: verification, + Metadata: map[string]any{ + "downloadURL": bestAsset.BrowserDownloadURL, + }, + } + if rel.Channel == "stable" && (best.Prerelease) { + rel.Channel = "beta" + } + if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil { + rel.PublishedAt = t + } + return rel, nil +} + +// Download streams the release artifact to dst, reporting progress. +func (p *Provider) Download(ctx context.Context, r *updater.Release, dst io.Writer, onProgress func(written, total int64)) error { + url, _ := r.Metadata["downloadURL"].(string) + if url == "" { + return fmt.Errorf("gitea: release %s has no download URL", r.Version) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "AniTrack-updater") + resp, err := p.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gitea: download %s: %s", url, resp.Status) + } + total := r.Artifact.Size + if total <= 0 { + total = resp.ContentLength + } + var written int64 + buf := make([]byte, 128*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := dst.Write(buf[:n]); werr != nil { + return werr + } + written += int64(n) + onProgress(written, total) + } + if rerr == io.EOF { + onProgress(written, total) + return nil + } + if rerr != nil { + return rerr + } + } +} + +// listReleases fetches the releases index (newest first page is enough). +func (p *Provider) listReleases(ctx context.Context) ([]giteaRelease, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", p.cfg.BaseURL, p.cfg.Owner, p.cfg.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "AniTrack-updater") + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("gitea: list releases: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea: list releases: %s", resp.Status) + } + var releases []giteaRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("gitea: decode releases: %w", err) + } + return releases, nil +} + +// findAsset returns the attachment with exactly assetName, or nil. +func findAsset(assets []giteaAsset, assetName string) *giteaAsset { + for i := range assets { + if assets[i].Name == assetName && assets[i].BrowserDownloadURL != "" { + return &assets[i] + } + } + return nil +} + +// fetchVerification downloads the .sha512 + .sig sidecars next to the asset. +// This mirrors `wails3 updater sign` (SHA-512 digest, ed25519ph signature). +// Anything missing or malformed is an error: verification fails closed. +func (p *Provider) fetchVerification(ctx context.Context, asset giteaAsset) (*updater.Verification, error) { + base := strings.TrimSuffix(asset.BrowserDownloadURL, asset.Name) + digestHex, err := p.fetchText(ctx, base+asset.Name+".sha512") + if err != nil { + return nil, fmt.Errorf("gitea: digest sidecar: %w", err) + } + sigB64, err := p.fetchText(ctx, base+asset.Name+".sig") + if err != nil { + return nil, fmt.Errorf("gitea: signature sidecar: %w", err) + } + + // " " (sha512sum format); tolerate bare hex too. + digestHex = strings.Fields(digestHex)[0] + digest, err := hex.DecodeString(digestHex) + if err != nil || len(digest) != 64 { + return nil, fmt.Errorf("gitea: malformed digest sidecar for %s", asset.Name) + } + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64)) + if err != nil || len(sig) == 0 { + return nil, fmt.Errorf("gitea: malformed signature sidecar for %s", asset.Name) + } + return &updater.Verification{ + DigestAlgo: "sha512", + Digest: digest, + SignatureAlgo: "ed25519ph", + Signature: sig, + }, nil +} + +// fetchText GETs url and returns the trimmed body. +func (p *Provider) fetchText(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "AniTrack-updater") + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%s: %s", url, resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(body)), nil +} + +// compareSemver compares plain versions ("1.99.0", optional v prefix). +// Pre-release suffixes (-rc1) sort below the plain release. Returns +// -1, 0 or +1. Unparseable input compares as 0.0.0 (never newest). +func compareSemver(a, b string) int { + an, ap := splitSemver(a) + bn, bp := splitSemver(b) + for i := 0; i < 3; i++ { + if an[i] != bn[i] { + if an[i] < bn[i] { + return -1 + } + return 1 + } + } + switch { + case ap == bp: + return 0 + case ap == "": + return 1 + case bp == "": + return -1 + case ap < bp: + return -1 + case ap > bp: + return 1 + } + return 0 +} + +// splitSemver splits "v1.99.0-rc1" into ([1 99 0], "rc1"). +func splitSemver(v string) ([3]int, string) { + var nums [3]int + v = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(v), "v"), "V") + core, pre, _ := strings.Cut(v, "-") + for i, part := range strings.Split(core, ".") { + if i >= 3 { + break + } + n, err := strconv.Atoi(part) + if err != nil { + return [3]int{}, pre + } + nums[i] = n + } + return nums, pre +} diff --git a/updater/gitea/gitea_test.go b/updater/gitea/gitea_test.go new file mode 100644 index 0000000..a7a6109 --- /dev/null +++ b/updater/gitea/gitea_test.go @@ -0,0 +1,291 @@ +package gitea + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/pkg/updater" +) + +func TestCompareSemver(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.99.0", "1.6.8", 1}, + {"1.6.8", "1.99.0", -1}, + {"1.99.0", "1.99.0", 0}, + {"v1.99.0", "1.99.0", 0}, + {"2.0.0", "1.99.9", 1}, + {"1.99.0-rc1", "1.99.0", -1}, + {"1.99.0", "1.99.0-rc1", 1}, + {"1.99.0-rc1", "1.99.0-rc2", -1}, + {"1.10.0", "1.9.0", 1}, // numeric, not lexicographic + {"garbage", "1.0.0", -1}, + {"1.0.0", "garbage", 1}, + {"", "", 0}, + } + for _, c := range cases { + if got := compareSemver(c.a, c.b); got != c.want { + t.Errorf("compareSemver(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +const testAsset = "AniTrack-linux-amd64" +const testData = "artifact-bytes" + +type releaseSpec struct { + tag string + draft bool + prerelease bool + withAsset bool + withSidecar bool +} + +// buildServer serves a canned releases index. Every spec with withAsset gets +// the test artifact; sidecars (.sha512/.sig, correctly formed) are served +// only if at least one spec sets withSidecar. +func buildServer(t *testing.T, specs []releaseSpec) *httptest.Server { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + data := []byte(testData) + sum := sha512.Sum512(data) + sha := hex.EncodeToString(sum[:]) + " " + testAsset + "\n" + sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, sum[:])) + "\n" + serveSidecars := false + for _, s := range specs { + if s.withSidecar { + serveSidecars = true + } + } + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases"): + w.Header().Set("Content-Type", "application/json") + var b strings.Builder + b.WriteString("[") + for i, s := range specs { + assets := "" + if s.withAsset { + assets = fmt.Sprintf(`{"name":%q,"size":%d,"browser_download_url":%q}`, + testAsset, len(data), srv.URL+"/dl/"+testAsset) + } + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"tag_name":%q,"name":%q,"body":"notes","draft":%v,"prerelease":%v,"published_at":"2026-09-16T00:00:00Z","assets":[%s]}`, + s.tag, s.tag, s.draft, s.prerelease, assets) + } + b.WriteString("]") + fmt.Fprint(w, b.String()) + case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sha512": + fmt.Fprint(w, sha) + case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sig": + fmt.Fprint(w, sig) + case serveSidecars && r.URL.Path == "/dl/"+testAsset: + w.Write(data) + default: + http.NotFound(w, r) + } + })) + return srv +} + +func testProvider(srv *httptest.Server, allowPre bool) *Provider { + p, err := New(Config{ + BaseURL: srv.URL, + Owner: "o", + Repo: "r", + AssetName: testAsset, + AllowPrerelease: allowPre, + }) + if err != nil { + panic(err) + } + return p +} + +func TestCheckFindsNewest(t *testing.T) { + srv := buildServer(t, []releaseSpec{ + {tag: "1.6.8", withAsset: true, withSidecar: true}, + {tag: "1.99.0", withAsset: true, withSidecar: true}, + }) + defer srv.Close() + rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"}) + if err != nil { + t.Fatal(err) + } + if rel == nil { + t.Fatal("expected a release, got up-to-date") + } + if rel.Version != "1.99.0" { + t.Errorf("version = %q, want 1.99.0", rel.Version) + } + if rel.Verification == nil || rel.Verification.DigestAlgo != "sha512" || rel.Verification.SignatureAlgo != "ed25519ph" { + t.Errorf("verification not populated: %+v", rel.Verification) + } + if len(rel.Verification.Digest) != 64 || len(rel.Verification.Signature) == 0 { + t.Errorf("verification malformed: %+v", rel.Verification) + } + if rel.Notes != "notes" { + t.Errorf("notes = %q", rel.Notes) + } +} + +func TestCheckUpToDate(t *testing.T) { + srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}}) + defer srv.Close() + rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"}) + if err != nil { + t.Fatal(err) + } + if rel != nil { + t.Errorf("expected up-to-date, got %s", rel.Version) + } +} + +func TestCheckSkipsDraftPrereleaseAndMissingAsset(t *testing.T) { + srv := buildServer(t, []releaseSpec{ + {tag: "1.99.2", draft: true, withAsset: true, withSidecar: true}, + {tag: "1.99.1", prerelease: true, withAsset: true, withSidecar: true}, + {tag: "1.99.0", withAsset: false}, + {tag: "1.6.9", withAsset: true, withSidecar: true}, + }) + defer srv.Close() + rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"}) + if err != nil { + t.Fatal(err) + } + if rel == nil || rel.Version != "1.6.9" { + t.Fatalf("version = %v, want 1.6.9 (draft/prerelease/asset-less skipped)", rel) + } +} + +func TestCheckAllowPrerelease(t *testing.T) { + srv := buildServer(t, []releaseSpec{ + {tag: "1.99.1-rc1", prerelease: true, withAsset: true, withSidecar: true}, + }) + defer srv.Close() + rel, err := testProvider(srv, true).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"}) + if err != nil { + t.Fatal(err) + } + if rel == nil || rel.Version != "1.99.1-rc1" { + t.Fatalf("version = %v, want 1.99.1-rc1", rel) + } + if rel.Channel != "beta" { + t.Errorf("channel = %q, want beta", rel.Channel) + } +} + +func TestCheckFailsClosedWithoutSidecars(t *testing.T) { + srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: false}}) + defer srv.Close() + _, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"}) + if err == nil { + t.Fatal("expected error for missing sidecars, got nil") + } +} + +func TestCheckServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + _, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"}) + if err == nil { + t.Fatal("expected error for 500, got nil") + } +} + +func TestDownloadStreamsWithProgress(t *testing.T) { + srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}}) + defer srv.Close() + p := testProvider(srv, false) + rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"}) + if err != nil || rel == nil { + t.Fatalf("check: %v %v", rel, err) + } + f, err := os.CreateTemp("", "dl") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + var calls int + var last int64 + err = p.Download(context.Background(), rel, f, func(written, total int64) { + calls++ + last = written + if total != int64(len(testData)) { + t.Errorf("total = %d", total) + } + }) + f.Close() + if err != nil { + t.Fatal(err) + } + if calls == 0 || last != int64(len(testData)) { + t.Errorf("progress calls=%d last=%d", calls, last) + } + got, _ := os.ReadFile(f.Name()) + if string(got) != testData { + t.Errorf("body = %q", got) + } +} + +func TestNewValidatesConfig(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Error("expected error for empty config") + } +} + +// TestLiveGiteaCheck hits the real instance. Opt-in only: +// ANITRACK_LIVE_TEST=1 go test ./updater/gitea/ -run TestLiveGiteaCheck -v +func TestLiveGiteaCheck(t *testing.T) { + if os.Getenv("ANITRACK_LIVE_TEST") == "" { + t.Skip("set ANITRACK_LIVE_TEST=1 to run") + } + p, err := New(Config{ + BaseURL: "https://git.linuxhg.com", + Owner: "john-okeefe", + Repo: "Anitrack", + AssetName: "AniTrack-linux-amd64", + }) + if err != nil { + t.Fatal(err) + } + rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "0.0.0"}) + if err != nil { + t.Fatal(err) + } + if rel == nil { + // Correct until CI publishes the bare-binary asset: no release + // carries AniTrack-linux-amd64 yet, so there is nothing to offer. + // This still proves API reachability + response parsing live. + t.Log("no matching asset published yet (expected until CI ships AniTrack-linux-amd64)") + return + } + t.Logf("latest=%s notes=%d bytes", rel.Version, len(rel.Notes)) + var n int64 + err = p.Download(context.Background(), rel, io.Discard, func(w, _ int64) { n = w }) + if err != nil { + t.Fatal(err) + } + t.Logf("downloaded %d bytes", n) +}