A shepherd for your Appimages.
0

Configure Feed

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

upgrade: best-effort support for static urls

Aly Raffauf (Jun 17, 2026, 9:58 PM EDT) 012b3866 6fc145ea

+194 -11
+24 -11
cmd/appherder/source.go
··· 10 10 "hash" 11 11 "os" 12 12 "strings" 13 + "time" 13 14 ) 14 15 15 16 // release describes the newest available build a source knows about. 16 17 type release struct { 17 - version string // human label, e.g. a release tag 18 - url string // download URL for the AppImage 19 - sha256 string // hex sha256 of the asset, "" when unavailable 20 - sha1 string // hex sha1 (zsync's hash), "" when unavailable 21 - size int64 18 + version string // human label, e.g. a release tag 19 + url string // download URL for the AppImage 20 + sha256 string // hex sha256 of the asset, "" when unavailable 21 + sha1 string // hex sha1 (zsync's hash), "" when unavailable 22 + size int64 // content length, 0 when unavailable 23 + modTime time.Time // server Last-Modified, for checksumless sources 22 24 } 23 25 24 26 // checksum returns the strongest hash the release carries with a hasher to ··· 34 36 } 35 37 36 38 // localMatches reports whether file already equals this release, by the 37 - // strongest checksum available and falling back to size (a weak signal that 38 - // only distinguishes differently-sized builds). 39 + // strongest available signal: checksum, then mtime, then size. 39 40 func (r release) localMatches(file string) (bool, error) { 40 41 if want, h, ok := r.checksum(); ok { 41 42 sum, err := fileSum(file, h) ··· 44 45 } 45 46 return strings.EqualFold(hex.EncodeToString(sum), want), nil 46 47 } 48 + 49 + info, err := os.Stat(file) 50 + if err != nil { 51 + return false, err 52 + } 53 + // No checksum: current if the install is at least as new as the server's 54 + // copy, else fall back to size. 55 + if !r.modTime.IsZero() { 56 + return !info.ModTime().Before(r.modTime), nil 57 + } 47 58 if r.size > 0 { 48 - info, err := os.Stat(file) 49 - if err != nil { 50 - return false, err 51 - } 52 59 return info.Size() == r.size, nil 53 60 } 54 61 return false, nil ··· 142 149 return nil, fmt.Errorf("malformed zsync update info %q", info) 143 150 } 144 151 return zsyncURLSource{url: fields[1]}, nil 152 + case "static": 153 + // static|https://host/App-latest.AppImage (our convention) 154 + if len(fields) != 2 || fields[1] == "" { 155 + return nil, fmt.Errorf("malformed static update info %q", info) 156 + } 157 + return staticURLSource{url: fields[1]}, nil 145 158 default: 146 159 return nil, fmt.Errorf("unsupported update source %q", fields[0]) 147 160 }
+62
cmd/appherder/source_static.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + ) 9 + 10 + // staticURLSource tracks a fixed URL that always serves the latest AppImage. 11 + // There's no version or checksum, so freshness leans on the server's 12 + // Last-Modified (vs. the installed file's mtime), then Content-Length. 13 + type staticURLSource struct { 14 + url string 15 + } 16 + 17 + func (s staticURLSource) latest(ctx context.Context) (release, error) { 18 + ctx, cancel := context.WithTimeout(ctx, apiTimeout) 19 + defer cancel() 20 + 21 + resp, err := s.probe(ctx) 22 + if err != nil { 23 + return release{}, err 24 + } 25 + defer resp.Body.Close() 26 + 27 + rel := release{url: s.url, version: "latest"} 28 + if lm := resp.Header.Get("Last-Modified"); lm != "" { 29 + rel.version = lm 30 + if t, err := http.ParseTime(lm); err == nil { 31 + rel.modTime = t 32 + } 33 + } 34 + if n, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil { 35 + rel.size = n 36 + } 37 + return rel, nil 38 + } 39 + 40 + // probe reads the URL's headers via HEAD, falling back to GET for servers that 41 + // reject it. The caller closes the body; we never read it. 42 + func (s staticURLSource) probe(ctx context.Context) (*http.Response, error) { 43 + for _, method := range []string{http.MethodHead, http.MethodGet} { 44 + req, err := http.NewRequestWithContext(ctx, method, s.url, nil) 45 + if err != nil { 46 + return nil, err 47 + } 48 + resp, err := httpClient.Do(req) 49 + if err != nil { 50 + return nil, fmt.Errorf("probe %s: %w", s.url, err) 51 + } 52 + if resp.StatusCode == http.StatusOK { 53 + return resp, nil 54 + } 55 + resp.Body.Close() 56 + if method == http.MethodHead && (resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented) { 57 + continue 58 + } 59 + return nil, fmt.Errorf("probe %s: %s", s.url, resp.Status) 60 + } 61 + return nil, fmt.Errorf("probe %s: no usable response", s.url) 62 + }
+108
cmd/appherder/source_static_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "os" 8 + "path/filepath" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + const lastModified = "Mon, 02 Jun 2025 12:00:00 GMT" 14 + 15 + func TestParseUpdateInfoStatic(t *testing.T) { 16 + src, err := parseUpdateInfo("static|https://host/App-latest.AppImage") 17 + if err != nil { 18 + t.Fatal(err) 19 + } 20 + s, ok := src.(staticURLSource) 21 + if !ok { 22 + t.Fatalf("got %T, want staticURLSource", src) 23 + } 24 + if s.url != "https://host/App-latest.AppImage" { 25 + t.Fatalf("url = %q", s.url) 26 + } 27 + } 28 + 29 + func TestParseUpdateInfoRejectsMalformedStatic(t *testing.T) { 30 + if _, err := parseUpdateInfo("static|"); err == nil { 31 + t.Fatal("expected error for empty static URL") 32 + } 33 + } 34 + 35 + func TestStaticURLSourceLatest(t *testing.T) { 36 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 + if r.Method != http.MethodHead { 38 + t.Errorf("method = %s, want HEAD", r.Method) 39 + } 40 + w.Header().Set("Last-Modified", lastModified) 41 + w.Header().Set("Content-Length", "4096") 42 + w.WriteHeader(http.StatusOK) 43 + })) 44 + defer srv.Close() 45 + 46 + rel, err := staticURLSource{url: srv.URL}.latest(context.Background()) 47 + if err != nil { 48 + t.Fatal(err) 49 + } 50 + if rel.url != srv.URL { 51 + t.Fatalf("url = %q", rel.url) 52 + } 53 + if rel.size != 4096 { 54 + t.Fatalf("size = %d", rel.size) 55 + } 56 + if rel.version != lastModified { 57 + t.Fatalf("version = %q", rel.version) 58 + } 59 + if want, _ := http.ParseTime(lastModified); !rel.modTime.Equal(want) { 60 + t.Fatalf("modTime = %v, want %v", rel.modTime, want) 61 + } 62 + } 63 + 64 + func TestStaticURLSourceFallsBackToGet(t *testing.T) { 65 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 + if r.Method == http.MethodHead { 67 + http.Error(w, "no HEAD", http.StatusMethodNotAllowed) 68 + return 69 + } 70 + w.Header().Set("Content-Length", "10") 71 + w.WriteHeader(http.StatusOK) 72 + })) 73 + defer srv.Close() 74 + 75 + rel, err := staticURLSource{url: srv.URL}.latest(context.Background()) 76 + if err != nil { 77 + t.Fatal(err) 78 + } 79 + if rel.size != 10 { 80 + t.Fatalf("size = %d, want value from GET fallback", rel.size) 81 + } 82 + } 83 + 84 + func TestReleaseLocalMatchesModTime(t *testing.T) { 85 + dir := t.TempDir() 86 + file := filepath.Join(dir, "app.AppImage") 87 + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { 88 + t.Fatal(err) 89 + } 90 + server, _ := http.ParseTime(lastModified) 91 + chtime := func(mtime time.Time) { 92 + if err := os.Chtimes(file, mtime, mtime); err != nil { 93 + t.Fatal(err) 94 + } 95 + } 96 + 97 + // Installed after the server's copy: current. 98 + chtime(server.Add(time.Hour)) 99 + if ok, err := (release{modTime: server}).localMatches(file); err != nil || !ok { 100 + t.Fatalf("newer install: matches = %v, %v; want true", ok, err) 101 + } 102 + 103 + // Server published something newer: stale. 104 + chtime(server.Add(-time.Hour)) 105 + if ok, _ := (release{modTime: server}).localMatches(file); ok { 106 + t.Fatal("older install: matches = true, want false") 107 + } 108 + }