blob: 3fe8a5d9333f54643d3d84588e43246ba38a00e5 [file] [log] [blame]
Josh Bleecher Snyder0af5fbe2025-07-14 19:40:48 +00001package update
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/x509"
7 _ "embed"
8 "encoding/json"
9 "encoding/pem"
10 "fmt"
11 "io"
12 "net/http"
13 "runtime"
14 "strings"
15 "sync"
16
17 "github.com/Masterminds/semver/v3"
18 "github.com/fynelabs/selfupdate"
19)
20
21//go:embed ed25519.pem
22var publicKeyPEM string
23
24// publicKey returns the parsed Ed25519 public key for signature verification
25var publicKey = sync.OnceValue(func() ed25519.PublicKey {
26 block, _ := pem.Decode([]byte(publicKeyPEM))
27 if block == nil {
28 panic("failed to decode PEM block containing public key")
29 }
30 pub, err := x509.ParsePKIXPublicKey(block.Bytes)
31 if err != nil {
32 panic(fmt.Sprintf("failed to parse public key: %v", err))
33 }
34 key, ok := pub.(ed25519.PublicKey)
35 if !ok {
36 panic(fmt.Sprintf("not an ed25519 public key: %T", pub))
37 }
38 return key
39})
40
41// Do updates sketch in-place.
42func Do(ctx context.Context, currentVersion, binaryPath string) error {
43 fmt.Printf("Current version: %s. Checking for updates...\n", currentVersion)
44
45 currentVer, err := semver.NewVersion(currentVersion)
46 if err != nil {
47 return fmt.Errorf("could not parse current version %q as semver: %w", currentVersion, err)
48 }
49
50 source := &ghSource{currentVer: currentVer}
51 if err := source.initialize(ctx); err != nil {
52 return err
53 }
54
55 if !source.latestVer.GreaterThan(currentVer) {
56 fmt.Printf("%s is up to date.\n", currentVersion)
57 return nil
58 }
59 fmt.Printf("Updating to %s...\n", source.latestVer)
60
61 if err := selfupdate.ManualUpdate(source, publicKey()); err != nil {
62 return fmt.Errorf("failed to perform update: %w", err)
63 }
64 fmt.Printf("Updated to %s.\n", source.latestVer)
65 return nil
66}
67
68// ghSource implements selfupdate.Source for sketch GitHub releases
69type ghSource struct {
70 currentVer *semver.Version
71 latestVer *semver.Version
72 asset ghAsset
73 signature [64]byte
74}
75
76// ghRelease represents a GitHub release
77type ghRelease struct {
78 TagName string `json:"tag_name"`
79 Assets []ghAsset `json:"assets"`
80}
81
82// ghAsset represents a release asset
83type ghAsset struct {
84 Name string `json:"name"`
85 BrowserDownloadURL string `json:"browser_download_url"`
86 Size int64 `json:"size"`
87}
88
89// initialize fetches the latest release information if not already done
90func (gs *ghSource) initialize(ctx context.Context) error {
91 url := "https://api.github.com/repos/boldsoftware/sketch/releases/latest"
92 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
93 if err != nil {
94 return fmt.Errorf("failed to create request: %w", err)
95 }
96 req.Header.Set("Accept", "application/vnd.github.v3+json")
97
98 resp, err := http.DefaultClient.Do(req)
99 if err != nil {
100 return fmt.Errorf("failed to fetch release info: %w", err)
101 }
102 defer resp.Body.Close()
103
104 if resp.StatusCode != http.StatusOK {
105 return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
106 }
107 var release ghRelease
108 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
109 return fmt.Errorf("failed to decode release info: %w", err)
110 }
111
112 gs.latestVer, err = semver.NewVersion(release.TagName)
113 if err != nil {
114 return fmt.Errorf("invalid latest version %q: %w", release.TagName, err)
115 }
116
117 // Find the appropriate asset for current platform
118 gs.asset, err = gs.assetForPlatform(release, runtime.GOOS, runtime.GOARCH)
119 if err != nil {
120 return fmt.Errorf("failed to find asset for current platform: %w", err)
121 }
122
123 // Download and parse the signature
124 if err := gs.downloadSignature(ctx); err != nil {
125 return fmt.Errorf("failed to download signature: %w", err)
126 }
127 return nil
128}
129
130// assetForPlatform finds the appropriate asset for the given platform
131func (gs *ghSource) assetForPlatform(release ghRelease, goos, goarch string) (ghAsset, error) {
132 for _, asset := range release.Assets {
133 if strings.HasSuffix(asset.Name, goos+"_"+goarch) {
134 return asset, nil
135 }
136 }
137 return ghAsset{}, fmt.Errorf("no asset found for platform %s/%s", goos, goarch)
138}
139
140// downloadSignature downloads and parses the signature file
141func (gs *ghSource) downloadSignature(ctx context.Context) error {
142 sigURL := gs.asset.BrowserDownloadURL + ".ed25519"
143 req, err := http.NewRequestWithContext(ctx, "GET", sigURL, nil)
144 if err != nil {
145 return fmt.Errorf("failed to create signature request: %w", err)
146 }
147 resp, err := http.DefaultClient.Do(req)
148 if err != nil {
149 return fmt.Errorf("failed to download signature: %w", err)
150 }
151 defer resp.Body.Close()
152
153 if resp.StatusCode != http.StatusOK {
154 return fmt.Errorf("unexpected status code for signature: %d", resp.StatusCode)
155 }
156 _, err = io.ReadFull(resp.Body, gs.signature[:])
157 if err != nil {
158 return fmt.Errorf("failed to read signature data: %w", err)
159 }
160 return nil
161}
162
163// Get downloads the binary for the specified version
164func (gs *ghSource) Get(v *selfupdate.Version) (io.ReadCloser, int64, error) {
165 req, err := http.NewRequest("GET", gs.asset.BrowserDownloadURL, nil)
166 if err != nil {
167 return nil, 0, fmt.Errorf("failed to create download request: %w", err)
168 }
169 resp, err := http.DefaultClient.Do(req)
170 if err != nil {
171 return nil, 0, fmt.Errorf("failed to download binary: %w", err)
172 }
173 if resp.StatusCode != http.StatusOK {
174 resp.Body.Close()
175 return nil, 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
176 }
177 return resp.Body, gs.asset.Size, nil
178}
179
180// GetSignature returns the signature for the binary
181func (gs *ghSource) GetSignature() ([64]byte, error) {
182 return gs.signature, nil
183}
184
185// LatestVersion returns the latest version available
186func (gs *ghSource) LatestVersion() (*selfupdate.Version, error) {
187 return &selfupdate.Version{Number: gs.latestVer.String()}, nil
188}