// 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 }