···11-package main
11+package appherder
2233import (
44 "context"
+16
internal/appherder/app.go
···11+package appherder
22+33+import "os"
44+55+// App is the core engine for managing AppImages. It holds no CLI or I/O
66+// state; all output formatting is the caller's responsibility.
77+type App struct {
88+ homeDir func() (string, error)
99+}
1010+1111+// NewApp returns an App wired to the current user's home directory.
1212+func NewApp() App {
1313+ return App{
1414+ homeDir: os.UserHomeDir,
1515+ }
1616+}
+76
internal/appherder/list.go
···11+package appherder
22+33+import (
44+ "fmt"
55+ "os"
66+ "path/filepath"
77+ "sort"
88+)
99+1010+// AppInfo is one managed app's display metadata, as returned by List.
1111+type AppInfo struct {
1212+ AppID string
1313+ Name string // desktop Name= field, falls back to filename
1414+ Filename string // basename of the AppImage in ~/AppImages, "" when missing
1515+ Version string // desktop X-AppImage-Version=
1616+ Size int64
1717+ Source string // update source kind, "none" when no info
1818+}
1919+2020+// List returns display metadata for every app appherder manages. Offline and
2121+// instant; use CheckUpgrades to see what's stale.
2222+func (a App) List() ([]AppInfo, error) {
2323+ home, err := a.homeDir()
2424+ if err != nil {
2525+ return nil, fmt.Errorf("resolve home directory: %w", err)
2626+ }
2727+2828+ appids, err := managedApps(home)
2929+ if err != nil {
3030+ return nil, err
3131+ }
3232+3333+ appimagesDir := filepath.Join(home, "AppImages")
3434+ appsDir := filepath.Join(home, ".local", "share", "applications")
3535+3636+ infos := make([]AppInfo, 0, len(appids))
3737+ for _, appid := range appids {
3838+ infos = append(infos, gatherAppInfo(appsDir, appimagesDir, appid))
3939+ }
4040+ sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
4141+ return infos, nil
4242+}
4343+4444+// gatherAppInfo collects display metadata for appid from its installed desktop
4545+// file and AppImage.
4646+func gatherAppInfo(appsDir, appimagesDir, appid string) AppInfo {
4747+ info := AppInfo{AppID: appid, Source: "none"}
4848+4949+ if desktop, err := readDesktopFile(filepath.Join(appsDir, appid+".desktop")); err == nil {
5050+ if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" {
5151+ info.Name = name
5252+ }
5353+ if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok {
5454+ info.Version = version
5555+ }
5656+ }
5757+5858+ if path, err := findAppImagePath(appimagesDir, appid); err == nil && path != "" {
5959+ info.Filename = filepath.Base(path)
6060+ if stat, err := os.Stat(path); err == nil {
6161+ info.Size = stat.Size()
6262+ }
6363+ if src, err := SourceForAppImage(path); err == nil && src != nil {
6464+ info.Source = src.Kind()
6565+ }
6666+ }
6767+6868+ if info.Name == "" {
6969+ if info.Filename != "" {
7070+ info.Name = info.Filename
7171+ } else {
7272+ info.Name = appid
7373+ }
7474+ }
7575+ return info
7676+}
+159
internal/appherder/upgrade.go
···11+package appherder
22+33+import (
44+ "context"
55+ "fmt"
66+ "io"
77+ "net/http"
88+ "os"
99+ "path/filepath"
1010+ "strings"
1111+ "sync"
1212+)
1313+1414+// checkConcurrency caps concurrent update checks: enough to overlap network
1515+// latency without hammering the API.
1616+const checkConcurrency = 8
1717+1818+// UpgradeCheck is the per-app outcome of checking for an update.
1919+type UpgradeCheck struct {
2020+ Name string
2121+ Release Release
2222+ Available bool // a newer build is available
2323+ NoSource bool // no embedded update info; skip silently
2424+ Err error // the check itself failed
2525+}
2626+2727+// UpgradeApplied is the per-app outcome of downloading and installing an update.
2828+type UpgradeApplied struct {
2929+ Name string
3030+ Version string
3131+ Err error
3232+}
3333+3434+// CheckUpgrades checks every AppImage in ~/AppImages for an available update.
3535+// Results come back in sorted filename order. Apps with no update info or
3636+// already current are included with NoSource/Available=false so the caller
3737+// can decide what to show.
3838+func (a App) CheckUpgrades(ctx context.Context) ([]UpgradeCheck, error) {
3939+ home, err := a.homeDir()
4040+ if err != nil {
4141+ return nil, fmt.Errorf("resolve home directory: %w", err)
4242+ }
4343+ files, err := listAppImages(filepath.Join(home, "AppImages"))
4444+ if err != nil {
4545+ return nil, err
4646+ }
4747+ return parallelMap(ctx, files, checkConcurrency, func(ctx context.Context, file string) UpgradeCheck {
4848+ return checkOne(ctx, file)
4949+ }), nil
5050+}
5151+5252+// ApplyUpgrades downloads and installs updates for the given checks, processing
5353+// only those with Available=true. Apply is sequential (bandwidth-bound); per-app
5454+// errors are included in the result rather than aborting the run.
5555+func (a App) ApplyUpgrades(ctx context.Context, checks []UpgradeCheck) []UpgradeApplied {
5656+ var applied []UpgradeApplied
5757+ for _, check := range checks {
5858+ if check.Err != nil || check.NoSource || !check.Available {
5959+ continue
6060+ }
6161+ err := a.applyUpgrade(ctx, check.Release)
6262+ applied = append(applied, UpgradeApplied{
6363+ Name: check.Name,
6464+ Version: check.Release.Version,
6565+ Err: err,
6666+ })
6767+ }
6868+ return applied
6969+}
7070+7171+// checkOne resolves an AppImage's source and reports whether an update exists.
7272+func checkOne(ctx context.Context, file string) UpgradeCheck {
7373+ name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
7474+7575+ src, err := SourceForAppImage(file)
7676+ if err != nil {
7777+ return UpgradeCheck{Name: name, Err: err}
7878+ }
7979+ if src == nil {
8080+ return UpgradeCheck{Name: name, NoSource: true}
8181+ }
8282+8383+ rel, err := src.Latest(ctx)
8484+ if err != nil {
8585+ return UpgradeCheck{Name: name, Err: err}
8686+ }
8787+8888+ current, err := rel.localMatches(file)
8989+ if err != nil {
9090+ return UpgradeCheck{Name: name, Err: err}
9191+ }
9292+ return UpgradeCheck{Name: name, Release: rel, Available: !current}
9393+}
9494+9595+// parallelMap applies fn to each item with at most `limit` concurrent calls,
9696+// returning results in input order.
9797+func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R {
9898+ if limit < 1 {
9999+ limit = 1
100100+ }
101101+ results := make([]R, len(items))
102102+ sem := make(chan struct{}, limit)
103103+ var wg sync.WaitGroup
104104+ for i, item := range items {
105105+ wg.Add(1)
106106+ sem <- struct{}{}
107107+ go func(i int, item T) {
108108+ defer wg.Done()
109109+ defer func() { <-sem }()
110110+ results[i] = fn(ctx, item)
111111+ }(i, item)
112112+ }
113113+ wg.Wait()
114114+ return results
115115+}
116116+117117+func (a App) applyUpgrade(ctx context.Context, rel Release) error {
118118+ tmp, err := os.CreateTemp("", "appherder-upgrade-*.appimage")
119119+ if err != nil {
120120+ return fmt.Errorf("create temporary file: %w", err)
121121+ }
122122+ tmpName := tmp.Name()
123123+ defer os.Remove(tmpName)
124124+125125+ if err := download(ctx, rel.URL, tmp); err != nil {
126126+ tmp.Close()
127127+ return err
128128+ }
129129+ if err := tmp.Close(); err != nil {
130130+ return fmt.Errorf("close download: %w", err)
131131+ }
132132+133133+ if err := rel.verifyDownload(tmpName); err != nil {
134134+ return err
135135+ }
136136+137137+ _, err = a.Install(tmpName)
138138+ return err
139139+}
140140+141141+func download(ctx context.Context, url string, writer io.Writer) error {
142142+ ctx, cancel := context.WithCancel(ctx)
143143+ defer cancel()
144144+145145+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
146146+ if err != nil {
147147+ return err
148148+ }
149149+ resp, err := httpClient.Do(req)
150150+ if err != nil {
151151+ return fmt.Errorf("download %s: %w", url, err)
152152+ }
153153+ defer resp.Body.Close()
154154+ if resp.StatusCode != http.StatusOK {
155155+ return fmt.Errorf("download %s: %s", url, resp.Status)
156156+ }
157157+ _, err = io.Copy(writer, newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel))
158158+ return err
159159+}