From 1ceb4b981e7d991334f29daf09b1dd54c5071280 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Fri, 19 Jun 2026 04:03:22 -0400 Subject: [PATCH] reorg --- cmd/appherder-gui/main.go | 6 +- cmd/appherder/main.go | 6 +- internal/appherder/app.go | 4 +- internal/appherder/appimage.go | 164 ++++++-------------------------- internal/appherder/http.go | 37 +++++++ internal/appherder/install.go | 9 ++ internal/appherder/parallel.go | 28 ++++++ internal/appherder/sync.go | 46 --------- internal/appherder/uninstall.go | 9 -- internal/appherder/upgrade.go | 61 ------------ internal/appherder/versions.go | 160 +++++++++++++++++++++++++++++++ 11 files changed, 268 insertions(+), 262 deletions(-) create mode 100644 internal/appherder/parallel.go create mode 100644 internal/appherder/versions.go diff --git a/cmd/appherder-gui/main.go b/cmd/appherder-gui/main.go index 4e3f835..fa1591e 100644 --- a/cmd/appherder-gui/main.go +++ b/cmd/appherder-gui/main.go @@ -14,11 +14,7 @@ import ( var version = "dev" func main() { - app, err := appherder.NewApp() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + app := appherder.NewApp() appID := "io.github.alyraffauf.AppHerder" gtkApp := adw.NewApplication(appID, gio.ApplicationFlagsNone) diff --git a/cmd/appherder/main.go b/cmd/appherder/main.go index 0ee02b0..8a8a613 100644 --- a/cmd/appherder/main.go +++ b/cmd/appherder/main.go @@ -10,11 +10,7 @@ import ( var version = "dev" func main() { - app, err := appherder.NewApp() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + app := appherder.NewApp() cmd := newRootCommand(app, os.Stdout, os.Stderr) cmd.SetArgs(os.Args[1:]) if err := cmd.Execute(); err != nil { diff --git a/internal/appherder/app.go b/internal/appherder/app.go index 6f3fae6..08b7c8a 100644 --- a/internal/appherder/app.go +++ b/internal/appherder/app.go @@ -20,14 +20,14 @@ type App struct { // NewApp returns an App wired to the current user's home directory and // ~/.config/appherder/config.toml. The applications directory honors // XDG_DATA_HOME. -func NewApp() (App, error) { +func NewApp() App { cfg := loadConfig() return NewAppWithDirs( cfg.AppImagesDir, filepath.Join(xdg.DataHome, "applications"), filepath.Join(cfg.AppImagesDir, ".icons"), cfg.BinDir, - ).withConfig(cfg), nil + ).withConfig(cfg) } // NewAppWithDirs returns an App that uses the given directories directly, diff --git a/internal/appherder/appimage.go b/internal/appherder/appimage.go index a9323f7..5e84fea 100644 --- a/internal/appherder/appimage.go +++ b/internal/appherder/appimage.go @@ -157,151 +157,47 @@ func scanForSquashFS(file readerAt, offset int64) (int64, bool) { return 0, false } -func (a App) installAppImage(file string, appName string) (string, error) { - if err := os.MkdirAll(a.appimagesDir, 0o755); err != nil { - return "", fmt.Errorf("create AppImages directory %s: %w", a.appimagesDir, err) +// listAppImages returns *.appimage files in dir, case-insensitive (the +// AppImage spec uses .AppImage, but .appimage is common). +func listAppImages(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err } - - dest := filepath.Join(a.appimagesDir, appName+".appimage") - inFolder := samePath(filepath.Dir(file), a.appimagesDir) - - if !samePath(file, dest) { - same, err := sameContent(file, dest) - if err != nil { - return "", fmt.Errorf("compare AppImage %s with %s: %w", file, dest, err) + var files []string + for _, entry := range entries { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue } - switch { - case same: - if inFolder { - if err := os.Remove(file); err != nil { - return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) - } - } - case inFolder: - if err := a.saveToVersions(dest, appName); err != nil { - return "", err - } - if err := os.Rename(file, dest); err != nil { - return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) - } - default: - if err := a.saveToVersions(dest, appName); err != nil { - return "", err - } - if err := writeAtomic(dest, 0o755, func(writer io.Writer) error { - return copyTo(file, writer) - }); err != nil { - return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) - } + if !strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { + continue } + files = append(files, filepath.Join(dir, entry.Name())) } - - if err := os.Chmod(dest, 0o755); err != nil { - return "", fmt.Errorf("make AppImage executable %s: %w", dest, err) - } - return dest, nil + sort.Strings(files) + return files, nil } -// samePath reports whether a and b resolve to the same file, accounting for -// symlinks and mounts (e.g. /home -> /var/home). -func samePath(pathA, pathB string) bool { - infoA, err := os.Stat(pathA) - if err != nil { - return false - } - infoB, err := os.Stat(pathB) - if err != nil { - return false - } - return os.SameFile(infoA, infoB) -} - -func copyTo(src string, dest io.Writer) error { - in, err := os.Open(src) - if err != nil { - return fmt.Errorf("open source %s: %w", src, err) - } - defer in.Close() - - _, err = io.Copy(dest, in) - return err -} - -// saveToVersions hardlinks src into .versions/appName/.appimage, -// pruning older versions to keep at most MaxSavedVersions from config. -func (a App) saveToVersions(src, appName string) error { - if _, err := os.Stat(src); os.IsNotExist(err) { - return nil - } - version := readAppImageVersion(src) - versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) - if err := os.MkdirAll(versionsDir, 0o755); err != nil { - return fmt.Errorf("create versions directory: %w", err) - } - - a.pruneVersions(versionsDir, a.config.MaxSavedVersions-1) - - dest := filepath.Join(versionsDir, version+".appimage") - os.Remove(dest) - if err := os.Link(src, dest); err != nil { - return fmt.Errorf("save current version %s: %w", version, err) - } - return nil -} - -// pruneVersions removes the oldest saved versions when the directory holds -// more than keep files, sorting by mtime. -func (a App) pruneVersions(dir string, keep int) { +// findAppImagePath returns the full path of .appimage in dir, matching +// the extension case-insensitively, or "" when absent. +func findAppImagePath(dir, appid string) (string, error) { entries, err := os.ReadDir(dir) if err != nil { - return - } - var files []os.DirEntry - for _, entry := range entries { - if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { - files = append(files, entry) + if os.IsNotExist(err) { + return "", nil } + return "", err } - if len(files) <= keep { - return - } - sort.Slice(files, func(i, j int) bool { - infoI, errI := files[i].Info() - infoJ, errJ := files[j].Info() - if errI != nil || errJ != nil { - return false + for _, entry := range entries { + if entry.IsDir() { + continue } - return infoI.ModTime().Before(infoJ.ModTime()) - }) - for _, entry := range files[:len(files)-keep] { - os.Remove(filepath.Join(dir, entry.Name())) - } -} - -// readAppImageVersion returns the embedded version string from the AppImage's -// desktop file, falling back to the file's mtime. -func readAppImageVersion(path string) string { - if fsys, closeFs, err := openAppImage(context.Background(), path); err == nil { - defer closeFs() - if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { - if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok && version != "" { - return sanitizeVersionForFilename(version) - } + if strings.EqualFold(entry.Name(), appid+".appimage") { + return filepath.Join(dir, entry.Name()), nil } } - if info, err := os.Stat(path); err == nil { - return info.ModTime().UTC().Format("2006-01-02T150405") - } - return "unknown" -} - -// sanitizeVersionForFilename replaces path separators so the version string is -// safe for a filename. -func sanitizeVersionForFilename(version string) string { - version = strings.ReplaceAll(version, "/", "_") - version = strings.ReplaceAll(version, "\\", "_") - if len(version) > 100 { - version = version[:100] - } - return version + return "", nil } diff --git a/internal/appherder/http.go b/internal/appherder/http.go index 3282920..ff7f474 100644 --- a/internal/appherder/http.go +++ b/internal/appherder/http.go @@ -6,6 +6,7 @@ import ( "io" "net" "net/http" + "os" "time" ) @@ -76,3 +77,39 @@ func httpGetOK(ctx context.Context, url, desc string, customize func(*http.Reque } return resp, nil } + +// downloadToTemp downloads url to a temporary file and returns its path. The +// caller must remove the file. +func downloadToTemp(ctx context.Context, url, prefix string, progress Progress, name string) (string, error) { + tmp, err := os.CreateTemp("", prefix+"-*.appimage") + if err != nil { + return "", fmt.Errorf("create temporary file: %w", err) + } + tmpName := tmp.Name() + if err := download(ctx, url, tmp, progress, name); err != nil { + tmp.Close() + os.Remove(tmpName) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return "", fmt.Errorf("close download: %w", err) + } + return tmpName, nil +} + +func download(ctx context.Context, url string, writer io.Writer, progress Progress, name string) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + resp, err := httpGetOK(ctx, url, fmt.Sprintf("download %s", url), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + body := io.Reader(newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) + body = newProgressReader(body, progress, name, resp.ContentLength) + _, err = io.Copy(writer, body) + return err +} diff --git a/internal/appherder/install.go b/internal/appherder/install.go index 0b3a933..dcb2801 100644 --- a/internal/appherder/install.go +++ b/internal/appherder/install.go @@ -169,3 +169,12 @@ func isTerminalApp(desktop *desktopfile.File) bool { val, ok := desktop.Get(desktopEntrySection, "Terminal") return ok && strings.EqualFold(val, "true") } + +// NormalizeAppName strips directory and .appimage extension from name. +func NormalizeAppName(name string) string { + name = filepath.Base(name) + if ext := filepath.Ext(name); strings.EqualFold(ext, ".appimage") { + name = strings.TrimSuffix(name, ext) + } + return name +} diff --git a/internal/appherder/parallel.go b/internal/appherder/parallel.go new file mode 100644 index 0000000..2525668 --- /dev/null +++ b/internal/appherder/parallel.go @@ -0,0 +1,28 @@ +package appherder + +import ( + "context" + "sync" +) + +// parallelMap applies fn to each item with at most limit concurrent calls, +// returning results in input order. +func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { + if limit < 1 { + limit = 1 + } + results := make([]R, len(items)) + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + for i, item := range items { + wg.Add(1) + sem <- struct{}{} + go func(i int, item T) { + defer wg.Done() + defer func() { <-sem }() + results[i] = fn(ctx, item) + }(i, item) + } + wg.Wait() + return results +} diff --git a/internal/appherder/sync.go b/internal/appherder/sync.go index 5ededc2..d35aff3 100644 --- a/internal/appherder/sync.go +++ b/internal/appherder/sync.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/alyraffauf/goxdgdesktop/desktopfile" @@ -95,30 +94,6 @@ func (a App) Sync(ctx context.Context, force bool) (SyncResult, error) { return result, nil } -// listAppImages returns *.appimage files in dir, case-insensitive (the -// AppImage spec uses .AppImage, but .appimage is common). -func listAppImages(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var files []string - for _, entry := range entries { - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - if !strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { - continue - } - files = append(files, filepath.Join(dir, entry.Name())) - } - sort.Strings(files) - return files, nil -} - // appImagePresent reports whether .appimage exists in dir, matching the // extension case-insensitively. func appImagePresent(dir, appid string) (bool, error) { @@ -129,27 +104,6 @@ func appImagePresent(dir, appid string) (bool, error) { return path != "", nil } -// findAppImagePath returns the full path of .appimage in dir, matching -// the extension case-insensitively, or "" when absent. -func findAppImagePath(dir, appid string) (string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - if strings.EqualFold(entry.Name(), appid+".appimage") { - return filepath.Join(dir, entry.Name()), nil - } - } - return "", nil -} - // appImageBackedOrphans returns appids of unmanaged desktop entries whose // TryExec or Exec points at a missing file inside appimagesDir; launchers left // by another tool after their AppImage was deleted. diff --git a/internal/appherder/uninstall.go b/internal/appherder/uninstall.go index b5c947e..60cb360 100644 --- a/internal/appherder/uninstall.go +++ b/internal/appherder/uninstall.go @@ -31,15 +31,6 @@ func (a App) Uninstall(name string, force bool) error { return nil } -// NormalizeAppName strips directory and .appimage extension from name. -func NormalizeAppName(name string) string { - name = filepath.Base(name) - if ext := filepath.Ext(name); strings.EqualFold(ext, ".appimage") { - name = strings.TrimSuffix(name, ext) - } - return name -} - func (a App) installedPaths(appName string) []string { return []string{ filepath.Join(a.appimagesDir, appName+".appimage"), diff --git a/internal/appherder/upgrade.go b/internal/appherder/upgrade.go index 8806069..f6be71a 100644 --- a/internal/appherder/upgrade.go +++ b/internal/appherder/upgrade.go @@ -2,12 +2,9 @@ package appherder import ( "context" - "fmt" - "io" "os" "path/filepath" "strings" - "sync" ) // checkConcurrency caps concurrent update checks: enough to overlap network @@ -95,28 +92,6 @@ func (a App) checkOne(ctx context.Context, file string) UpgradeCheck { return UpgradeCheck{Name: name, Release: rel, Available: !current} } -// parallelMap applies fn to each item with at most `limit` concurrent calls, -// returning results in input order. -func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) R) []R { - if limit < 1 { - limit = 1 - } - results := make([]R, len(items)) - sem := make(chan struct{}, limit) - var wg sync.WaitGroup - for i, item := range items { - wg.Add(1) - sem <- struct{}{} - go func(i int, item T) { - defer wg.Done() - defer func() { <-sem }() - results[i] = fn(ctx, item) - }(i, item) - } - wg.Wait() - return results -} - func (a App) applyUpgrade(ctx context.Context, name string, rel Release) error { tmpName, err := downloadToTemp(ctx, rel.URL, "appherder-upgrade", a.progress, name) if err != nil { @@ -127,39 +102,3 @@ func (a App) applyUpgrade(ctx context.Context, name string, rel Release) error { _, err = a.install(ctx, tmpName, rel.expectedChecksum()) return err } - -// downloadToTemp downloads url to a temporary file and returns its path. The -// caller must remove the file. -func downloadToTemp(ctx context.Context, url, prefix string, progress Progress, name string) (string, error) { - tmp, err := os.CreateTemp("", prefix+"-*.appimage") - if err != nil { - return "", fmt.Errorf("create temporary file: %w", err) - } - tmpName := tmp.Name() - if err := download(ctx, url, tmp, progress, name); err != nil { - tmp.Close() - os.Remove(tmpName) - return "", err - } - if err := tmp.Close(); err != nil { - os.Remove(tmpName) - return "", fmt.Errorf("close download: %w", err) - } - return tmpName, nil -} - -func download(ctx context.Context, url string, writer io.Writer, progress Progress, name string) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - resp, err := httpGetOK(ctx, url, fmt.Sprintf("download %s", url), nil) - if err != nil { - return err - } - defer resp.Body.Close() - - body := io.Reader(newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) - body = newProgressReader(body, progress, name, resp.ContentLength) - _, err = io.Copy(writer, body) - return err -} diff --git a/internal/appherder/versions.go b/internal/appherder/versions.go new file mode 100644 index 0000000..790fd9c --- /dev/null +++ b/internal/appherder/versions.go @@ -0,0 +1,160 @@ +package appherder + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +func (a App) installAppImage(file string, appName string) (string, error) { + if err := os.MkdirAll(a.appimagesDir, 0o755); err != nil { + return "", fmt.Errorf("create AppImages directory %s: %w", a.appimagesDir, err) + } + + dest := filepath.Join(a.appimagesDir, appName+".appimage") + inFolder := samePath(filepath.Dir(file), a.appimagesDir) + + if !samePath(file, dest) { + same, err := sameContent(file, dest) + if err != nil { + return "", fmt.Errorf("compare AppImage %s with %s: %w", file, dest, err) + } + switch { + case same: + if inFolder { + if err := os.Remove(file); err != nil { + return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) + } + } + case inFolder: + if err := a.saveToVersions(dest, appName); err != nil { + return "", err + } + if err := os.Rename(file, dest); err != nil { + return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) + } + default: + if err := a.saveToVersions(dest, appName); err != nil { + return "", err + } + if err := writeAtomic(dest, 0o755, func(writer io.Writer) error { + return copyTo(file, writer) + }); err != nil { + return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) + } + } + } + + if err := os.Chmod(dest, 0o755); err != nil { + return "", fmt.Errorf("make AppImage executable %s: %w", dest, err) + } + return dest, nil +} + +// samePath reports whether a and b resolve to the same file, accounting for +// symlinks and mounts (e.g. /home -> /var/home). +func samePath(pathA, pathB string) bool { + infoA, err := os.Stat(pathA) + if err != nil { + return false + } + infoB, err := os.Stat(pathB) + if err != nil { + return false + } + return os.SameFile(infoA, infoB) +} + +func copyTo(src string, dest io.Writer) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source %s: %w", src, err) + } + defer in.Close() + + _, err = io.Copy(dest, in) + return err +} + +// saveToVersions hardlinks src into .versions/appName/.appimage, +// pruning older versions to keep at most MaxSavedVersions from config. +func (a App) saveToVersions(src, appName string) error { + if _, err := os.Stat(src); os.IsNotExist(err) { + return nil + } + version := readAppImageVersion(src) + versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) + if err := os.MkdirAll(versionsDir, 0o755); err != nil { + return fmt.Errorf("create versions directory: %w", err) + } + + a.pruneVersions(versionsDir, a.config.MaxSavedVersions-1) + + dest := filepath.Join(versionsDir, version+".appimage") + os.Remove(dest) + if err := os.Link(src, dest); err != nil { + return fmt.Errorf("save current version %s: %w", version, err) + } + return nil +} + +// pruneVersions removes the oldest saved versions when the directory holds +// more than keep files, sorting by mtime. +func (a App) pruneVersions(dir string, keep int) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + var files []os.DirEntry + for _, entry := range entries { + if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { + files = append(files, entry) + } + } + if len(files) <= keep { + return + } + sort.Slice(files, func(i, j int) bool { + infoI, errI := files[i].Info() + infoJ, errJ := files[j].Info() + if errI != nil || errJ != nil { + return false + } + return infoI.ModTime().Before(infoJ.ModTime()) + }) + for _, entry := range files[:len(files)-keep] { + os.Remove(filepath.Join(dir, entry.Name())) + } +} + +// readAppImageVersion returns the embedded version string from the AppImage's +// desktop file, falling back to the file's mtime. +func readAppImageVersion(path string) string { + if fsys, closeFs, err := openAppImage(context.Background(), path); err == nil { + defer closeFs() + if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { + if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok && version != "" { + return sanitizeVersionForFilename(version) + } + } + } + if info, err := os.Stat(path); err == nil { + return info.ModTime().UTC().Format("2006-01-02T150405") + } + return "unknown" +} + +// sanitizeVersionForFilename replaces path separators so the version string is +// safe for a filename. +func sanitizeVersionForFilename(version string) string { + version = strings.ReplaceAll(version, "/", "_") + version = strings.ReplaceAll(version, "\\", "_") + if len(version) > 100 { + version = version[:100] + } + return version +} -- 2.51.2