A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

add initial support for upgrades

Aly Raffauf (Jun 17, 2026, 9:24 PM EDT) 4b96317c 3993bd1e

+589 -1
+15 -1
cmd/appherder/cli.go
··· 15 15 } 16 16 cmd.SetOut(stdout) 17 17 cmd.SetErr(stderr) 18 - cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a)) 18 + cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a)) 19 + return cmd 20 + } 21 + 22 + func newUpgradeCommand(a app) *cobra.Command { 23 + var check bool 24 + cmd := &cobra.Command{ 25 + Use: "upgrade", 26 + Short: "Download and install updates for your AppImages", 27 + Args: cobra.NoArgs, 28 + RunE: func(cmd *cobra.Command, args []string) error { 29 + return a.upgrade(cmd.Context(), cmd.OutOrStdout(), check) 30 + }, 31 + } 32 + cmd.Flags().BoolVar(&check, "check", false, "Report available updates without installing them") 19 33 return cmd 20 34 } 21 35
+55
cmd/appherder/http.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "net" 7 + "net/http" 8 + "time" 9 + ) 10 + 11 + // httpClient is shared by update checks and downloads. It bounds connect, TLS, 12 + // and header time but sets no overall Timeout, so large downloads aren't capped. 13 + var httpClient = &http.Client{ 14 + Transport: &http.Transport{ 15 + Proxy: http.ProxyFromEnvironment, 16 + DialContext: (&net.Dialer{ 17 + Timeout: 30 * time.Second, 18 + KeepAlive: 30 * time.Second, 19 + }).DialContext, 20 + ForceAttemptHTTP2: true, 21 + MaxIdleConns: 100, 22 + IdleConnTimeout: 90 * time.Second, 23 + TLSHandshakeTimeout: 10 * time.Second, 24 + ResponseHeaderTimeout: 30 * time.Second, 25 + ExpectContinueTimeout: time.Second, 26 + }, 27 + } 28 + 29 + const ( 30 + // apiTimeout bounds a single small API request end to end. 31 + apiTimeout = 30 * time.Second 32 + // downloadIdleTimeout aborts a stalled download without capping total time. 33 + downloadIdleTimeout = 60 * time.Second 34 + ) 35 + 36 + // idleTimeoutReader cancels via cancel() when a single Read stalls longer than 37 + // timeout, guarding a download against a connection that goes quiet. 38 + type idleTimeoutReader struct { 39 + r io.Reader 40 + timer *time.Timer 41 + timeout time.Duration 42 + } 43 + 44 + func newIdleTimeoutReader(r io.Reader, timeout time.Duration, cancel context.CancelFunc) *idleTimeoutReader { 45 + timer := time.AfterFunc(timeout, cancel) 46 + timer.Stop() 47 + return &idleTimeoutReader{r: r, timer: timer, timeout: timeout} 48 + } 49 + 50 + func (t *idleTimeoutReader) Read(p []byte) (int, error) { 51 + t.timer.Reset(t.timeout) 52 + n, err := t.r.Read(p) 53 + t.timer.Stop() 54 + return n, err 55 + }
+75
cmd/appherder/source.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "debug/elf" 6 + "fmt" 7 + "strings" 8 + ) 9 + 10 + // release describes the newest available build a source knows about. 11 + type release struct { 12 + version string // human label, e.g. a release tag 13 + url string // download URL for the AppImage 14 + sha256 string // hex sha256 of the asset, "" when the source can't provide it 15 + size int64 16 + } 17 + 18 + // source resolves the latest available build of an installed app. 19 + type source interface { 20 + latest(ctx context.Context) (release, error) 21 + } 22 + 23 + // readUpdateInfo returns the AppImage's embedded update-information string from 24 + // its .upd_info ELF section, or "" when absent or empty. 25 + func readUpdateInfo(file string) (string, error) { 26 + f, err := elf.Open(file) 27 + if err != nil { 28 + return "", fmt.Errorf("open AppImage %s: %w", file, err) 29 + } 30 + defer f.Close() 31 + 32 + section := f.Section(".upd_info") 33 + if section == nil { 34 + return "", nil 35 + } 36 + data, err := section.Data() 37 + if err != nil { 38 + return "", fmt.Errorf("read .upd_info from %s: %w", file, err) 39 + } 40 + return strings.TrimRight(string(data), "\x00"), nil 41 + } 42 + 43 + // sourceForAppImage resolves an update source from the AppImage's embedded 44 + // update info. It returns (nil, nil) when the AppImage carries none. 45 + func sourceForAppImage(file string) (source, error) { 46 + info, err := readUpdateInfo(file) 47 + if err != nil { 48 + return nil, err 49 + } 50 + if info == "" { 51 + return nil, nil 52 + } 53 + return parseUpdateInfo(info) 54 + } 55 + 56 + // parseUpdateInfo turns an AppImage update-info string (the "type|a|b|..." form) 57 + // into a concrete source. 58 + func parseUpdateInfo(info string) (source, error) { 59 + fields := strings.Split(info, "|") 60 + switch fields[0] { 61 + case "gh-releases-zsync", "gh-releases-direct": 62 + // gh-releases-zsync|owner|repo|tag|pattern.zsync 63 + if len(fields) != 5 { 64 + return nil, fmt.Errorf("malformed GitHub update info %q", info) 65 + } 66 + return githubReleaseSource{ 67 + owner: fields[1], 68 + repo: fields[2], 69 + tag: fields[3], 70 + pattern: strings.TrimSuffix(fields[4], ".zsync"), 71 + }, nil 72 + default: 73 + return nil, fmt.Errorf("unsupported update source %q", fields[0]) 74 + } 75 + }
+106
cmd/appherder/source_github.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "path" 10 + "sort" 11 + "strings" 12 + ) 13 + 14 + type githubReleaseSource struct { 15 + owner, repo, tag, pattern string 16 + api string // base API URL; "" means api.github.com (overridable for tests) 17 + } 18 + 19 + type ghAsset struct { 20 + Name string `json:"name"` 21 + URL string `json:"browser_download_url"` 22 + Size int64 `json:"size"` 23 + Digest string `json:"digest"` // e.g. "sha256:abc…", may be empty 24 + } 25 + 26 + type ghRelease struct { 27 + TagName string `json:"tag_name"` 28 + Assets []ghAsset `json:"assets"` 29 + } 30 + 31 + func (s githubReleaseSource) latest(ctx context.Context) (release, error) { 32 + base := s.api 33 + if base == "" { 34 + base = "https://api.github.com" 35 + } 36 + endpoint := fmt.Sprintf("%s/repos/%s/%s/releases/", base, s.owner, s.repo) 37 + if s.tag == "" || s.tag == "latest" { 38 + endpoint += "latest" 39 + } else { 40 + endpoint += "tags/" + s.tag 41 + } 42 + 43 + ctx, cancel := context.WithTimeout(ctx, apiTimeout) 44 + defer cancel() 45 + 46 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 47 + if err != nil { 48 + return release{}, err 49 + } 50 + req.Header.Set("Accept", "application/vnd.github+json") 51 + if tok := githubToken(); tok != "" { 52 + req.Header.Set("Authorization", "Bearer "+tok) 53 + } 54 + 55 + resp, err := httpClient.Do(req) 56 + if err != nil { 57 + return release{}, fmt.Errorf("query GitHub releases for %s/%s: %w", s.owner, s.repo, err) 58 + } 59 + defer resp.Body.Close() 60 + if resp.StatusCode != http.StatusOK { 61 + return release{}, fmt.Errorf("query GitHub releases for %s/%s: %s", s.owner, s.repo, resp.Status) 62 + } 63 + 64 + var rel ghRelease 65 + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 66 + return release{}, fmt.Errorf("decode GitHub release for %s/%s: %w", s.owner, s.repo, err) 67 + } 68 + 69 + asset, err := matchAsset(rel.Assets, s.pattern) 70 + if err != nil { 71 + return release{}, err 72 + } 73 + return release{ 74 + version: rel.TagName, 75 + url: asset.URL, 76 + sha256: strings.TrimPrefix(asset.Digest, "sha256:"), 77 + size: asset.Size, 78 + }, nil 79 + } 80 + 81 + // matchAsset picks the release asset whose name matches pattern (a glob). On 82 + // multiple matches it takes the first by name, for determinism. 83 + func matchAsset(assets []ghAsset, pattern string) (ghAsset, error) { 84 + var matches []ghAsset 85 + for _, a := range assets { 86 + if ok, _ := path.Match(pattern, a.Name); ok { 87 + matches = append(matches, a) 88 + } 89 + } 90 + if len(matches) == 0 { 91 + return ghAsset{}, fmt.Errorf("no GitHub release asset matches %q", pattern) 92 + } 93 + sort.Slice(matches, func(i, j int) bool { return matches[i].Name < matches[j].Name }) 94 + return matches[0], nil 95 + } 96 + 97 + // githubToken returns a personal access token from the environment, used to 98 + // raise the API rate limit. It is never sent to asset download URLs. 99 + func githubToken() string { 100 + for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN"} { 101 + if v := os.Getenv(key); v != "" { 102 + return v 103 + } 104 + } 105 + return "" 106 + }
+71
cmd/appherder/source_github_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + ) 9 + 10 + func TestGitHubReleaseSourceLatest(t *testing.T) { 11 + const body = `{ 12 + "tag_name": "v1.2.3", 13 + "assets": [ 14 + {"name": "MyApp-1.2.3-x86_64.AppImage", "browser_download_url": "https://example.com/MyApp.AppImage", "size": 1234, "digest": "sha256:deadbeef"}, 15 + {"name": "MyApp-1.2.3-x86_64.AppImage.zsync", "browser_download_url": "https://example.com/MyApp.AppImage.zsync", "size": 99}, 16 + {"name": "source.tar.gz", "browser_download_url": "https://example.com/src.tgz", "size": 5} 17 + ] 18 + }` 19 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 + if r.URL.Path != "/repos/myorg/myapp/releases/latest" { 21 + t.Errorf("path = %s, want /repos/myorg/myapp/releases/latest", r.URL.Path) 22 + } 23 + w.Write([]byte(body)) 24 + })) 25 + defer srv.Close() 26 + 27 + src := githubReleaseSource{owner: "myorg", repo: "myapp", tag: "latest", pattern: "MyApp-*-x86_64.AppImage", api: srv.URL} 28 + rel, err := src.latest(context.Background()) 29 + if err != nil { 30 + t.Fatal(err) 31 + } 32 + if rel.version != "v1.2.3" { 33 + t.Fatalf("version = %q", rel.version) 34 + } 35 + if rel.url != "https://example.com/MyApp.AppImage" { 36 + t.Fatalf("url = %q", rel.url) 37 + } 38 + if rel.sha256 != "deadbeef" { 39 + t.Fatalf("sha256 = %q, want digest with sha256: prefix stripped", rel.sha256) 40 + } 41 + if rel.size != 1234 { 42 + t.Fatalf("size = %d", rel.size) 43 + } 44 + } 45 + 46 + func TestGitHubReleaseSourceUsesTagEndpoint(t *testing.T) { 47 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + if r.URL.Path != "/repos/o/r/releases/tags/v9" { 49 + t.Errorf("path = %s, want /repos/o/r/releases/tags/v9", r.URL.Path) 50 + } 51 + w.Write([]byte(`{"tag_name":"v9","assets":[{"name":"a.AppImage","browser_download_url":"u","size":1,"digest":"sha256:ab"}]}`)) 52 + })) 53 + defer srv.Close() 54 + 55 + src := githubReleaseSource{owner: "o", repo: "r", tag: "v9", pattern: "*.AppImage", api: srv.URL} 56 + if _, err := src.latest(context.Background()); err != nil { 57 + t.Fatal(err) 58 + } 59 + } 60 + 61 + func TestGitHubReleaseSourceErrorsOnNotFound(t *testing.T) { 62 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 + http.Error(w, "not found", http.StatusNotFound) 64 + })) 65 + defer srv.Close() 66 + 67 + src := githubReleaseSource{owner: "o", repo: "r", tag: "latest", pattern: "*.AppImage", api: srv.URL} 68 + if _, err := src.latest(context.Background()); err == nil { 69 + t.Fatal("expected error on 404") 70 + } 71 + }
+53
cmd/appherder/source_test.go
··· 1 + package main 2 + 3 + import "testing" 4 + 5 + func TestParseUpdateInfoGitHubStripsZsyncSuffix(t *testing.T) { 6 + src, err := parseUpdateInfo("gh-releases-zsync|myorg|myapp|latest|MyApp-*-x86_64.AppImage.zsync") 7 + if err != nil { 8 + t.Fatal(err) 9 + } 10 + gh, ok := src.(githubReleaseSource) 11 + if !ok { 12 + t.Fatalf("got %T, want githubReleaseSource", src) 13 + } 14 + if gh.owner != "myorg" || gh.repo != "myapp" || gh.tag != "latest" { 15 + t.Fatalf("parsed = %+v", gh) 16 + } 17 + if gh.pattern != "MyApp-*-x86_64.AppImage" { 18 + t.Fatalf("pattern = %q, want .zsync stripped", gh.pattern) 19 + } 20 + } 21 + 22 + func TestParseUpdateInfoRejectsUnsupported(t *testing.T) { 23 + if _, err := parseUpdateInfo("bintray-zsync|a|b|c|d"); err == nil { 24 + t.Fatal("expected error for unsupported source type") 25 + } 26 + } 27 + 28 + func TestParseUpdateInfoRejectsMalformedGitHub(t *testing.T) { 29 + if _, err := parseUpdateInfo("gh-releases-zsync|too|few"); err == nil { 30 + t.Fatal("expected error for malformed gh-releases info") 31 + } 32 + } 33 + 34 + func TestMatchAssetIgnoresZsyncAndOthers(t *testing.T) { 35 + assets := []ghAsset{ 36 + {Name: "MyApp-1.2.3-x86_64.AppImage.zsync"}, 37 + {Name: "source.tar.gz"}, 38 + {Name: "MyApp-1.2.3-x86_64.AppImage"}, 39 + } 40 + got, err := matchAsset(assets, "MyApp-*-x86_64.AppImage") 41 + if err != nil { 42 + t.Fatal(err) 43 + } 44 + if got.Name != "MyApp-1.2.3-x86_64.AppImage" { 45 + t.Fatalf("matched %q", got.Name) 46 + } 47 + } 48 + 49 + func TestMatchAssetNoMatch(t *testing.T) { 50 + if _, err := matchAsset([]ghAsset{{Name: "other.AppImage"}}, "MyApp-*.AppImage"); err == nil { 51 + t.Fatal("expected error when nothing matches") 52 + } 53 + }
+189
cmd/appherder/upgrade.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/hex" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + "sync" 13 + ) 14 + 15 + // checkConcurrency caps concurrent update checks: enough to overlap network 16 + // latency without hammering the API. 17 + const checkConcurrency = 8 18 + 19 + // upgradeCheck is the per-app outcome of the (concurrent) check phase. 20 + type upgradeCheck struct { 21 + name string 22 + release release 23 + available bool // a newer build is available 24 + noSource bool // no embedded update info; skip silently 25 + err error // the check itself failed 26 + } 27 + 28 + // upgrade installs available updates for AppImages in ~/AppImages; checkOnly 29 + // reports them without downloading. Apps with no update info or already current 30 + // are skipped silently, and per-app errors don't abort the run. 31 + func (a app) upgrade(ctx context.Context, out io.Writer, checkOnly bool) error { 32 + home, err := a.homeDir() 33 + if err != nil { 34 + return fmt.Errorf("resolve home directory: %w", err) 35 + } 36 + files, err := listAppImages(filepath.Join(home, "AppImages")) 37 + if err != nil { 38 + return err 39 + } 40 + 41 + // Check every app concurrently; results come back in input order. 42 + checks := parallelMap(ctx, files, checkConcurrency, func(ctx context.Context, file string) upgradeCheck { 43 + return checkOne(ctx, file) 44 + }) 45 + 46 + // Apply sequentially: bandwidth/disk-bound, and keeps output ordered. 47 + available := 0 48 + for _, c := range checks { 49 + switch { 50 + case c.err != nil: 51 + fmt.Fprintf(out, "skip %s: %v\n", c.name, c.err) 52 + continue 53 + case c.noSource || !c.available: 54 + continue 55 + } 56 + 57 + available++ 58 + if checkOnly { 59 + fmt.Fprintf(out, "%s: update available (%s)\n", c.name, c.release.version) 60 + continue 61 + } 62 + 63 + if err := a.applyUpgrade(ctx, c.release); err != nil { 64 + fmt.Fprintf(out, "skip %s: %v\n", c.name, err) 65 + continue 66 + } 67 + fmt.Fprintf(out, "upgraded %s to %s\n", c.name, c.release.version) 68 + } 69 + 70 + if available == 0 { 71 + fmt.Fprintln(out, "everything is up to date") 72 + } 73 + return nil 74 + } 75 + 76 + // checkOne resolves an AppImage's source and reports whether an update exists. 77 + func checkOne(ctx context.Context, file string) upgradeCheck { 78 + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) 79 + 80 + src, err := sourceForAppImage(file) 81 + if err != nil { 82 + return upgradeCheck{name: name, err: err} 83 + } 84 + if src == nil { 85 + return upgradeCheck{name: name, noSource: true} 86 + } 87 + 88 + rel, err := src.latest(ctx) 89 + if err != nil { 90 + return upgradeCheck{name: name, err: err} 91 + } 92 + 93 + current, err := upToDate(file, rel) 94 + if err != nil { 95 + return upgradeCheck{name: name, err: err} 96 + } 97 + return upgradeCheck{name: name, release: rel, available: !current} 98 + } 99 + 100 + // parallelMap applies fn to each item with at most `limit` concurrent calls, 101 + // returning results in input order. 102 + func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { 103 + if limit < 1 { 104 + limit = 1 105 + } 106 + results := make([]R, len(items)) 107 + sem := make(chan struct{}, limit) 108 + var wg sync.WaitGroup 109 + for i, item := range items { 110 + wg.Add(1) 111 + sem <- struct{}{} 112 + go func(i int, item T) { 113 + defer wg.Done() 114 + defer func() { <-sem }() 115 + results[i] = fn(ctx, item) 116 + }(i, item) 117 + } 118 + wg.Wait() 119 + return results 120 + } 121 + 122 + // upToDate reports whether the installed file already matches the latest 123 + // release, preferring an exact sha256 check and falling back to size. 124 + func upToDate(file string, rel release) (bool, error) { 125 + if rel.sha256 != "" { 126 + sum, err := fileHash(file) 127 + if err != nil { 128 + return false, err 129 + } 130 + return strings.EqualFold(hex.EncodeToString(sum), rel.sha256), nil 131 + } 132 + if rel.size > 0 { 133 + info, err := os.Stat(file) 134 + if err != nil { 135 + return false, err 136 + } 137 + return info.Size() == rel.size, nil 138 + } 139 + return false, nil 140 + } 141 + 142 + func (a app) applyUpgrade(ctx context.Context, rel release) error { 143 + tmp, err := os.CreateTemp("", "appherder-upgrade-*.appimage") 144 + if err != nil { 145 + return fmt.Errorf("create temporary file: %w", err) 146 + } 147 + tmpName := tmp.Name() 148 + defer os.Remove(tmpName) 149 + 150 + if err := download(ctx, rel.url, tmp); err != nil { 151 + tmp.Close() 152 + return err 153 + } 154 + if err := tmp.Close(); err != nil { 155 + return fmt.Errorf("close download: %w", err) 156 + } 157 + 158 + if rel.sha256 != "" { 159 + sum, err := fileHash(tmpName) 160 + if err != nil { 161 + return err 162 + } 163 + if !strings.EqualFold(hex.EncodeToString(sum), rel.sha256) { 164 + return fmt.Errorf("downloaded AppImage failed sha256 verification") 165 + } 166 + } 167 + 168 + return a.install(tmpName) 169 + } 170 + 171 + func download(ctx context.Context, url string, w io.Writer) error { 172 + ctx, cancel := context.WithCancel(ctx) 173 + defer cancel() 174 + 175 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 176 + if err != nil { 177 + return err 178 + } 179 + resp, err := httpClient.Do(req) 180 + if err != nil { 181 + return fmt.Errorf("download %s: %w", url, err) 182 + } 183 + defer resp.Body.Close() 184 + if resp.StatusCode != http.StatusOK { 185 + return fmt.Errorf("download %s: %s", url, resp.Status) 186 + } 187 + _, err = io.Copy(w, newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) 188 + return err 189 + }
+25
cmd/appherder/upgrade_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "reflect" 6 + "testing" 7 + ) 8 + 9 + func TestParallelMapPreservesInputOrder(t *testing.T) { 10 + items := []int{1, 2, 3, 4, 5, 6, 7} 11 + got := parallelMap(context.Background(), items, 3, func(_ context.Context, n int) int { 12 + return n * n 13 + }) 14 + want := []int{1, 4, 9, 16, 25, 36, 49} 15 + if !reflect.DeepEqual(got, want) { 16 + t.Fatalf("got %v, want %v", got, want) 17 + } 18 + } 19 + 20 + func TestParallelMapEmpty(t *testing.T) { 21 + got := parallelMap(context.Background(), []int{}, 4, func(_ context.Context, n int) int { return n }) 22 + if len(got) != 0 { 23 + t.Fatalf("got %v, want empty", got) 24 + } 25 + }