A shepherd for your Appimages.
0

Configure Feed

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

reorg

Aly Raffauf (Jun 19, 2026, 4:03 AM EDT) 1ceb4b98 5a304cf1

+268 -262
+1 -5
cmd/appherder-gui/main.go
··· 14 14 var version = "dev" 15 15 16 16 func main() { 17 - app, err := appherder.NewApp() 18 - if err != nil { 19 - fmt.Fprintln(os.Stderr, err) 20 - os.Exit(1) 21 - } 17 + app := appherder.NewApp() 22 18 23 19 appID := "io.github.alyraffauf.AppHerder" 24 20 gtkApp := adw.NewApplication(appID, gio.ApplicationFlagsNone)
+1 -5
cmd/appherder/main.go
··· 10 10 var version = "dev" 11 11 12 12 func main() { 13 - app, err := appherder.NewApp() 14 - if err != nil { 15 - fmt.Fprintln(os.Stderr, err) 16 - os.Exit(1) 17 - } 13 + app := appherder.NewApp() 18 14 cmd := newRootCommand(app, os.Stdout, os.Stderr) 19 15 cmd.SetArgs(os.Args[1:]) 20 16 if err := cmd.Execute(); err != nil {
+2 -2
internal/appherder/app.go
··· 20 20 // NewApp returns an App wired to the current user's home directory and 21 21 // ~/.config/appherder/config.toml. The applications directory honors 22 22 // XDG_DATA_HOME. 23 - func NewApp() (App, error) { 23 + func NewApp() App { 24 24 cfg := loadConfig() 25 25 return NewAppWithDirs( 26 26 cfg.AppImagesDir, 27 27 filepath.Join(xdg.DataHome, "applications"), 28 28 filepath.Join(cfg.AppImagesDir, ".icons"), 29 29 cfg.BinDir, 30 - ).withConfig(cfg), nil 30 + ).withConfig(cfg) 31 31 } 32 32 33 33 // NewAppWithDirs returns an App that uses the given directories directly,
+30 -134
internal/appherder/appimage.go
··· 157 157 return 0, false 158 158 } 159 159 160 - func (a App) installAppImage(file string, appName string) (string, error) { 161 - if err := os.MkdirAll(a.appimagesDir, 0o755); err != nil { 162 - return "", fmt.Errorf("create AppImages directory %s: %w", a.appimagesDir, err) 160 + // listAppImages returns *.appimage files in dir, case-insensitive (the 161 + // AppImage spec uses .AppImage, but .appimage is common). 162 + func listAppImages(dir string) ([]string, error) { 163 + entries, err := os.ReadDir(dir) 164 + if err != nil { 165 + if os.IsNotExist(err) { 166 + return nil, nil 167 + } 168 + return nil, err 163 169 } 164 - 165 - dest := filepath.Join(a.appimagesDir, appName+".appimage") 166 - inFolder := samePath(filepath.Dir(file), a.appimagesDir) 167 - 168 - if !samePath(file, dest) { 169 - same, err := sameContent(file, dest) 170 - if err != nil { 171 - return "", fmt.Errorf("compare AppImage %s with %s: %w", file, dest, err) 170 + var files []string 171 + for _, entry := range entries { 172 + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { 173 + continue 172 174 } 173 - switch { 174 - case same: 175 - if inFolder { 176 - if err := os.Remove(file); err != nil { 177 - return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) 178 - } 179 - } 180 - case inFolder: 181 - if err := a.saveToVersions(dest, appName); err != nil { 182 - return "", err 183 - } 184 - if err := os.Rename(file, dest); err != nil { 185 - return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) 186 - } 187 - default: 188 - if err := a.saveToVersions(dest, appName); err != nil { 189 - return "", err 190 - } 191 - if err := writeAtomic(dest, 0o755, func(writer io.Writer) error { 192 - return copyTo(file, writer) 193 - }); err != nil { 194 - return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) 195 - } 175 + if !strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 176 + continue 196 177 } 197 - } 198 - 199 - if err := os.Chmod(dest, 0o755); err != nil { 200 - return "", fmt.Errorf("make AppImage executable %s: %w", dest, err) 201 - } 202 - return dest, nil 203 - } 204 - 205 - // samePath reports whether a and b resolve to the same file, accounting for 206 - // symlinks and mounts (e.g. /home -> /var/home). 207 - func samePath(pathA, pathB string) bool { 208 - infoA, err := os.Stat(pathA) 209 - if err != nil { 210 - return false 211 - } 212 - infoB, err := os.Stat(pathB) 213 - if err != nil { 214 - return false 215 - } 216 - return os.SameFile(infoA, infoB) 217 - } 218 - 219 - func copyTo(src string, dest io.Writer) error { 220 - in, err := os.Open(src) 221 - if err != nil { 222 - return fmt.Errorf("open source %s: %w", src, err) 223 - } 224 - defer in.Close() 225 - 226 - _, err = io.Copy(dest, in) 227 - return err 228 - } 229 - 230 - // saveToVersions hardlinks src into .versions/appName/<version>.appimage, 231 - // pruning older versions to keep at most MaxSavedVersions from config. 232 - func (a App) saveToVersions(src, appName string) error { 233 - if _, err := os.Stat(src); os.IsNotExist(err) { 234 - return nil 178 + files = append(files, filepath.Join(dir, entry.Name())) 235 179 } 236 - version := readAppImageVersion(src) 237 - versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) 238 - if err := os.MkdirAll(versionsDir, 0o755); err != nil { 239 - return fmt.Errorf("create versions directory: %w", err) 240 - } 241 - 242 - a.pruneVersions(versionsDir, a.config.MaxSavedVersions-1) 243 - 244 - dest := filepath.Join(versionsDir, version+".appimage") 245 - os.Remove(dest) 246 - if err := os.Link(src, dest); err != nil { 247 - return fmt.Errorf("save current version %s: %w", version, err) 248 - } 249 - return nil 180 + sort.Strings(files) 181 + return files, nil 250 182 } 251 183 252 - // pruneVersions removes the oldest saved versions when the directory holds 253 - // more than keep files, sorting by mtime. 254 - func (a App) pruneVersions(dir string, keep int) { 184 + // findAppImagePath returns the full path of <appid>.appimage in dir, matching 185 + // the extension case-insensitively, or "" when absent. 186 + func findAppImagePath(dir, appid string) (string, error) { 255 187 entries, err := os.ReadDir(dir) 256 188 if err != nil { 257 - return 258 - } 259 - var files []os.DirEntry 260 - for _, entry := range entries { 261 - if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 262 - files = append(files, entry) 189 + if os.IsNotExist(err) { 190 + return "", nil 263 191 } 192 + return "", err 264 193 } 265 - if len(files) <= keep { 266 - return 267 - } 268 - sort.Slice(files, func(i, j int) bool { 269 - infoI, errI := files[i].Info() 270 - infoJ, errJ := files[j].Info() 271 - if errI != nil || errJ != nil { 272 - return false 194 + for _, entry := range entries { 195 + if entry.IsDir() { 196 + continue 273 197 } 274 - return infoI.ModTime().Before(infoJ.ModTime()) 275 - }) 276 - for _, entry := range files[:len(files)-keep] { 277 - os.Remove(filepath.Join(dir, entry.Name())) 278 - } 279 - } 280 - 281 - // readAppImageVersion returns the embedded version string from the AppImage's 282 - // desktop file, falling back to the file's mtime. 283 - func readAppImageVersion(path string) string { 284 - if fsys, closeFs, err := openAppImage(context.Background(), path); err == nil { 285 - defer closeFs() 286 - if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { 287 - if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok && version != "" { 288 - return sanitizeVersionForFilename(version) 289 - } 198 + if strings.EqualFold(entry.Name(), appid+".appimage") { 199 + return filepath.Join(dir, entry.Name()), nil 290 200 } 291 201 } 292 - if info, err := os.Stat(path); err == nil { 293 - return info.ModTime().UTC().Format("2006-01-02T150405") 294 - } 295 - return "unknown" 296 - } 297 - 298 - // sanitizeVersionForFilename replaces path separators so the version string is 299 - // safe for a filename. 300 - func sanitizeVersionForFilename(version string) string { 301 - version = strings.ReplaceAll(version, "/", "_") 302 - version = strings.ReplaceAll(version, "\\", "_") 303 - if len(version) > 100 { 304 - version = version[:100] 305 - } 306 - return version 202 + return "", nil 307 203 }
+37
internal/appherder/http.go
··· 6 6 "io" 7 7 "net" 8 8 "net/http" 9 + "os" 9 10 "time" 10 11 ) 11 12 ··· 76 77 } 77 78 return resp, nil 78 79 } 80 + 81 + // downloadToTemp downloads url to a temporary file and returns its path. The 82 + // caller must remove the file. 83 + func downloadToTemp(ctx context.Context, url, prefix string, progress Progress, name string) (string, error) { 84 + tmp, err := os.CreateTemp("", prefix+"-*.appimage") 85 + if err != nil { 86 + return "", fmt.Errorf("create temporary file: %w", err) 87 + } 88 + tmpName := tmp.Name() 89 + if err := download(ctx, url, tmp, progress, name); err != nil { 90 + tmp.Close() 91 + os.Remove(tmpName) 92 + return "", err 93 + } 94 + if err := tmp.Close(); err != nil { 95 + os.Remove(tmpName) 96 + return "", fmt.Errorf("close download: %w", err) 97 + } 98 + return tmpName, nil 99 + } 100 + 101 + func download(ctx context.Context, url string, writer io.Writer, progress Progress, name string) error { 102 + ctx, cancel := context.WithCancel(ctx) 103 + defer cancel() 104 + 105 + resp, err := httpGetOK(ctx, url, fmt.Sprintf("download %s", url), nil) 106 + if err != nil { 107 + return err 108 + } 109 + defer resp.Body.Close() 110 + 111 + body := io.Reader(newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) 112 + body = newProgressReader(body, progress, name, resp.ContentLength) 113 + _, err = io.Copy(writer, body) 114 + return err 115 + }
+9
internal/appherder/install.go
··· 169 169 val, ok := desktop.Get(desktopEntrySection, "Terminal") 170 170 return ok && strings.EqualFold(val, "true") 171 171 } 172 + 173 + // NormalizeAppName strips directory and .appimage extension from name. 174 + func NormalizeAppName(name string) string { 175 + name = filepath.Base(name) 176 + if ext := filepath.Ext(name); strings.EqualFold(ext, ".appimage") { 177 + name = strings.TrimSuffix(name, ext) 178 + } 179 + return name 180 + }
+28
internal/appherder/parallel.go
··· 1 + package appherder 2 + 3 + import ( 4 + "context" 5 + "sync" 6 + ) 7 + 8 + // parallelMap applies fn to each item with at most limit concurrent calls, 9 + // returning results in input order. 10 + func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { 11 + if limit < 1 { 12 + limit = 1 13 + } 14 + results := make([]R, len(items)) 15 + sem := make(chan struct{}, limit) 16 + var wg sync.WaitGroup 17 + for i, item := range items { 18 + wg.Add(1) 19 + sem <- struct{}{} 20 + go func(i int, item T) { 21 + defer wg.Done() 22 + defer func() { <-sem }() 23 + results[i] = fn(ctx, item) 24 + }(i, item) 25 + } 26 + wg.Wait() 27 + return results 28 + }
-46
internal/appherder/sync.go
··· 5 5 "fmt" 6 6 "os" 7 7 "path/filepath" 8 - "sort" 9 8 "strings" 10 9 11 10 "github.com/alyraffauf/goxdgdesktop/desktopfile" ··· 95 94 return result, nil 96 95 } 97 96 98 - // listAppImages returns *.appimage files in dir, case-insensitive (the 99 - // AppImage spec uses .AppImage, but .appimage is common). 100 - func listAppImages(dir string) ([]string, error) { 101 - entries, err := os.ReadDir(dir) 102 - if err != nil { 103 - if os.IsNotExist(err) { 104 - return nil, nil 105 - } 106 - return nil, err 107 - } 108 - var files []string 109 - for _, entry := range entries { 110 - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { 111 - continue 112 - } 113 - if !strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 114 - continue 115 - } 116 - files = append(files, filepath.Join(dir, entry.Name())) 117 - } 118 - sort.Strings(files) 119 - return files, nil 120 - } 121 - 122 97 // appImagePresent reports whether <appid>.appimage exists in dir, matching the 123 98 // extension case-insensitively. 124 99 func appImagePresent(dir, appid string) (bool, error) { ··· 127 102 return false, err 128 103 } 129 104 return path != "", nil 130 - } 131 - 132 - // findAppImagePath returns the full path of <appid>.appimage in dir, matching 133 - // the extension case-insensitively, or "" when absent. 134 - func findAppImagePath(dir, appid string) (string, error) { 135 - entries, err := os.ReadDir(dir) 136 - if err != nil { 137 - if os.IsNotExist(err) { 138 - return "", nil 139 - } 140 - return "", err 141 - } 142 - for _, entry := range entries { 143 - if entry.IsDir() { 144 - continue 145 - } 146 - if strings.EqualFold(entry.Name(), appid+".appimage") { 147 - return filepath.Join(dir, entry.Name()), nil 148 - } 149 - } 150 - return "", nil 151 105 } 152 106 153 107 // appImageBackedOrphans returns appids of unmanaged desktop entries whose
-9
internal/appherder/uninstall.go
··· 31 31 return nil 32 32 } 33 33 34 - // NormalizeAppName strips directory and .appimage extension from name. 35 - func NormalizeAppName(name string) string { 36 - name = filepath.Base(name) 37 - if ext := filepath.Ext(name); strings.EqualFold(ext, ".appimage") { 38 - name = strings.TrimSuffix(name, ext) 39 - } 40 - return name 41 - } 42 - 43 34 func (a App) installedPaths(appName string) []string { 44 35 return []string{ 45 36 filepath.Join(a.appimagesDir, appName+".appimage"),
-61
internal/appherder/upgrade.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "fmt" 6 - "io" 7 5 "os" 8 6 "path/filepath" 9 7 "strings" 10 - "sync" 11 8 ) 12 9 13 10 // checkConcurrency caps concurrent update checks: enough to overlap network ··· 95 92 return UpgradeCheck{Name: name, Release: rel, Available: !current} 96 93 } 97 94 98 - // parallelMap applies fn to each item with at most `limit` concurrent calls, 99 - // returning results in input order. 100 - func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { 101 - if limit < 1 { 102 - limit = 1 103 - } 104 - results := make([]R, len(items)) 105 - sem := make(chan struct{}, limit) 106 - var wg sync.WaitGroup 107 - for i, item := range items { 108 - wg.Add(1) 109 - sem <- struct{}{} 110 - go func(i int, item T) { 111 - defer wg.Done() 112 - defer func() { <-sem }() 113 - results[i] = fn(ctx, item) 114 - }(i, item) 115 - } 116 - wg.Wait() 117 - return results 118 - } 119 - 120 95 func (a App) applyUpgrade(ctx context.Context, name string, rel Release) error { 121 96 tmpName, err := downloadToTemp(ctx, rel.URL, "appherder-upgrade", a.progress, name) 122 97 if err != nil { ··· 127 102 _, err = a.install(ctx, tmpName, rel.expectedChecksum()) 128 103 return err 129 104 } 130 - 131 - // downloadToTemp downloads url to a temporary file and returns its path. The 132 - // caller must remove the file. 133 - func downloadToTemp(ctx context.Context, url, prefix string, progress Progress, name string) (string, error) { 134 - tmp, err := os.CreateTemp("", prefix+"-*.appimage") 135 - if err != nil { 136 - return "", fmt.Errorf("create temporary file: %w", err) 137 - } 138 - tmpName := tmp.Name() 139 - if err := download(ctx, url, tmp, progress, name); err != nil { 140 - tmp.Close() 141 - os.Remove(tmpName) 142 - return "", err 143 - } 144 - if err := tmp.Close(); err != nil { 145 - os.Remove(tmpName) 146 - return "", fmt.Errorf("close download: %w", err) 147 - } 148 - return tmpName, nil 149 - } 150 - 151 - func download(ctx context.Context, url string, writer io.Writer, progress Progress, name string) error { 152 - ctx, cancel := context.WithCancel(ctx) 153 - defer cancel() 154 - 155 - resp, err := httpGetOK(ctx, url, fmt.Sprintf("download %s", url), nil) 156 - if err != nil { 157 - return err 158 - } 159 - defer resp.Body.Close() 160 - 161 - body := io.Reader(newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) 162 - body = newProgressReader(body, progress, name, resp.ContentLength) 163 - _, err = io.Copy(writer, body) 164 - return err 165 - }
+160
internal/appherder/versions.go
··· 1 + package appherder 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "sort" 10 + "strings" 11 + ) 12 + 13 + func (a App) installAppImage(file string, appName string) (string, error) { 14 + if err := os.MkdirAll(a.appimagesDir, 0o755); err != nil { 15 + return "", fmt.Errorf("create AppImages directory %s: %w", a.appimagesDir, err) 16 + } 17 + 18 + dest := filepath.Join(a.appimagesDir, appName+".appimage") 19 + inFolder := samePath(filepath.Dir(file), a.appimagesDir) 20 + 21 + if !samePath(file, dest) { 22 + same, err := sameContent(file, dest) 23 + if err != nil { 24 + return "", fmt.Errorf("compare AppImage %s with %s: %w", file, dest, err) 25 + } 26 + switch { 27 + case same: 28 + if inFolder { 29 + if err := os.Remove(file); err != nil { 30 + return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) 31 + } 32 + } 33 + case inFolder: 34 + if err := a.saveToVersions(dest, appName); err != nil { 35 + return "", err 36 + } 37 + if err := os.Rename(file, dest); err != nil { 38 + return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) 39 + } 40 + default: 41 + if err := a.saveToVersions(dest, appName); err != nil { 42 + return "", err 43 + } 44 + if err := writeAtomic(dest, 0o755, func(writer io.Writer) error { 45 + return copyTo(file, writer) 46 + }); err != nil { 47 + return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) 48 + } 49 + } 50 + } 51 + 52 + if err := os.Chmod(dest, 0o755); err != nil { 53 + return "", fmt.Errorf("make AppImage executable %s: %w", dest, err) 54 + } 55 + return dest, nil 56 + } 57 + 58 + // samePath reports whether a and b resolve to the same file, accounting for 59 + // symlinks and mounts (e.g. /home -> /var/home). 60 + func samePath(pathA, pathB string) bool { 61 + infoA, err := os.Stat(pathA) 62 + if err != nil { 63 + return false 64 + } 65 + infoB, err := os.Stat(pathB) 66 + if err != nil { 67 + return false 68 + } 69 + return os.SameFile(infoA, infoB) 70 + } 71 + 72 + func copyTo(src string, dest io.Writer) error { 73 + in, err := os.Open(src) 74 + if err != nil { 75 + return fmt.Errorf("open source %s: %w", src, err) 76 + } 77 + defer in.Close() 78 + 79 + _, err = io.Copy(dest, in) 80 + return err 81 + } 82 + 83 + // saveToVersions hardlinks src into .versions/appName/<version>.appimage, 84 + // pruning older versions to keep at most MaxSavedVersions from config. 85 + func (a App) saveToVersions(src, appName string) error { 86 + if _, err := os.Stat(src); os.IsNotExist(err) { 87 + return nil 88 + } 89 + version := readAppImageVersion(src) 90 + versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) 91 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 92 + return fmt.Errorf("create versions directory: %w", err) 93 + } 94 + 95 + a.pruneVersions(versionsDir, a.config.MaxSavedVersions-1) 96 + 97 + dest := filepath.Join(versionsDir, version+".appimage") 98 + os.Remove(dest) 99 + if err := os.Link(src, dest); err != nil { 100 + return fmt.Errorf("save current version %s: %w", version, err) 101 + } 102 + return nil 103 + } 104 + 105 + // pruneVersions removes the oldest saved versions when the directory holds 106 + // more than keep files, sorting by mtime. 107 + func (a App) pruneVersions(dir string, keep int) { 108 + entries, err := os.ReadDir(dir) 109 + if err != nil { 110 + return 111 + } 112 + var files []os.DirEntry 113 + for _, entry := range entries { 114 + if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 115 + files = append(files, entry) 116 + } 117 + } 118 + if len(files) <= keep { 119 + return 120 + } 121 + sort.Slice(files, func(i, j int) bool { 122 + infoI, errI := files[i].Info() 123 + infoJ, errJ := files[j].Info() 124 + if errI != nil || errJ != nil { 125 + return false 126 + } 127 + return infoI.ModTime().Before(infoJ.ModTime()) 128 + }) 129 + for _, entry := range files[:len(files)-keep] { 130 + os.Remove(filepath.Join(dir, entry.Name())) 131 + } 132 + } 133 + 134 + // readAppImageVersion returns the embedded version string from the AppImage's 135 + // desktop file, falling back to the file's mtime. 136 + func readAppImageVersion(path string) string { 137 + if fsys, closeFs, err := openAppImage(context.Background(), path); err == nil { 138 + defer closeFs() 139 + if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { 140 + if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok && version != "" { 141 + return sanitizeVersionForFilename(version) 142 + } 143 + } 144 + } 145 + if info, err := os.Stat(path); err == nil { 146 + return info.ModTime().UTC().Format("2006-01-02T150405") 147 + } 148 + return "unknown" 149 + } 150 + 151 + // sanitizeVersionForFilename replaces path separators so the version string is 152 + // safe for a filename. 153 + func sanitizeVersionForFilename(version string) string { 154 + version = strings.ReplaceAll(version, "/", "_") 155 + version = strings.ReplaceAll(version, "\\", "_") 156 + if len(version) > 100 { 157 + version = version[:100] 158 + } 159 + return version 160 + }