package githubreleases import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "text/template" "time" "github.com/Eriyc/rules_wails/pkg/wails3kit/updates" ) const ( defaultAssetTemplate = "{{ .ProductID }}_{{ .Version }}_{{ .OS }}_{{ .Arch }}.zip" defaultChecksumAssetTemplate = "{{ .AssetName }}.sha256" ) type Config struct { Owner string Repo string HTTPClient *http.Client PrepareRequest func(*http.Request) error AssetNameTemplate string ChecksumAssetNameTemplate string } type Provider struct { config Config client *http.Client assetNameTemplate *template.Template checksumNameTemplate *template.Template baseURL string } func New(cfg Config) (*Provider, error) { if cfg.Owner == "" || cfg.Repo == "" { return nil, updates.ErrInvalidConfig } client := cfg.HTTPClient if client == nil { client = http.DefaultClient } assetTemplate := cfg.AssetNameTemplate if assetTemplate == "" { assetTemplate = defaultAssetTemplate } checksumTemplate := cfg.ChecksumAssetNameTemplate if checksumTemplate == "" { checksumTemplate = defaultChecksumAssetTemplate } assetNameTemplate, err := template.New("asset").Parse(assetTemplate) if err != nil { return nil, err } checksumNameTemplate, err := template.New("checksum").Parse(checksumTemplate) if err != nil { return nil, err } return &Provider{ config: cfg, client: client, assetNameTemplate: assetNameTemplate, checksumNameTemplate: checksumNameTemplate, baseURL: "https://api.github.com", }, nil } type githubRelease struct { TagName string `json:"tag_name"` Body string `json:"body"` Prerelease bool `json:"prerelease"` PublishedAt time.Time `json:"published_at"` Assets []githubAsset `json:"assets"` } type githubAsset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } func (provider *Provider) Resolve(ctx context.Context, req updates.ResolveRequest) (*updates.Release, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.releaseURL(), nil) if err != nil { return nil, err } if provider.config.PrepareRequest != nil { if err := provider.config.PrepareRequest(request); err != nil { return nil, err } } response, err := provider.client.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("unexpected github status: %s", response.Status) } var releases []githubRelease if err := json.NewDecoder(response.Body).Decode(&releases); err != nil { return nil, err } var best *updates.Release for _, release := range releases { version := strings.TrimPrefix(release.TagName, "v") channel := mapChannel(release) if channel != req.App.Channel { continue } comparison, err := updates.CompareVersions(version, req.App.CurrentVersion) if err != nil || comparison <= 0 { continue } assetName, err := provider.renderAssetName(req.App, version, "") if err != nil { return nil, err } checksumName, err := provider.renderChecksumName(req.App, version, assetName) if err != nil { return nil, err } artifactURL := "" checksumURL := "" for _, asset := range release.Assets { if asset.Name == assetName { artifactURL = asset.BrowserDownloadURL } if asset.Name == checksumName { checksumURL = asset.BrowserDownloadURL } } if artifactURL == "" || checksumURL == "" { continue } sha256Value, err := provider.readChecksum(ctx, checksumURL) if err != nil { return nil, err } candidate := &updates.Release{ ID: version, Version: version, Channel: channel, NotesMarkdown: release.Body, PublishedAt: release.PublishedAt, Artifact: updates.Artifact{ Kind: updates.ArtifactKindBundleArchive, Format: updates.ArtifactFormatZip, URL: artifactURL, SHA256: sha256Value, }, } if best == nil { best = candidate continue } better, err := updates.CompareVersions(candidate.Version, best.Version) if err != nil { return nil, err } if better > 0 { best = candidate } } return best, nil } func (provider *Provider) OpenArtifact(ctx context.Context, release updates.Release) (io.ReadCloser, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.Artifact.URL, nil) if err != nil { return nil, err } if provider.config.PrepareRequest != nil { if err := provider.config.PrepareRequest(request); err != nil { return nil, err } } response, err := provider.client.Do(request) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { defer response.Body.Close() return nil, fmt.Errorf("unexpected artifact status: %s", response.Status) } return response.Body, nil } func (provider *Provider) readChecksum(ctx context.Context, url string) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } if provider.config.PrepareRequest != nil { if err := provider.config.PrepareRequest(request); err != nil { return "", err } } response, err := provider.client.Do(request) if err != nil { return "", err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return "", fmt.Errorf("unexpected checksum status: %s", response.Status) } bytes, err := io.ReadAll(response.Body) if err != nil { return "", err } return strings.Fields(string(bytes))[0], nil } func (provider *Provider) releaseURL() string { return fmt.Sprintf("%s/repos/%s/%s/releases", provider.baseURL, provider.config.Owner, provider.config.Repo) } func (provider *Provider) renderAssetName(app updates.AppDescriptor, version string, assetName string) (string, error) { return provider.executeTemplate(provider.assetNameTemplate, app, version, assetName) } func (provider *Provider) renderChecksumName(app updates.AppDescriptor, version string, assetName string) (string, error) { return provider.executeTemplate(provider.checksumNameTemplate, app, version, assetName) } func (provider *Provider) executeTemplate(templateValue *template.Template, app updates.AppDescriptor, version string, assetName string) (string, error) { var builder strings.Builder err := templateValue.Execute(&builder, map[string]string{ "ProductID": app.ProductID, "Version": version, "OS": app.OS, "Arch": app.Arch, "AssetName": assetName, }) return builder.String(), err } func mapChannel(release githubRelease) updates.Channel { if !release.Prerelease { return updates.ChannelStable } if strings.Contains(strings.ToLower(release.TagName), "alpha") { return updates.ChannelAlpha } return updates.ChannelBeta }