A shepherd for your Appimages.
0

Configure Feed

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

split frontend from backend via structured data

Aly Raffauf (Jun 17, 2026, 11:11 PM EDT) c2581160 63e9c7bc

+704 -565
-13
cmd/appherder/app.go
··· 1 - package main 2 - 3 - import "os" 4 - 5 - type app struct { 6 - homeDir func() (string, error) 7 - } 8 - 9 - func newApp() app { 10 - return app{ 11 - homeDir: os.UserHomeDir, 12 - } 13 - }
+2 -2
cmd/appherder/appimage.go internal/appherder/appimage.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "encoding/binary" ··· 96 96 } 97 97 } 98 98 99 - func (a app) installAppImage(file string, appName string) (string, error) { 99 + func (a App) installAppImage(file string, appName string) (string, error) { 100 100 home, err := a.homeDir() 101 101 if err != nil { 102 102 return "", fmt.Errorf("resolve home directory: %w", err)
+6 -6
cmd/appherder/appimage_test.go internal/appherder/appimage_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "bytes" ··· 63 63 if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { 64 64 t.Fatal(err) 65 65 } 66 - a := app{homeDir: func() (string, error) { return home, nil }} 66 + a := App{homeDir: func() (string, error) { return home, nil }} 67 67 68 68 dest, err := a.installAppImage(src, "foo") 69 69 if err != nil { ··· 88 88 if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { 89 89 t.Fatal(err) 90 90 } 91 - a := app{homeDir: func() (string, error) { return home, nil }} 91 + a := App{homeDir: func() (string, error) { return home, nil }} 92 92 93 93 dest, err := a.installAppImage(src, "foo") 94 94 if err != nil { ··· 119 119 if err != nil { 120 120 t.Fatal(err) 121 121 } 122 - a := app{homeDir: func() (string, error) { return home, nil }} 122 + a := App{homeDir: func() (string, error) { return home, nil }} 123 123 if _, err := a.installAppImage(src, "foo"); err != nil { 124 124 t.Fatal(err) 125 125 } ··· 155 155 if err != nil { 156 156 t.Fatal(err) 157 157 } 158 - a := app{homeDir: func() (string, error) { return home, nil }} 158 + a := App{homeDir: func() (string, error) { return home, nil }} 159 159 if _, err := a.installAppImage(dup, "foo"); err != nil { 160 160 t.Fatal(err) 161 161 } ··· 182 182 if err := os.WriteFile(dest, []byte("payload"), 0o644); err != nil { 183 183 t.Fatal(err) 184 184 } 185 - a := app{homeDir: func() (string, error) { return home, nil }} 185 + a := App{homeDir: func() (string, error) { return home, nil }} 186 186 187 187 got, err := a.installAppImage(dest, "foo") 188 188 if err != nil {
+49 -15
cmd/appherder/cli.go
··· 4 4 "fmt" 5 5 "io" 6 6 7 + "github.com/alyraffauf/appherder/internal/appherder" 7 8 "github.com/spf13/cobra" 8 9 ) 9 10 10 - func newRootCommand(a app, stdout io.Writer, stderr io.Writer) *cobra.Command { 11 + func newRootCommand(a appherder.App, stdout io.Writer, stderr io.Writer) *cobra.Command { 11 12 cmd := &cobra.Command{ 12 13 Use: "appherder", 13 14 Short: "A herder for your AppImages", ··· 19 20 } 20 21 cmd.SetOut(stdout) 21 22 cmd.SetErr(stderr) 22 - cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newListCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a)) 23 + cmd.AddCommand( 24 + newInstallCommand(a), 25 + newUninstallCommand(a), 26 + newListCommand(a), 27 + newSyncCommand(a), 28 + newMigrateCommand(a), 29 + newUpgradeCommand(a), 30 + ) 23 31 return cmd 24 32 } 25 33 26 - func newInstallCommand(a app) *cobra.Command { 34 + func newInstallCommand(a appherder.App) *cobra.Command { 27 35 return &cobra.Command{ 28 36 Use: "install APPIMAGE", 29 37 Short: "Install an AppImage", 30 38 Long: "Copies an AppImage into ~/AppImages and creates a launcher for it with the app's\nreal name and icon. You can delete the original download afterward.", 31 39 Args: cobra.ExactArgs(1), 32 40 RunE: func(cmd *cobra.Command, args []string) error { 33 - name, err := a.install(args[0]) 41 + name, err := a.Install(args[0]) 34 42 if err != nil { 35 43 return err 36 44 } ··· 40 48 } 41 49 } 42 50 43 - func newUninstallCommand(a app) *cobra.Command { 51 + func newUninstallCommand(a appherder.App) *cobra.Command { 44 52 var force bool 45 53 cmd := &cobra.Command{ 46 54 Use: "uninstall APP|APPIMAGE", ··· 48 56 Long: "Removes an AppImage and its launcher and icon. Give the name as it appears in\n~/AppImages (without .appimage), or the full path.", 49 57 Args: cobra.ExactArgs(1), 50 58 RunE: func(cmd *cobra.Command, args []string) error { 51 - if err := a.uninstall(args[0], force); err != nil { 59 + if err := a.Uninstall(args[0], force); err != nil { 52 60 return err 53 61 } 54 - fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", normalizeAppName(args[0])) 62 + fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", appherder.NormalizeAppName(args[0])) 55 63 return nil 56 64 }, 57 65 } ··· 60 68 return cmd 61 69 } 62 70 63 - func newListCommand(a app) *cobra.Command { 71 + func newListCommand(a appherder.App) *cobra.Command { 64 72 return &cobra.Command{ 65 73 Use: "list", 66 74 Short: "Show managed AppImages", 67 75 Long: "Shows every app appherder is managing: its display name, version, file size,\nand where it checks for updates.", 68 76 Args: cobra.NoArgs, 69 77 RunE: func(cmd *cobra.Command, args []string) error { 70 - return a.list(cmd.OutOrStdout()) 78 + infos, err := a.List() 79 + if err != nil { 80 + return err 81 + } 82 + printAppList(cmd.OutOrStdout(), infos) 83 + return nil 71 84 }, 72 85 } 73 86 } 74 87 75 - func newSyncCommand(a app) *cobra.Command { 88 + func newSyncCommand(a appherder.App) *cobra.Command { 76 89 var force bool 77 90 cmd := &cobra.Command{ 78 91 Use: "sync", ··· 82 95 "~/AppImages.", 83 96 Args: cobra.NoArgs, 84 97 RunE: func(cmd *cobra.Command, args []string) error { 85 - return a.sync(cmd.Context(), cmd.OutOrStdout(), force) 98 + result, err := a.Sync(cmd.Context(), force) 99 + if err != nil { 100 + return err 101 + } 102 + printSyncResult(cmd.OutOrStdout(), result) 103 + return nil 86 104 }, 87 105 } 88 106 cmd.Flags().BoolVarP(&force, "force", "f", false, ··· 90 108 return cmd 91 109 } 92 110 93 - func newMigrateCommand(a app) *cobra.Command { 111 + func newMigrateCommand(a appherder.App) *cobra.Command { 94 112 return &cobra.Command{ 95 113 Use: "migrate", 96 114 Short: "Adopt apps from another tool", ··· 98 116 "whose AppImage is missing. Safe to run anytime.", 99 117 Args: cobra.NoArgs, 100 118 RunE: func(cmd *cobra.Command, args []string) error { 101 - return a.sync(cmd.Context(), cmd.OutOrStdout(), true) 119 + result, err := a.Sync(cmd.Context(), true) 120 + if err != nil { 121 + return err 122 + } 123 + printSyncResult(cmd.OutOrStdout(), result) 124 + return nil 102 125 }, 103 126 } 104 127 } 105 128 106 - func newUpgradeCommand(a app) *cobra.Command { 129 + func newUpgradeCommand(a appherder.App) *cobra.Command { 107 130 var check bool 108 131 cmd := &cobra.Command{ 109 132 Use: "upgrade", ··· 113 136 "Apps with no update info or already current are skipped silently.", 114 137 Args: cobra.NoArgs, 115 138 RunE: func(cmd *cobra.Command, args []string) error { 116 - return a.upgrade(cmd.Context(), cmd.OutOrStdout(), check) 139 + out := cmd.OutOrStdout() 140 + checks, err := a.CheckUpgrades(cmd.Context()) 141 + if err != nil { 142 + return err 143 + } 144 + if check { 145 + printUpgradeChecks(out, checks) 146 + return nil 147 + } 148 + applied := a.ApplyUpgrades(cmd.Context(), checks) 149 + printUpgradeApplied(out, checks, applied) 150 + return nil 117 151 }, 118 152 } 119 153 cmd.Flags().BoolVar(&check, "check", false, "Report available updates without installing them")
+3 -3
cmd/appherder/desktop.go internal/appherder/desktop.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "errors" ··· 239 239 return writeIfChanged(path, 0o644, []byte(text)) 240 240 } 241 241 242 - func (a app) patchDesktopFile(desktop *desktopFile, appName string, hasIcon bool) error { 242 + func (a App) patchDesktopFile(desktop *desktopFile, appName string, hasIcon bool) error { 243 243 home, err := a.homeDir() 244 244 if err != nil { 245 245 return fmt.Errorf("resolve home directory: %w", err) ··· 270 270 return nil 271 271 } 272 272 273 - func (a app) installDesktopFile(desktop *desktopFile, appName string) (string, error) { 273 + func (a App) installDesktopFile(desktop *desktopFile, appName string) (string, error) { 274 274 home, err := a.homeDir() 275 275 if err != nil { 276 276 return "", fmt.Errorf("resolve home directory: %w", err)
+2 -2
cmd/appherder/desktop_test.go internal/appherder/desktop_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "os" ··· 56 56 t.Fatal(err) 57 57 } 58 58 59 - a := app{homeDir: func() (string, error) { return "/home/test", nil }} 59 + a := App{homeDir: func() (string, error) { return "/home/test", nil }} 60 60 61 61 desktop, err := readDesktopFile(source) 62 62 if err != nil {
+1 -1
cmd/appherder/exec.go internal/appherder/exec.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "strings"
+1 -1
cmd/appherder/exec_test.go internal/appherder/exec_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "reflect"
+1 -1
cmd/appherder/files.go internal/appherder/files.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "bytes"
+1 -1
cmd/appherder/files_test.go internal/appherder/files_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "os"
+94
cmd/appherder/format.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "path/filepath" 7 + "text/tabwriter" 8 + 9 + "github.com/alyraffauf/appherder/internal/appherder" 10 + ) 11 + 12 + func printAppList(out io.Writer, infos []appherder.AppInfo) { 13 + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) 14 + fmt.Fprintln(tw, "NAME\tFILENAME\tVERSION\tSIZE\tSOURCE") 15 + for _, info := range infos { 16 + size := "-" 17 + if info.Size > 0 { 18 + size = humanSize(info.Size) 19 + } 20 + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", 21 + info.Name, orDash(info.Filename), orDash(info.Version), size, orDash(info.Source)) 22 + } 23 + tw.Flush() 24 + } 25 + 26 + func printSyncResult(out io.Writer, result appherder.SyncResult) { 27 + for _, inst := range result.Installs { 28 + if inst.Err != nil { 29 + fmt.Fprintf(out, "skipped %s: %v\n", filepath.Base(inst.File), inst.Err) 30 + continue 31 + } 32 + if inst.New { 33 + fmt.Fprintf(out, "installed %s\n", inst.AppName) 34 + } 35 + } 36 + for _, rem := range result.Removals { 37 + if rem.Err != nil { 38 + fmt.Fprintf(out, "skipped removing %s: %v\n", rem.AppName, rem.Err) 39 + continue 40 + } 41 + fmt.Fprintf(out, "removed %s\n", rem.AppName) 42 + } 43 + } 44 + 45 + func printUpgradeChecks(out io.Writer, checks []appherder.UpgradeCheck) { 46 + available := 0 47 + for _, check := range checks { 48 + if check.Err != nil { 49 + fmt.Fprintf(out, "skipped %s: %v\n", check.Name, check.Err) 50 + continue 51 + } 52 + if check.NoSource || !check.Available { 53 + continue 54 + } 55 + available++ 56 + fmt.Fprintf(out, "%s: update available (%s)\n", check.Name, check.Release.Version) 57 + } 58 + if available == 0 { 59 + fmt.Fprintln(out, "everything is up to date") 60 + } 61 + } 62 + 63 + func printUpgradeApplied(out io.Writer, checks []appherder.UpgradeCheck, applied []appherder.UpgradeApplied) { 64 + for _, app := range applied { 65 + if app.Err != nil { 66 + fmt.Fprintf(out, "skipped %s: %v\n", app.Name, app.Err) 67 + continue 68 + } 69 + fmt.Fprintf(out, "upgraded %s to %s\n", app.Name, app.Version) 70 + } 71 + if len(applied) == 0 { 72 + fmt.Fprintln(out, "everything is up to date") 73 + } 74 + } 75 + 76 + func orDash(s string) string { 77 + if s == "" { 78 + return "-" 79 + } 80 + return s 81 + } 82 + 83 + func humanSize(bytes int64) string { 84 + const unit = 1024 85 + if bytes < unit { 86 + return fmt.Sprintf("%d B", bytes) 87 + } 88 + div, exp := int64(unit), 0 89 + for scaled := bytes / unit; scaled >= unit; scaled /= unit { 90 + div *= unit 91 + exp++ 92 + } 93 + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 94 + }
+1 -1
cmd/appherder/http.go internal/appherder/http.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context"
+2 -2
cmd/appherder/icons.go internal/appherder/icons.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "fmt" ··· 85 85 } 86 86 } 87 87 88 - func (a app) installIcon(fsys fs.FS, icon string, appName string) (string, error) { 88 + func (a App) installIcon(fsys fs.FS, icon string, appName string) (string, error) { 89 89 home, err := a.homeDir() 90 90 if err != nil { 91 91 return "", fmt.Errorf("resolve home directory: %w", err)
+1 -1
cmd/appherder/icons_test.go internal/appherder/icons_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "testing"
+2 -2
cmd/appherder/install.go internal/appherder/install.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "fmt" ··· 7 7 "strings" 8 8 ) 9 9 10 - func (a app) install(appimage string) (appName string, err error) { 10 + func (a App) Install(appimage string) (appName string, err error) { 11 11 appimage, err = filepath.Abs(appimage) 12 12 if err != nil { 13 13 return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err)
-112
cmd/appherder/list.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "io" 6 - "os" 7 - "path/filepath" 8 - "sort" 9 - "text/tabwriter" 10 - ) 11 - 12 - // appInfo is one row in the `list` output. 13 - type appInfo struct { 14 - appid string 15 - name string // desktop Name= field, falls back to filename 16 - filename string // basename of the AppImage in ~/AppImages 17 - version string // desktop X-AppImage-Version= 18 - size int64 19 - source string 20 - } 21 - 22 - // list prints appherder's managed apps: display name, version, file size, and 23 - // update source. Offline and instant; use `upgrade --check` to see what's 24 - // stale. A missing AppImage shows as empty filename and size. 25 - func (a app) list(out io.Writer) error { 26 - home, err := a.homeDir() 27 - if err != nil { 28 - return fmt.Errorf("resolve home directory: %w", err) 29 - } 30 - 31 - appids, err := managedApps(home) 32 - if err != nil { 33 - return err 34 - } 35 - 36 - appimagesDir := filepath.Join(home, "AppImages") 37 - appsDir := filepath.Join(home, ".local", "share", "applications") 38 - 39 - infos := make([]appInfo, 0, len(appids)) 40 - for _, appid := range appids { 41 - infos = append(infos, gatherAppInfo(appsDir, appimagesDir, appid)) 42 - } 43 - sort.Slice(infos, func(i, j int) bool { return infos[i].name < infos[j].name }) 44 - 45 - tabWriter := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) 46 - fmt.Fprintln(tabWriter, "NAME\tFILENAME\tVERSION\tSIZE\tSOURCE") 47 - for _, info := range infos { 48 - size := "-" 49 - if info.size > 0 { 50 - size = humanSize(info.size) 51 - } 52 - fmt.Fprintf(tabWriter, "%s\t%s\t%s\t%s\t%s\n", 53 - info.name, orDash(info.filename), orDash(info.version), size, orDash(info.source)) 54 - } 55 - return tabWriter.Flush() 56 - } 57 - 58 - // orDash returns s, or "-" when empty, for table cells. 59 - func orDash(s string) string { 60 - if s == "" { 61 - return "-" 62 - } 63 - return s 64 - } 65 - 66 - // gatherAppInfo collects display metadata for appid from its installed desktop 67 - // file and AppImage. 68 - func gatherAppInfo(appsDir, appimagesDir, appid string) appInfo { 69 - info := appInfo{appid: appid, source: "none"} 70 - 71 - if desktop, err := readDesktopFile(filepath.Join(appsDir, appid+".desktop")); err == nil { 72 - if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" { 73 - info.name = name 74 - } 75 - if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok { 76 - info.version = version 77 - } 78 - } 79 - 80 - if path, err := findAppImagePath(appimagesDir, appid); err == nil && path != "" { 81 - info.filename = filepath.Base(path) 82 - if stat, err := os.Stat(path); err == nil { 83 - info.size = stat.Size() 84 - } 85 - if src, err := sourceForAppImage(path); err == nil && src != nil { 86 - info.source = src.kind() 87 - } 88 - } 89 - 90 - if info.name == "" { 91 - if info.filename != "" { 92 - info.name = info.filename 93 - } else { 94 - info.name = appid 95 - } 96 - } 97 - return info 98 - } 99 - 100 - // humanSize formats bytes in binary units with one decimal place (e.g. "354 MB"). 101 - func humanSize(bytes int64) string { 102 - const unit = 1024 103 - if bytes < unit { 104 - return fmt.Sprintf("%d B", bytes) 105 - } 106 - div, exp := int64(unit), 0 107 - for scaled := bytes / unit; scaled >= unit; scaled /= unit { 108 - div *= unit 109 - exp++ 110 - } 111 - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 112 - }
+36 -23
cmd/appherder/list_test.go internal/appherder/list_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 - "bytes" 5 4 "os" 6 5 "path/filepath" 7 - "strings" 8 6 "testing" 9 7 ) 10 8 ··· 40 38 t.Fatal(err) 41 39 } 42 40 43 - var out bytes.Buffer 44 - app := app{homeDir: func() (string, error) { return home, nil }} 45 - if err := app.list(&out); err != nil { 41 + a := App{homeDir: func() (string, error) { return home, nil }} 42 + infos, err := a.List() 43 + if err != nil { 46 44 t.Fatal(err) 47 45 } 48 46 49 - got := out.String() 50 - for _, want := range []string{"Present App", "present.appimage", "1.2.3", "gone"} { 51 - if !strings.Contains(got, want) { 52 - t.Fatalf("list output missing %q:\n%s", want, got) 47 + byName := make(map[string]AppInfo, len(infos)) 48 + for _, info := range infos { 49 + byName[info.Name] = info 50 + } 51 + 52 + if info, ok := byName["Present App"]; !ok { 53 + t.Fatalf("list missing Present App: %+v", infos) 54 + } else { 55 + if info.Filename != "present.appimage" { 56 + t.Fatalf("filename = %q, want present.appimage", info.Filename) 57 + } 58 + if info.Version != "1.2.3" { 59 + t.Fatalf("version = %q, want 1.2.3", info.Version) 53 60 } 54 61 } 55 - if strings.Contains(got, "other") { 56 - t.Fatalf("unmanaged app appeared in list:\n%s", got) 62 + 63 + if info, ok := byName["gone"]; !ok { 64 + t.Fatalf("list missing gone: %+v", infos) 65 + } else if info.Filename != "" { 66 + t.Fatalf("gone filename = %q, want empty (orphaned)", info.Filename) 67 + } 68 + 69 + if _, ok := byName["Other"]; ok { 70 + t.Fatal("unmanaged app appeared in list") 57 71 } 58 72 } 59 73 60 74 func TestListEmptyWhenNothingManaged(t *testing.T) { 61 75 home := t.TempDir() 62 - var out bytes.Buffer 63 - app := app{homeDir: func() (string, error) { return home, nil }} 64 - if err := app.list(&out); err != nil { 76 + a := App{homeDir: func() (string, error) { return home, nil }} 77 + infos, err := a.List() 78 + if err != nil { 65 79 t.Fatal(err) 66 80 } 67 - lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") 68 - if len(lines) != 1 || !strings.HasPrefix(lines[0], "NAME") { 69 - t.Fatalf("expected header only, got:\n%s", out.String()) 81 + if len(infos) != 0 { 82 + t.Fatalf("expected no managed apps, got %d: %+v", len(infos), infos) 70 83 } 71 84 } 72 85 ··· 91 104 t.Fatal(err) 92 105 } 93 106 94 - var out bytes.Buffer 95 - app := app{homeDir: func() (string, error) { return home, nil }} 96 - if err := app.list(&out); err != nil { 107 + a := App{homeDir: func() (string, error) { return home, nil }} 108 + infos, err := a.List() 109 + if err != nil { 97 110 t.Fatal(err) 98 111 } 99 - if !strings.Contains(out.String(), "noname.appimage") { 100 - t.Fatalf("expected filename fallback in output:\n%s", out.String()) 112 + if len(infos) != 1 || infos[0].Name != "noname.appimage" { 113 + t.Fatalf("expected filename fallback, got: %+v", infos) 101 114 } 102 115 }
+3 -1
cmd/appherder/main.go
··· 3 3 import ( 4 4 "fmt" 5 5 "os" 6 + 7 + "github.com/alyraffauf/appherder/internal/appherder" 6 8 ) 7 9 8 10 func main() { 9 - cmd := newRootCommand(newApp(), os.Stdout, os.Stderr) 11 + cmd := newRootCommand(appherder.NewApp(), os.Stdout, os.Stderr) 10 12 cmd.SetArgs(os.Args[1:]) 11 13 if err := cmd.Execute(); err != nil { 12 14 fmt.Fprintln(os.Stderr, err)
+30 -30
cmd/appherder/source.go internal/appherder/source.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 13 13 "time" 14 14 ) 15 15 16 - // release describes the newest available build a source knows about. 17 - type release struct { 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 16 + // Release describes the newest available build a source knows about. 17 + type Release struct { 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 24 24 } 25 25 26 26 // checksum returns the strongest hash the release carries with a hasher to 27 27 // match; ok is false when the source provided no cryptographic hash. 28 - func (r release) checksum() (want string, hasher hash.Hash, ok bool) { 28 + func (r Release) checksum() (want string, hasher hash.Hash, ok bool) { 29 29 switch { 30 - case r.sha256 != "": 31 - return r.sha256, sha256.New(), true 32 - case r.sha1 != "": 33 - return r.sha1, sha1.New(), true 30 + case r.SHA256 != "": 31 + return r.SHA256, sha256.New(), true 32 + case r.SHA1 != "": 33 + return r.SHA1, sha1.New(), true 34 34 } 35 35 return "", nil, false 36 36 } 37 37 38 38 // localMatches reports whether file already equals this release, by the 39 39 // strongest available signal: checksum, then mtime, then size. 40 - func (r release) localMatches(file string) (bool, error) { 40 + func (r Release) localMatches(file string) (bool, error) { 41 41 if want, hasher, ok := r.checksum(); ok { 42 42 sum, err := fileSum(file, hasher) 43 43 if err != nil { ··· 52 52 } 53 53 // No checksum: current if the install is at least as new as the server's 54 54 // copy, else fall back to size. 55 - if !r.modTime.IsZero() { 56 - return !info.ModTime().Before(r.modTime), nil 55 + if !r.ModTime.IsZero() { 56 + return !info.ModTime().Before(r.ModTime), nil 57 57 } 58 - if r.size > 0 { 59 - return info.Size() == r.size, nil 58 + if r.Size > 0 { 59 + return info.Size() == r.Size, nil 60 60 } 61 61 return false, nil 62 62 } 63 63 64 64 // verifyDownload checks a freshly downloaded file against the release's 65 65 // checksum. With no checksum there is nothing to verify and it returns nil. 66 - func (r release) verifyDownload(file string) error { 66 + func (r Release) verifyDownload(file string) error { 67 67 want, hasher, ok := r.checksum() 68 68 if !ok { 69 69 return nil ··· 78 78 return nil 79 79 } 80 80 81 - // source resolves the latest available build of an installed app. 82 - type source interface { 83 - latest(ctx context.Context) (release, error) 84 - kind() string 81 + // Source resolves the latest available build of an installed app. 82 + type Source interface { 83 + Latest(ctx context.Context) (Release, error) 84 + Kind() string 85 85 } 86 86 87 - // readUpdateInfo returns the AppImage's embedded update-information string from 87 + // ReadUpdateInfo returns the AppImage's embedded update-information string from 88 88 // its .upd_info ELF section, or "" when absent or empty. 89 - func readUpdateInfo(path string) (string, error) { 89 + func ReadUpdateInfo(path string) (string, error) { 90 90 file, err := elf.Open(path) 91 91 if err != nil { 92 92 return "", fmt.Errorf("open AppImage %s: %w", path, err) ··· 104 104 return strings.TrimRight(string(data), "\x00"), nil 105 105 } 106 106 107 - // sourceForAppImage resolves an update source from the AppImage's embedded 107 + // SourceForAppImage resolves an update source from the AppImage's embedded 108 108 // update info. It returns (nil, nil) when the AppImage carries none. 109 - func sourceForAppImage(file string) (source, error) { 110 - info, err := readUpdateInfo(file) 109 + func SourceForAppImage(file string) (Source, error) { 110 + info, err := ReadUpdateInfo(file) 111 111 if err != nil { 112 112 return nil, err 113 113 } ··· 119 119 120 120 // parseUpdateInfo turns an AppImage update-info string (the "type|a|b|..." form) 121 121 // into a concrete source. 122 - func parseUpdateInfo(info string) (source, error) { 122 + func parseUpdateInfo(info string) (Source, error) { 123 123 fields := strings.Split(info, "|") 124 124 switch fields[0] { 125 125 case "gh-releases-zsync", "gh-releases-direct":
+13 -13
cmd/appherder/source_github.go internal/appherder/source_github.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 28 28 Assets []ghAsset `json:"assets"` 29 29 } 30 30 31 - func (githubReleaseSource) kind() string { return "github" } 31 + func (githubReleaseSource) Kind() string { return "github" } 32 32 33 - func (s githubReleaseSource) latest(ctx context.Context) (release, error) { 33 + func (s githubReleaseSource) Latest(ctx context.Context) (Release, error) { 34 34 base := s.api 35 35 if base == "" { 36 36 base = "https://api.github.com" ··· 47 47 48 48 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 49 49 if err != nil { 50 - return release{}, err 50 + return Release{}, err 51 51 } 52 52 req.Header.Set("Accept", "application/vnd.github+json") 53 53 if tok := githubToken(); tok != "" { ··· 56 56 57 57 resp, err := httpClient.Do(req) 58 58 if err != nil { 59 - return release{}, fmt.Errorf("query GitHub releases for %s/%s: %w", s.owner, s.repo, err) 59 + return Release{}, fmt.Errorf("query GitHub releases for %s/%s: %w", s.owner, s.repo, err) 60 60 } 61 61 defer resp.Body.Close() 62 62 if resp.StatusCode != http.StatusOK { 63 - return release{}, fmt.Errorf("query GitHub releases for %s/%s: %s", s.owner, s.repo, resp.Status) 63 + return Release{}, fmt.Errorf("query GitHub releases for %s/%s: %s", s.owner, s.repo, resp.Status) 64 64 } 65 65 66 66 var rel ghRelease 67 67 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 68 - return release{}, fmt.Errorf("decode GitHub release for %s/%s: %w", s.owner, s.repo, err) 68 + return Release{}, fmt.Errorf("decode GitHub release for %s/%s: %w", s.owner, s.repo, err) 69 69 } 70 70 71 71 asset, err := matchAsset(rel.Assets, s.pattern) 72 72 if err != nil { 73 - return release{}, err 73 + return Release{}, err 74 74 } 75 - return release{ 76 - version: rel.TagName, 77 - url: asset.URL, 78 - sha256: strings.TrimPrefix(asset.Digest, "sha256:"), 79 - size: asset.Size, 75 + return Release{ 76 + Version: rel.TagName, 77 + URL: asset.URL, 78 + SHA256: strings.TrimPrefix(asset.Digest, "sha256:"), 79 + Size: asset.Size, 80 80 }, nil 81 81 } 82 82
+12 -12
cmd/appherder/source_github_test.go internal/appherder/source_github_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 25 25 defer srv.Close() 26 26 27 27 src := githubReleaseSource{owner: "myorg", repo: "myapp", tag: "latest", pattern: "MyApp-*-x86_64.AppImage", api: srv.URL} 28 - rel, err := src.latest(context.Background()) 28 + rel, err := src.Latest(context.Background()) 29 29 if err != nil { 30 30 t.Fatal(err) 31 31 } 32 - if rel.version != "v1.2.3" { 33 - t.Fatalf("version = %q", rel.version) 32 + if rel.Version != "v1.2.3" { 33 + t.Fatalf("version = %q", rel.Version) 34 34 } 35 - if rel.url != "https://example.com/MyApp.AppImage" { 36 - t.Fatalf("url = %q", rel.url) 35 + if rel.URL != "https://example.com/MyApp.AppImage" { 36 + t.Fatalf("url = %q", rel.URL) 37 37 } 38 - if rel.sha256 != "deadbeef" { 39 - t.Fatalf("sha256 = %q, want digest with sha256: prefix stripped", rel.sha256) 38 + if rel.SHA256 != "deadbeef" { 39 + t.Fatalf("sha256 = %q, want digest with sha256: prefix stripped", rel.SHA256) 40 40 } 41 - if rel.size != 1234 { 42 - t.Fatalf("size = %d", rel.size) 41 + if rel.Size != 1234 { 42 + t.Fatalf("size = %d", rel.Size) 43 43 } 44 44 } 45 45 ··· 53 53 defer srv.Close() 54 54 55 55 src := githubReleaseSource{owner: "o", repo: "r", tag: "v9", pattern: "*.AppImage", api: srv.URL} 56 - if _, err := src.latest(context.Background()); err != nil { 56 + if _, err := src.Latest(context.Background()); err != nil { 57 57 t.Fatal(err) 58 58 } 59 59 } ··· 65 65 defer srv.Close() 66 66 67 67 src := githubReleaseSource{owner: "o", repo: "r", tag: "latest", pattern: "*.AppImage", api: srv.URL} 68 - if _, err := src.latest(context.Background()); err == nil { 68 + if _, err := src.Latest(context.Background()); err == nil { 69 69 t.Fatal("expected error on 404") 70 70 } 71 71 }
+12 -12
cmd/appherder/source_gitlab.go internal/appherder/source_gitlab.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 33 33 } `json:"assets"` 34 34 } 35 35 36 - func (gitlabReleaseSource) kind() string { return "gitlab" } 36 + func (gitlabReleaseSource) Kind() string { return "gitlab" } 37 37 38 - func (s gitlabReleaseSource) latest(ctx context.Context) (release, error) { 38 + func (s gitlabReleaseSource) Latest(ctx context.Context) (Release, error) { 39 39 base := s.api 40 40 if base == "" { 41 41 base = "https://" + s.host ··· 53 53 54 54 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 55 55 if err != nil { 56 - return release{}, err 56 + return Release{}, err 57 57 } 58 58 if tok := gitlabToken(); tok != "" { 59 59 req.Header.Set("PRIVATE-TOKEN", tok) ··· 61 61 62 62 resp, err := httpClient.Do(req) 63 63 if err != nil { 64 - return release{}, fmt.Errorf("query GitLab releases for %s: %w", s.project, err) 64 + return Release{}, fmt.Errorf("query GitLab releases for %s: %w", s.project, err) 65 65 } 66 66 defer resp.Body.Close() 67 67 if resp.StatusCode != http.StatusOK { 68 - return release{}, fmt.Errorf("query GitLab releases for %s: %s", s.project, resp.Status) 68 + return Release{}, fmt.Errorf("query GitLab releases for %s: %s", s.project, resp.Status) 69 69 } 70 70 71 71 var rel glRelease 72 72 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 73 - return release{}, fmt.Errorf("decode GitLab release for %s: %w", s.project, err) 73 + return Release{}, fmt.Errorf("decode GitLab release for %s: %w", s.project, err) 74 74 } 75 75 76 76 asset, err := matchLink(rel.Assets.Links, s.pattern) 77 77 if err != nil { 78 - return release{}, err 78 + return Release{}, err 79 79 } 80 - out := release{version: rel.TagName, url: linkURL(asset)} 80 + out := Release{Version: rel.TagName, URL: linkURL(asset)} 81 81 82 82 // No digest from the API: take the checksum from the sibling .zsync asset 83 83 // when present, else comparison falls back to size. 84 84 if zsyncLink, ok := findLink(rel.Assets.Links, asset.Name+".zsync"); ok { 85 85 header, err := fetchZsyncHeader(ctx, linkURL(zsyncLink)) 86 86 if err != nil { 87 - return release{}, err 87 + return Release{}, err 88 88 } 89 - out.sha1 = header["sha-1"] 89 + out.SHA1 = header["sha-1"] 90 90 if size, err := strconv.ParseInt(header["length"], 10, 64); err == nil { 91 - out.size = size 91 + out.Size = size 92 92 } 93 93 } 94 94 return out, nil
+14 -14
cmd/appherder/source_gitlab_test.go internal/appherder/source_gitlab_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 54 54 defer srv.Close() 55 55 56 56 src := gitlabReleaseSource{host: "gitlab.com", project: "mygroup/myapp", tag: "latest", pattern: "MyApp-*.AppImage", api: srv.URL} 57 - rel, err := src.latest(context.Background()) 57 + rel, err := src.Latest(context.Background()) 58 58 if err != nil { 59 59 t.Fatal(err) 60 60 } 61 - if rel.version != "v2.0.0" { 62 - t.Fatalf("version = %q", rel.version) 61 + if rel.Version != "v2.0.0" { 62 + t.Fatalf("version = %q", rel.Version) 63 63 } 64 - if rel.url != srv.URL+"/dl/MyApp-x86_64.AppImage" { 65 - t.Fatalf("url = %q, want direct_asset_url", rel.url) 64 + if rel.URL != srv.URL+"/dl/MyApp-x86_64.AppImage" { 65 + t.Fatalf("url = %q, want direct_asset_url", rel.URL) 66 66 } 67 - if rel.sha1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 68 - t.Fatalf("sha1 = %q, want value from sibling .zsync", rel.sha1) 67 + if rel.SHA1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 68 + t.Fatalf("sha1 = %q, want value from sibling .zsync", rel.SHA1) 69 69 } 70 - if rel.size != 4096 { 71 - t.Fatalf("size = %d, want length from sibling .zsync", rel.size) 70 + if rel.Size != 4096 { 71 + t.Fatalf("size = %d, want length from sibling .zsync", rel.Size) 72 72 } 73 73 } 74 74 ··· 82 82 defer srv.Close() 83 83 84 84 src := gitlabReleaseSource{host: "h", project: "o/r", tag: "v9", pattern: "*.AppImage", api: srv.URL} 85 - rel, err := src.latest(context.Background()) 85 + rel, err := src.Latest(context.Background()) 86 86 if err != nil { 87 87 t.Fatal(err) 88 88 } 89 - if rel.url != "u" { // no direct_asset_url, falls back to url 90 - t.Fatalf("url = %q", rel.url) 89 + if rel.URL != "u" { // no direct_asset_url, falls back to url 90 + t.Fatalf("url = %q", rel.URL) 91 91 } 92 92 } 93 93 ··· 98 98 defer srv.Close() 99 99 100 100 src := gitlabReleaseSource{host: "h", project: "o/r", tag: "latest", pattern: "*.AppImage", api: srv.URL} 101 - if _, err := src.latest(context.Background()); err == nil { 101 + if _, err := src.Latest(context.Background()); err == nil { 102 102 t.Fatal("expected error on 404") 103 103 } 104 104 }
+8 -8
cmd/appherder/source_static.go internal/appherder/source_static.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 14 14 url string 15 15 } 16 16 17 - func (staticURLSource) kind() string { return "static" } 17 + func (staticURLSource) Kind() string { return "static" } 18 18 19 - func (s staticURLSource) latest(ctx context.Context) (release, error) { 19 + func (s staticURLSource) Latest(ctx context.Context) (Release, error) { 20 20 ctx, cancel := context.WithTimeout(ctx, apiTimeout) 21 21 defer cancel() 22 22 23 23 resp, err := s.probe(ctx) 24 24 if err != nil { 25 - return release{}, err 25 + return Release{}, err 26 26 } 27 27 defer resp.Body.Close() 28 28 29 - rel := release{url: s.url, version: "latest"} 29 + rel := Release{URL: s.url, Version: "latest"} 30 30 if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" { 31 - rel.version = lastModified 31 + rel.Version = lastModified 32 32 if modTime, err := http.ParseTime(lastModified); err == nil { 33 - rel.modTime = modTime 33 + rel.ModTime = modTime 34 34 } 35 35 } 36 36 if size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil { 37 - rel.size = size 37 + rel.Size = size 38 38 } 39 39 return rel, nil 40 40 }
+15 -15
cmd/appherder/source_static_test.go internal/appherder/source_static_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 43 43 })) 44 44 defer srv.Close() 45 45 46 - rel, err := staticURLSource{url: srv.URL}.latest(context.Background()) 46 + rel, err := staticURLSource{url: srv.URL}.Latest(context.Background()) 47 47 if err != nil { 48 48 t.Fatal(err) 49 49 } 50 - if rel.url != srv.URL { 51 - t.Fatalf("url = %q", rel.url) 50 + if rel.URL != srv.URL { 51 + t.Fatalf("url = %q", rel.URL) 52 52 } 53 - if rel.size != 4096 { 54 - t.Fatalf("size = %d", rel.size) 53 + if rel.Size != 4096 { 54 + t.Fatalf("size = %d", rel.Size) 55 55 } 56 - if rel.version != lastModified { 57 - t.Fatalf("version = %q", rel.version) 56 + if rel.Version != lastModified { 57 + t.Fatalf("version = %q", rel.Version) 58 58 } 59 - if want, _ := http.ParseTime(lastModified); !rel.modTime.Equal(want) { 60 - t.Fatalf("modTime = %v, want %v", rel.modTime, want) 59 + if want, _ := http.ParseTime(lastModified); !rel.ModTime.Equal(want) { 60 + t.Fatalf("modTime = %v, want %v", rel.ModTime, want) 61 61 } 62 62 } 63 63 ··· 72 72 })) 73 73 defer srv.Close() 74 74 75 - rel, err := staticURLSource{url: srv.URL}.latest(context.Background()) 75 + rel, err := staticURLSource{url: srv.URL}.Latest(context.Background()) 76 76 if err != nil { 77 77 t.Fatal(err) 78 78 } 79 - if rel.size != 10 { 80 - t.Fatalf("size = %d, want value from GET fallback", rel.size) 79 + if rel.Size != 10 { 80 + t.Fatalf("size = %d, want value from GET fallback", rel.Size) 81 81 } 82 82 } 83 83 ··· 96 96 97 97 // Installed after the server's copy: current. 98 98 chtime(server.Add(time.Hour)) 99 - if ok, err := (release{modTime: server}).localMatches(file); err != nil || !ok { 99 + if ok, err := (Release{ModTime: server}).localMatches(file); err != nil || !ok { 100 100 t.Fatalf("newer install: matches = %v, %v; want true", ok, err) 101 101 } 102 102 103 103 // Server published something newer: stale. 104 104 chtime(server.Add(-time.Hour)) 105 - if ok, _ := (release{modTime: server}).localMatches(file); ok { 105 + if ok, _ := (Release{ModTime: server}).localMatches(file); ok { 106 106 t.Fatal("older install: matches = true, want false") 107 107 } 108 108 }
+3 -3
cmd/appherder/source_test.go internal/appherder/source_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "crypto/sha1" ··· 17 17 } 18 18 sum := sha1.Sum(content) 19 19 20 - rel := release{sha1: hex.EncodeToString(sum[:])} 20 + rel := Release{SHA1: hex.EncodeToString(sum[:])} 21 21 if ok, err := rel.localMatches(file); err != nil || !ok { 22 22 t.Fatalf("localMatches = %v, %v; want true", ok, err) 23 23 } ··· 25 25 t.Fatalf("verifyDownload = %v; want nil", err) 26 26 } 27 27 28 - stale := release{sha1: hex.EncodeToString(make([]byte, 20))} 28 + stale := Release{SHA1: hex.EncodeToString(make([]byte, 20))} 29 29 if ok, _ := stale.localMatches(file); ok { 30 30 t.Fatal("localMatches = true for mismatched sha1") 31 31 }
+10 -10
cmd/appherder/source_zsync.go internal/appherder/source_zsync.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "bufio" ··· 19 19 url string 20 20 } 21 21 22 - func (zsyncURLSource) kind() string { return "zsync" } 22 + func (zsyncURLSource) Kind() string { return "zsync" } 23 23 24 - func (s zsyncURLSource) latest(ctx context.Context) (release, error) { 24 + func (s zsyncURLSource) Latest(ctx context.Context) (Release, error) { 25 25 header, err := fetchZsyncHeader(ctx, s.url) 26 26 if err != nil { 27 - return release{}, err 27 + return Release{}, err 28 28 } 29 29 30 30 target, err := resolveZsyncURL(s.url, header["url"]) 31 31 if err != nil { 32 - return release{}, err 32 + return Release{}, err 33 33 } 34 34 35 - rel := release{ 36 - url: target, 37 - sha1: header["sha-1"], 38 - version: zsyncVersion(header), 35 + rel := Release{ 36 + URL: target, 37 + SHA1: header["sha-1"], 38 + Version: zsyncVersion(header), 39 39 } 40 40 if size, err := strconv.ParseInt(header["length"], 10, 64); err == nil { 41 - rel.size = size 41 + rel.Size = size 42 42 } 43 43 return rel, nil 44 44 }
+10 -10
cmd/appherder/source_zsync_test.go internal/appherder/source_zsync_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" ··· 88 88 defer srv.Close() 89 89 90 90 src := zsyncURLSource{url: srv.URL + "/app/MyApp.AppImage.zsync"} 91 - rel, err := src.latest(context.Background()) 91 + rel, err := src.Latest(context.Background()) 92 92 if err != nil { 93 93 t.Fatal(err) 94 94 } 95 - if rel.url != srv.URL+"/app/MyApp-x86_64.AppImage" { 96 - t.Fatalf("url = %q, want target resolved against control file", rel.url) 95 + if rel.URL != srv.URL+"/app/MyApp-x86_64.AppImage" { 96 + t.Fatalf("url = %q, want target resolved against control file", rel.URL) 97 97 } 98 - if rel.sha1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 99 - t.Fatalf("sha1 = %q", rel.sha1) 98 + if rel.SHA1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 99 + t.Fatalf("sha1 = %q", rel.SHA1) 100 100 } 101 - if rel.size != 4096 { 102 - t.Fatalf("size = %d", rel.size) 101 + if rel.Size != 4096 { 102 + t.Fatalf("size = %d", rel.Size) 103 103 } 104 - if rel.version != "Wed, 01 Jan 2025 00:00:00 +0000" { 105 - t.Fatalf("version = %q", rel.version) 104 + if rel.Version != "Wed, 01 Jan 2025 00:00:00 +0000" { 105 + t.Fatalf("version = %q", rel.Version) 106 106 } 107 107 }
+43 -32
cmd/appherder/sync.go internal/appherder/sync.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "io" 7 6 "os" 8 7 "path/filepath" 9 8 "sort" ··· 14 13 // and squashfs decompression without copying several 200 MB binaries at once. 15 14 const installConcurrency = 4 16 15 17 - type syncResult struct { 18 - file string 19 - appName string 20 - err error 16 + // SyncInstall is one AppImage's outcome from the install phase of Sync. 17 + type SyncInstall struct { 18 + File string // source AppImage path 19 + AppName string // resolved install name; "" on error 20 + New bool // false if the app was already managed 21 + Err error // nil means success 21 22 } 22 23 23 - // sync reconciles ~/AppImages with installed state: install every AppImage in 24 + // SyncRemoval is one launcher's outcome from the removal phase of Sync. 25 + type SyncRemoval struct { 26 + AppName string 27 + Err error // nil means removed 28 + } 29 + 30 + // SyncResult is the structured outcome of Sync: what was installed and what 31 + // was removed, with per-item errors so the caller can format them. 32 + type SyncResult struct { 33 + Installs []SyncInstall 34 + Removals []SyncRemoval 35 + } 36 + 37 + // Sync reconciles ~/AppImages with installed state: install every AppImage in 24 38 // the folder, remove launchers whose AppImage is gone. With force, also remove 25 39 // unmanaged launchers whose TryExec/Exec points at a missing file in ~/AppImages 26 - // (entries left by another tool). Per-file errors are reported and skipped so 27 - // one bad file does not abort the pass. 28 - func (a app) sync(ctx context.Context, out io.Writer, force bool) error { 40 + // (entries left by another tool). Per-file errors are included in the result 41 + // rather than aborting the pass. 42 + func (a App) Sync(ctx context.Context, force bool) (SyncResult, error) { 29 43 home, err := a.homeDir() 30 44 if err != nil { 31 - return fmt.Errorf("resolve home directory: %w", err) 45 + return SyncResult{}, fmt.Errorf("resolve home directory: %w", err) 32 46 } 33 47 appimagesDir := filepath.Join(home, "AppImages") 34 48 35 - // Snapshot managed apps before installing, so we can report only newly 36 - // installed ones instead of every already-present app. 37 49 existing, err := managedApps(home) 38 50 if err != nil { 39 - return err 51 + return SyncResult{}, err 40 52 } 41 53 managed := make(map[string]bool, len(existing)) 42 54 for _, appid := range existing { ··· 45 57 46 58 files, err := listAppImages(appimagesDir) 47 59 if err != nil { 48 - return err 60 + return SyncResult{}, err 49 61 } 50 62 51 - // parallelMap preserves input order, so output stays deterministic. 52 - results := parallelMap(ctx, files, installConcurrency, func(_ context.Context, f string) syncResult { 53 - name, err := a.install(f) 54 - return syncResult{file: f, appName: name, err: err} 63 + // parallelMap preserves input order, so results stay deterministic. 64 + installResults := parallelMap(ctx, files, installConcurrency, func(_ context.Context, f string) SyncInstall { 65 + name, err := a.Install(f) 66 + return SyncInstall{File: f, AppName: name, Err: err} 55 67 }) 56 - for _, result := range results { 57 - if result.err != nil { 58 - fmt.Fprintf(out, "skipped %s: %v\n", filepath.Base(result.file), result.err) 59 - continue 68 + 69 + var result SyncResult 70 + for _, inst := range installResults { 71 + if inst.Err == nil && !managed[inst.AppName] { 72 + inst.New = true 60 73 } 61 - if !managed[result.appName] { 62 - fmt.Fprintf(out, "installed %s\n", result.appName) 63 - } 74 + result.Installs = append(result.Installs, inst) 64 75 } 65 76 66 77 candidates := existing 67 78 if force { 68 79 extra, err := appImageBackedOrphans(home, appimagesDir) 69 80 if err != nil { 70 - return err 81 + return result, err 71 82 } 72 83 candidates = append(candidates, extra...) 73 84 } 74 85 for _, appid := range candidates { 75 86 present, err := appImagePresent(appimagesDir, appid) 76 87 if err != nil { 77 - return err 88 + return result, err 78 89 } 79 90 if present { 80 91 continue 81 92 } 82 - if err := a.uninstall(appid, force); err != nil { 83 - fmt.Fprintf(out, "skipped removing %s: %v\n", appid, err) 93 + if err := a.Uninstall(appid, force); err != nil { 94 + result.Removals = append(result.Removals, SyncRemoval{AppName: appid, Err: err}) 84 95 continue 85 96 } 86 - fmt.Fprintf(out, "removed %s\n", appid) 97 + result.Removals = append(result.Removals, SyncRemoval{AppName: appid}) 87 98 } 88 - return nil 99 + return result, nil 89 100 } 90 101 91 102 // listAppImages returns *.appimage files in dir, case-insensitive (the
+64 -43
cmd/appherder/sync_test.go internal/appherder/sync_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "errors" 7 6 "os" 8 7 "path/filepath" 9 - "strings" 10 8 "testing" 11 9 ) 12 10 ··· 29 27 } 30 28 writeManagedDesktop(t, home, "gone") 31 29 32 - var out bytes.Buffer 33 - a := app{homeDir: func() (string, error) { return home, nil }} 34 - if err := a.sync(context.Background(), &out, false); err != nil { 30 + a := App{homeDir: func() (string, error) { return home, nil }} 31 + result, err := a.Sync(context.Background(), false) 32 + if err != nil { 35 33 t.Fatal(err) 36 34 } 37 35 38 36 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "gone.desktop")); !errors.Is(err, os.ErrNotExist) { 39 37 t.Fatalf("orphaned launcher should be removed, stat err: %v", err) 40 38 } 41 - if !bytes.Contains(out.Bytes(), []byte("removed gone")) { 42 - t.Fatalf("sync output = %q, want it to report removal of gone", out.String()) 39 + if !removalSucceeded(result, "gone") { 40 + t.Fatalf("sync result = %+v, want removal of gone", result) 43 41 } 44 42 } 45 43 ··· 56 54 } 57 55 writeManagedDesktop(t, home, "present") 58 56 59 - var out bytes.Buffer 60 - a := app{homeDir: func() (string, error) { return home, nil }} 61 - if err := a.sync(context.Background(), &out, false); err != nil { 57 + a := App{homeDir: func() (string, error) { return home, nil }} 58 + result, err := a.Sync(context.Background(), false) 59 + if err != nil { 62 60 t.Fatal(err) 63 61 } 64 62 65 63 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "present.desktop")); err != nil { 66 64 t.Fatalf("launcher for a present (if unparseable) AppImage should be kept: %v", err) 67 65 } 68 - if !bytes.Contains(out.Bytes(), []byte("skipped present.appimage:")) { 69 - t.Fatalf("sync output = %q, want a skip report for the bad file", out.String()) 66 + if !installFailed(result, "present.appimage") { 67 + t.Fatalf("sync result = %+v, want a failed install for present.appimage", result) 70 68 } 71 69 } 72 70 ··· 85 83 t.Fatal(err) 86 84 } 87 85 88 - a := app{homeDir: func() (string, error) { return home, nil }} 86 + a := App{homeDir: func() (string, error) { return home, nil }} 89 87 for _, force := range []bool{false, true} { 90 - var out bytes.Buffer 91 - if err := a.sync(context.Background(), &out, force); err != nil { 88 + if _, err := a.Sync(context.Background(), force); err != nil { 92 89 t.Fatal(err) 93 90 } 94 91 if _, err := os.Stat(handmade); err != nil { ··· 114 111 t.Fatal(err) 115 112 } 116 113 117 - var out bytes.Buffer 118 - a := app{homeDir: func() (string, error) { return home, nil }} 119 - if err := a.sync(context.Background(), &out, true); err != nil { 114 + a := App{homeDir: func() (string, error) { return home, nil }} 115 + result, err := a.Sync(context.Background(), true) 116 + if err != nil { 120 117 t.Fatal(err) 121 118 } 122 119 if _, err := os.Stat(filepath.Join(dir, "gone.desktop")); !errors.Is(err, os.ErrNotExist) { 123 120 t.Fatalf("--force should remove the AppImage-backed orphan, stat err: %v", err) 124 121 } 125 - if !bytes.Contains(out.Bytes(), []byte("removed gone")) { 126 - t.Fatalf("sync output = %q, want removal reported", out.String()) 122 + if !removalSucceeded(result, "gone") { 123 + t.Fatalf("sync result = %+v, want removal reported", result) 127 124 } 128 125 } 129 126 ··· 147 144 t.Fatal(err) 148 145 } 149 146 150 - var out bytes.Buffer 151 - a := app{homeDir: func() (string, error) { return home, nil }} 152 - if err := a.sync(context.Background(), &out, true); err != nil { 147 + a := App{homeDir: func() (string, error) { return home, nil }} 148 + if _, err := a.Sync(context.Background(), true); err != nil { 153 149 t.Fatal(err) 154 150 } 155 151 if _, err := os.Stat(filepath.Join(dir, "present.desktop")); err != nil { ··· 170 166 } 171 167 } 172 168 173 - var out bytes.Buffer 174 - a := app{homeDir: func() (string, error) { return home, nil }} 175 - if err := a.sync(context.Background(), &out, false); err != nil { 169 + a := App{homeDir: func() (string, error) { return home, nil }} 170 + result, err := a.Sync(context.Background(), false) 171 + if err != nil { 176 172 t.Fatal(err) 177 173 } 178 - if out.Len() != 0 { 179 - t.Fatalf("sync output = %q, want no activity for hidden/temp files", out.String()) 174 + if len(result.Installs) != 0 || len(result.Removals) != 0 { 175 + t.Fatalf("sync result = %+v, want no activity for hidden/temp files", result) 180 176 } 181 177 } 182 178 ··· 184 180 home := t.TempDir() 185 181 writeManagedDesktop(t, home, "orphan") 186 182 187 - var out bytes.Buffer 188 - a := app{homeDir: func() (string, error) { return home, nil }} 189 - if err := a.sync(context.Background(), &out, false); err != nil { 183 + a := App{homeDir: func() (string, error) { return home, nil }} 184 + if _, err := a.Sync(context.Background(), false); err != nil { 190 185 t.Fatal(err) 191 186 } 192 187 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "orphan.desktop")); !errors.Is(err, os.ErrNotExist) { ··· 200 195 if err := os.MkdirAll(appimages, 0o755); err != nil { 201 196 t.Fatal(err) 202 197 } 203 - // Concurrent installs must still produce skip lines in input order. 198 + // Concurrent installs must still produce results in input order. 204 199 for _, name := range []string{"charlie.appimage", "alpha.appimage", "bravo.appimage"} { 205 200 if err := os.WriteFile(filepath.Join(appimages, name), []byte("not an appimage"), 0o644); err != nil { 206 201 t.Fatal(err) 207 202 } 208 203 } 209 204 210 - var out bytes.Buffer 211 - a := app{homeDir: func() (string, error) { return home, nil }} 212 - if err := a.sync(context.Background(), &out, false); err != nil { 205 + a := App{homeDir: func() (string, error) { return home, nil }} 206 + result, err := a.Sync(context.Background(), false) 207 + if err != nil { 213 208 t.Fatal(err) 214 209 } 215 210 216 - got := out.String() 217 211 want := []string{"alpha.appimage", "bravo.appimage", "charlie.appimage"} 218 - pos := 0 219 - for _, wantName := range want { 220 - idx := strings.Index(got[pos:], wantName) 221 - if idx < 0 { 222 - t.Fatalf("output missing %s in order:\n%s", wantName, got) 212 + var failed []string 213 + for _, inst := range result.Installs { 214 + if inst.Err != nil { 215 + failed = append(failed, filepath.Base(inst.File)) 216 + } 217 + } 218 + if len(failed) != len(want) { 219 + t.Fatalf("failed installs = %v, want %d", failed, len(want)) 220 + } 221 + for i, name := range want { 222 + if failed[i] != name { 223 + t.Fatalf("failed[%d] = %s, want %s (order must be sorted)", i, failed[i], name) 224 + } 225 + } 226 + } 227 + 228 + // removalSucceeded reports whether result includes a successful removal of appid. 229 + func removalSucceeded(result SyncResult, appid string) bool { 230 + for _, rem := range result.Removals { 231 + if rem.AppName == appid && rem.Err == nil { 232 + return true 223 233 } 224 - pos += idx + len(wantName) 234 + } 235 + return false 236 + } 237 + 238 + // installFailed reports whether result includes a failed install whose File 239 + // basename ends with filename. 240 + func installFailed(result SyncResult, filename string) bool { 241 + for _, inst := range result.Installs { 242 + if inst.Err != nil && filepath.Base(inst.File) == filename { 243 + return true 244 + } 225 245 } 246 + return false 226 247 }
+5 -4
cmd/appherder/uninstall.go internal/appherder/uninstall.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "errors" ··· 8 8 "strings" 9 9 ) 10 10 11 - func (a app) uninstall(name string, force bool) error { 12 - appName := normalizeAppName(name) 11 + func (a App) Uninstall(name string, force bool) error { 12 + appName := NormalizeAppName(name) 13 13 home, err := a.homeDir() 14 14 if err != nil { 15 15 return fmt.Errorf("resolve home directory: %w", err) ··· 35 35 return nil 36 36 } 37 37 38 - func normalizeAppName(name string) string { 38 + // NormalizeAppName strips directory and .appimage extension from name. 39 + func NormalizeAppName(name string) string { 39 40 name = filepath.Base(name) 40 41 if ext := filepath.Ext(name); strings.EqualFold(ext, ".appimage") { 41 42 name = strings.TrimSuffix(name, ext)
+8 -8
cmd/appherder/uninstall_test.go internal/appherder/uninstall_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "errors" ··· 22 22 } 23 23 24 24 for input, want := range tests { 25 - if got := normalizeAppName(input); got != want { 26 - t.Fatalf("normalizeAppName(%q) = %q, want %q", input, got, want) 25 + if got := NormalizeAppName(input); got != want { 26 + t.Fatalf("NormalizeAppName(%q) = %q, want %q", input, got, want) 27 27 } 28 28 } 29 29 } ··· 43 43 } 44 44 } 45 45 46 - a := app{homeDir: func() (string, error) { return home, nil }} 47 - if err := a.uninstall(filepath.Join(home, "AppImages", "example.AppImage"), false); err != nil { 46 + a := App{homeDir: func() (string, error) { return home, nil }} 47 + if err := a.Uninstall(filepath.Join(home, "AppImages", "example.AppImage"), false); err != nil { 48 48 t.Fatal(err) 49 49 } 50 50 ··· 66 66 t.Fatal(err) 67 67 } 68 68 69 - a := app{homeDir: func() (string, error) { return home, nil }} 70 - if err := a.uninstall("example", false); err != nil { 69 + a := App{homeDir: func() (string, error) { return home, nil }} 70 + if err := a.Uninstall("example", false); err != nil { 71 71 t.Fatal(err) 72 72 } 73 73 if _, err := os.Stat(desktop); err != nil { ··· 75 75 } 76 76 77 77 // --force removes it anyway. 78 - if err := a.uninstall("example", true); err != nil { 78 + if err := a.Uninstall("example", true); err != nil { 79 79 t.Fatal(err) 80 80 } 81 81 if _, err := os.Stat(desktop); !errors.Is(err, os.ErrNotExist) {
-163
cmd/appherder/upgrade.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "net/http" 8 - "os" 9 - "path/filepath" 10 - "strings" 11 - "sync" 12 - ) 13 - 14 - // checkConcurrency caps concurrent update checks: enough to overlap network 15 - // latency without hammering the API. 16 - const checkConcurrency = 8 17 - 18 - // upgradeCheck is the per-app outcome of the (concurrent) check phase. 19 - type upgradeCheck struct { 20 - name string 21 - release release 22 - available bool // a newer build is available 23 - noSource bool // no embedded update info; skip silently 24 - err error // the check itself failed 25 - } 26 - 27 - // upgrade installs available updates for AppImages in ~/AppImages; checkOnly 28 - // reports them without downloading. Apps with no update info or already current 29 - // are skipped silently, and per-app errors don't abort the run. 30 - func (a app) upgrade(ctx context.Context, out io.Writer, checkOnly bool) error { 31 - home, err := a.homeDir() 32 - if err != nil { 33 - return fmt.Errorf("resolve home directory: %w", err) 34 - } 35 - files, err := listAppImages(filepath.Join(home, "AppImages")) 36 - if err != nil { 37 - return err 38 - } 39 - 40 - // Check every app concurrently; results come back in input order. 41 - checks := parallelMap(ctx, files, checkConcurrency, func(ctx context.Context, file string) upgradeCheck { 42 - return checkOne(ctx, file) 43 - }) 44 - 45 - // Apply sequentially: bandwidth/disk-bound, and keeps output ordered. 46 - available := 0 47 - for _, check := range checks { 48 - switch { 49 - case check.err != nil: 50 - fmt.Fprintf(out, "skipped %s: %v\n", check.name, check.err) 51 - continue 52 - case check.noSource || !check.available: 53 - continue 54 - } 55 - 56 - available++ 57 - if checkOnly { 58 - fmt.Fprintf(out, "%s: update available (%s)\n", check.name, check.release.version) 59 - continue 60 - } 61 - 62 - if err := a.applyUpgrade(ctx, check.release); err != nil { 63 - fmt.Fprintf(out, "skipped %s: %v\n", check.name, err) 64 - continue 65 - } 66 - fmt.Fprintf(out, "upgraded %s to %s\n", check.name, check.release.version) 67 - } 68 - 69 - if available == 0 { 70 - fmt.Fprintln(out, "everything is up to date") 71 - } 72 - return nil 73 - } 74 - 75 - // checkOne resolves an AppImage's source and reports whether an update exists. 76 - func checkOne(ctx context.Context, file string) upgradeCheck { 77 - name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) 78 - 79 - src, err := sourceForAppImage(file) 80 - if err != nil { 81 - return upgradeCheck{name: name, err: err} 82 - } 83 - if src == nil { 84 - return upgradeCheck{name: name, noSource: true} 85 - } 86 - 87 - rel, err := src.latest(ctx) 88 - if err != nil { 89 - return upgradeCheck{name: name, err: err} 90 - } 91 - 92 - current, err := rel.localMatches(file) 93 - if err != nil { 94 - return upgradeCheck{name: name, err: err} 95 - } 96 - return upgradeCheck{name: name, release: rel, available: !current} 97 - } 98 - 99 - // parallelMap applies fn to each item with at most `limit` concurrent calls, 100 - // returning results in input order. 101 - func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { 102 - if limit < 1 { 103 - limit = 1 104 - } 105 - results := make([]R, len(items)) 106 - sem := make(chan struct{}, limit) 107 - var wg sync.WaitGroup 108 - for i, item := range items { 109 - wg.Add(1) 110 - sem <- struct{}{} 111 - go func(i int, item T) { 112 - defer wg.Done() 113 - defer func() { <-sem }() 114 - results[i] = fn(ctx, item) 115 - }(i, item) 116 - } 117 - wg.Wait() 118 - return results 119 - } 120 - 121 - func (a app) applyUpgrade(ctx context.Context, rel release) error { 122 - tmp, err := os.CreateTemp("", "appherder-upgrade-*.appimage") 123 - if err != nil { 124 - return fmt.Errorf("create temporary file: %w", err) 125 - } 126 - tmpName := tmp.Name() 127 - defer os.Remove(tmpName) 128 - 129 - if err := download(ctx, rel.url, tmp); err != nil { 130 - tmp.Close() 131 - return err 132 - } 133 - if err := tmp.Close(); err != nil { 134 - return fmt.Errorf("close download: %w", err) 135 - } 136 - 137 - if err := rel.verifyDownload(tmpName); err != nil { 138 - return err 139 - } 140 - 141 - _, err = a.install(tmpName) 142 - return err 143 - } 144 - 145 - func download(ctx context.Context, url string, writer io.Writer) error { 146 - ctx, cancel := context.WithCancel(ctx) 147 - defer cancel() 148 - 149 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 150 - if err != nil { 151 - return err 152 - } 153 - resp, err := httpClient.Do(req) 154 - if err != nil { 155 - return fmt.Errorf("download %s: %w", url, err) 156 - } 157 - defer resp.Body.Close() 158 - if resp.StatusCode != http.StatusOK { 159 - return fmt.Errorf("download %s: %s", url, resp.Status) 160 - } 161 - _, err = io.Copy(writer, newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) 162 - return err 163 - }
+1 -1
cmd/appherder/upgrade_test.go internal/appherder/upgrade_test.go
··· 1 - package main 1 + package appherder 2 2 3 3 import ( 4 4 "context"
+16
internal/appherder/app.go
··· 1 + package appherder 2 + 3 + import "os" 4 + 5 + // App is the core engine for managing AppImages. It holds no CLI or I/O 6 + // state; all output formatting is the caller's responsibility. 7 + type App struct { 8 + homeDir func() (string, error) 9 + } 10 + 11 + // NewApp returns an App wired to the current user's home directory. 12 + func NewApp() App { 13 + return App{ 14 + homeDir: os.UserHomeDir, 15 + } 16 + }
+76
internal/appherder/list.go
··· 1 + package appherder 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "sort" 8 + ) 9 + 10 + // AppInfo is one managed app's display metadata, as returned by List. 11 + type AppInfo struct { 12 + AppID string 13 + Name string // desktop Name= field, falls back to filename 14 + Filename string // basename of the AppImage in ~/AppImages, "" when missing 15 + Version string // desktop X-AppImage-Version= 16 + Size int64 17 + Source string // update source kind, "none" when no info 18 + } 19 + 20 + // List returns display metadata for every app appherder manages. Offline and 21 + // instant; use CheckUpgrades to see what's stale. 22 + func (a App) List() ([]AppInfo, error) { 23 + home, err := a.homeDir() 24 + if err != nil { 25 + return nil, fmt.Errorf("resolve home directory: %w", err) 26 + } 27 + 28 + appids, err := managedApps(home) 29 + if err != nil { 30 + return nil, err 31 + } 32 + 33 + appimagesDir := filepath.Join(home, "AppImages") 34 + appsDir := filepath.Join(home, ".local", "share", "applications") 35 + 36 + infos := make([]AppInfo, 0, len(appids)) 37 + for _, appid := range appids { 38 + infos = append(infos, gatherAppInfo(appsDir, appimagesDir, appid)) 39 + } 40 + sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) 41 + return infos, nil 42 + } 43 + 44 + // gatherAppInfo collects display metadata for appid from its installed desktop 45 + // file and AppImage. 46 + func gatherAppInfo(appsDir, appimagesDir, appid string) AppInfo { 47 + info := AppInfo{AppID: appid, Source: "none"} 48 + 49 + if desktop, err := readDesktopFile(filepath.Join(appsDir, appid+".desktop")); err == nil { 50 + if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" { 51 + info.Name = name 52 + } 53 + if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok { 54 + info.Version = version 55 + } 56 + } 57 + 58 + if path, err := findAppImagePath(appimagesDir, appid); err == nil && path != "" { 59 + info.Filename = filepath.Base(path) 60 + if stat, err := os.Stat(path); err == nil { 61 + info.Size = stat.Size() 62 + } 63 + if src, err := SourceForAppImage(path); err == nil && src != nil { 64 + info.Source = src.Kind() 65 + } 66 + } 67 + 68 + if info.Name == "" { 69 + if info.Filename != "" { 70 + info.Name = info.Filename 71 + } else { 72 + info.Name = appid 73 + } 74 + } 75 + return info 76 + }
+159
internal/appherder/upgrade.go
··· 1 + package appherder 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + "sync" 12 + ) 13 + 14 + // checkConcurrency caps concurrent update checks: enough to overlap network 15 + // latency without hammering the API. 16 + const checkConcurrency = 8 17 + 18 + // UpgradeCheck is the per-app outcome of checking for an update. 19 + type UpgradeCheck struct { 20 + Name string 21 + Release Release 22 + Available bool // a newer build is available 23 + NoSource bool // no embedded update info; skip silently 24 + Err error // the check itself failed 25 + } 26 + 27 + // UpgradeApplied is the per-app outcome of downloading and installing an update. 28 + type UpgradeApplied struct { 29 + Name string 30 + Version string 31 + Err error 32 + } 33 + 34 + // CheckUpgrades checks every AppImage in ~/AppImages for an available update. 35 + // Results come back in sorted filename order. Apps with no update info or 36 + // already current are included with NoSource/Available=false so the caller 37 + // can decide what to show. 38 + func (a App) CheckUpgrades(ctx context.Context) ([]UpgradeCheck, error) { 39 + home, err := a.homeDir() 40 + if err != nil { 41 + return nil, fmt.Errorf("resolve home directory: %w", err) 42 + } 43 + files, err := listAppImages(filepath.Join(home, "AppImages")) 44 + if err != nil { 45 + return nil, err 46 + } 47 + return parallelMap(ctx, files, checkConcurrency, func(ctx context.Context, file string) UpgradeCheck { 48 + return checkOne(ctx, file) 49 + }), nil 50 + } 51 + 52 + // ApplyUpgrades downloads and installs updates for the given checks, processing 53 + // only those with Available=true. Apply is sequential (bandwidth-bound); per-app 54 + // errors are included in the result rather than aborting the run. 55 + func (a App) ApplyUpgrades(ctx context.Context, checks []UpgradeCheck) []UpgradeApplied { 56 + var applied []UpgradeApplied 57 + for _, check := range checks { 58 + if check.Err != nil || check.NoSource || !check.Available { 59 + continue 60 + } 61 + err := a.applyUpgrade(ctx, check.Release) 62 + applied = append(applied, UpgradeApplied{ 63 + Name: check.Name, 64 + Version: check.Release.Version, 65 + Err: err, 66 + }) 67 + } 68 + return applied 69 + } 70 + 71 + // checkOne resolves an AppImage's source and reports whether an update exists. 72 + func checkOne(ctx context.Context, file string) UpgradeCheck { 73 + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) 74 + 75 + src, err := SourceForAppImage(file) 76 + if err != nil { 77 + return UpgradeCheck{Name: name, Err: err} 78 + } 79 + if src == nil { 80 + return UpgradeCheck{Name: name, NoSource: true} 81 + } 82 + 83 + rel, err := src.Latest(ctx) 84 + if err != nil { 85 + return UpgradeCheck{Name: name, Err: err} 86 + } 87 + 88 + current, err := rel.localMatches(file) 89 + if err != nil { 90 + return UpgradeCheck{Name: name, Err: err} 91 + } 92 + return UpgradeCheck{Name: name, Release: rel, Available: !current} 93 + } 94 + 95 + // parallelMap applies fn to each item with at most `limit` concurrent calls, 96 + // returning results in input order. 97 + func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { 98 + if limit < 1 { 99 + limit = 1 100 + } 101 + results := make([]R, len(items)) 102 + sem := make(chan struct{}, limit) 103 + var wg sync.WaitGroup 104 + for i, item := range items { 105 + wg.Add(1) 106 + sem <- struct{}{} 107 + go func(i int, item T) { 108 + defer wg.Done() 109 + defer func() { <-sem }() 110 + results[i] = fn(ctx, item) 111 + }(i, item) 112 + } 113 + wg.Wait() 114 + return results 115 + } 116 + 117 + func (a App) applyUpgrade(ctx context.Context, rel Release) error { 118 + tmp, err := os.CreateTemp("", "appherder-upgrade-*.appimage") 119 + if err != nil { 120 + return fmt.Errorf("create temporary file: %w", err) 121 + } 122 + tmpName := tmp.Name() 123 + defer os.Remove(tmpName) 124 + 125 + if err := download(ctx, rel.URL, tmp); err != nil { 126 + tmp.Close() 127 + return err 128 + } 129 + if err := tmp.Close(); err != nil { 130 + return fmt.Errorf("close download: %w", err) 131 + } 132 + 133 + if err := rel.verifyDownload(tmpName); err != nil { 134 + return err 135 + } 136 + 137 + _, err = a.Install(tmpName) 138 + return err 139 + } 140 + 141 + func download(ctx context.Context, url string, writer io.Writer) error { 142 + ctx, cancel := context.WithCancel(ctx) 143 + defer cancel() 144 + 145 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 146 + if err != nil { 147 + return err 148 + } 149 + resp, err := httpClient.Do(req) 150 + if err != nil { 151 + return fmt.Errorf("download %s: %w", url, err) 152 + } 153 + defer resp.Body.Close() 154 + if resp.StatusCode != http.StatusOK { 155 + return fmt.Errorf("download %s: %s", url, resp.Status) 156 + } 157 + _, err = io.Copy(writer, newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) 158 + return err 159 + }