| Josh Bleecher Snyder | 0af5fbe | 2025-07-14 19:40:48 +0000 | [diff] [blame] | 1 | package update |
| 2 | |
| 3 | import ( |
| 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 |
| 22 | var publicKeyPEM string |
| 23 | |
| 24 | // publicKey returns the parsed Ed25519 public key for signature verification |
| 25 | var 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. |
| 42 | func 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 |
| 69 | type 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 |
| 77 | type ghRelease struct { |
| 78 | TagName string `json:"tag_name"` |
| 79 | Assets []ghAsset `json:"assets"` |
| 80 | } |
| 81 | |
| 82 | // ghAsset represents a release asset |
| 83 | type 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 |
| 90 | func (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 |
| 131 | func (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 |
| 141 | func (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 |
| 164 | func (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 |
| 181 | func (gs *ghSource) GetSignature() ([64]byte, error) { |
| 182 | return gs.signature, nil |
| 183 | } |
| 184 | |
| 185 | // LatestVersion returns the latest version available |
| 186 | func (gs *ghSource) LatestVersion() (*selfupdate.Version, error) { |
| 187 | return &selfupdate.Version{Number: gs.latestVer.String()}, nil |
| 188 | } |