diff --git a/README.md b/README.md index 3345a0d..0391c8f 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ Some AppImages are signed by their publisher. The first time AppHerder installs `appherder list` shows each app's status in the **SIGNATURE** column: `pinned` (key locked in), `signed` (carries a signature appherder hasn't pinned yet), or `none`. +## Configuration + +AppHerder's directories and update sources can be customized via `~/.config/appherder/config.toml`. See [Configuration](docs/Configuration.md) for all options. + ## Under the hood AppHerder reads the AppImage's filesystem to grab its icon and desktop entry. SquashFS images are parsed in-process. DwarFS images use the system dwarfsextract tool, falling back to the AppImage's own runtime when the tool isn't available. Everything it writes is tagged, so uninstall and sync only touch its own files. diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..5a48752 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,91 @@ +# Configuration + +AppHerder reads `~/.config/appherder/config.toml` on startup. Missing or malformed keys fall back to defaults. + +## Example + +```toml +appimages_dir = "/data/AppImages" +max_saved_versions = 5 +bin_dir = "/usr/local/bin" + +[sources.firefox] +type = "github" +owner = "mozilla" +repo = "geckodriver" +tag = "latest" +pattern = "Firefox-*.AppImage" + +[sources.librewolf] +type = "gitlab" +host = "gitlab.com" +project = "librewolf-community/browser/appimage" +tag = "latest" +pattern = "LibreWolf-*.AppImage" + +[sources.myapp] +type = "static" +url = "https://example.com/MyApp-latest.AppImage" +``` + +## Settings + +| Key | Type | Default | Description | +|---|---|---|---| +| `appimages_dir` | string | `~/AppImages` | Directory where AppImages live. | +| `max_saved_versions` | int | `3` | Number of prior versions kept for rollback. | +| `bin_dir` | string | `~/.local/bin` | Directory for `appherder link` symlinks. | + +## Source overrides + +The `[sources]` table overrides the update source for an app, taking priority over the embedded `.upd_info` ELF section. The key is the app name (the filename without `.appimage`). + +### github + +GitHub Releases. + +```toml +[sources.appname] +type = "github" +owner = "owner" +repo = "repo" +tag = "latest" +pattern = "*-x86_64.AppImage" +``` + +Set `GH_TOKEN` or `GITHUB_TOKEN` in the environment for higher API rate limits. + +### gitlab + +GitLab Releases (gitlab.com or self-hosted). + +```toml +[sources.appname] +type = "gitlab" +host = "gitlab.com" +project = "group/project" +tag = "latest" +pattern = "*-x86_64.AppImage" +``` + +Set `GL_TOKEN` or `GITLAB_TOKEN` in the environment for higher API rate limits. + +### static + +A fixed URL that always serves the latest AppImage. + +```toml +[sources.appname] +type = "static" +url = "https://example.com/App-latest.AppImage" +``` + +### zsync + +A zsync control file at a fixed URL. + +```toml +[sources.appname] +type = "zsync" +url = "https://example.com/App-latest.AppImage.zsync" +``` diff --git a/internal/appherder/config.go b/internal/appherder/config.go index 67bab8e..3f67971 100644 --- a/internal/appherder/config.go +++ b/internal/appherder/config.go @@ -10,9 +10,46 @@ import ( ) type Config struct { - AppImagesDir string `toml:"appimages_dir"` - MaxSavedVersions int `toml:"max_saved_versions"` - BinDir string `toml:"bin_dir"` + AppImagesDir string `toml:"appimages_dir"` + MaxSavedVersions int `toml:"max_saved_versions"` + BinDir string `toml:"bin_dir"` + Sources map[string]SourceConfig `toml:"sources"` +} + +type SourceConfig struct { + Type string `toml:"type"` + Owner string `toml:"owner"` + Repo string `toml:"repo"` + Host string `toml:"host"` + Project string `toml:"project"` + Tag string `toml:"tag"` + Pattern string `toml:"pattern"` + URL string `toml:"url"` +} + +func (sc SourceConfig) ToSource() (Source, error) { + switch sc.Type { + case "github": + return githubReleaseSource{ + owner: sc.Owner, + repo: sc.Repo, + tag: sc.Tag, + pattern: sc.Pattern, + }, nil + case "gitlab": + return gitlabReleaseSource{ + host: sc.Host, + project: sc.Project, + tag: sc.Tag, + pattern: sc.Pattern, + }, nil + case "zsync": + return zsyncURLSource{url: sc.URL}, nil + case "static": + return staticURLSource{url: sc.URL}, nil + default: + return nil, fmt.Errorf("unknown source type %q (expected github, gitlab, zsync, or static)", sc.Type) + } } func configPath() string { diff --git a/internal/appherder/list.go b/internal/appherder/list.go index 66804f1..f9ff80b 100644 --- a/internal/appherder/list.go +++ b/internal/appherder/list.go @@ -31,19 +31,17 @@ func (a App) List() ([]AppInfo, error) { infos := make([]AppInfo, 0, len(appids)) for _, appid := range appids { - infos = append(infos, gatherAppInfo(a.applicationsDir, a.appimagesDir, appid)) + infos = append(infos, a.gatherAppInfo(appid)) } sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) return infos, nil } -// gatherAppInfo collects display metadata for appid from its installed desktop -// file and AppImage. -func gatherAppInfo(appsDir, appimagesDir, appid string) AppInfo { +func (a App) gatherAppInfo(appid string) AppInfo { info := AppInfo{AppID: appid, Source: "none"} var pinned string - if desktop, err := desktopfile.Read(filepath.Join(appsDir, appid+".desktop")); err == nil { + if desktop, err := desktopfile.Read(filepath.Join(a.applicationsDir, appid+".desktop")); err == nil { if name, ok := desktop.Get(desktopEntrySection, "Name"); ok && name != "" { info.Name = name } @@ -57,13 +55,13 @@ func gatherAppInfo(appsDir, appimagesDir, appid string) AppInfo { } var signed bool - if path, err := findAppImagePath(appimagesDir, appid); err == nil && path != "" { + if path, err := findAppImagePath(a.appimagesDir, appid); err == nil && path != "" { info.Path = path info.Filename = filepath.Base(path) if stat, err := os.Stat(path); err == nil { info.Size = stat.Size() } - if src, err := SourceForAppImage(path); err == nil && src != nil { + if src, err := a.SourceForAppImage(path); err == nil && src != nil { info.Source = src.Kind() } signed = appImageSigned(path) diff --git a/internal/appherder/source.go b/internal/appherder/source.go index d836af8..2f0773a 100644 --- a/internal/appherder/source.go +++ b/internal/appherder/source.go @@ -12,6 +12,7 @@ import ( "io" "os" "path" + "path/filepath" "sort" "strings" "time" @@ -97,9 +98,20 @@ func ReadUpdateInfo(path string) (string, error) { return string(data), nil } -// SourceForAppImage resolves an update source from the AppImage's embedded -// update info. It returns (nil, nil) when the AppImage carries none. -func SourceForAppImage(file string) (Source, error) { +// SourceForAppImage resolves an update source for the given AppImage, checking +// config.toml's [sources] table first, then falling back to the embedded +// .upd_info ELF section. Returns (nil, nil) when no source is configured. +func (a App) SourceForAppImage(file string) (Source, error) { + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + if sc, ok := a.config.Sources[name]; ok { + return sc.ToSource() + } + return sourceFromELF(file) +} + +// sourceFromELF reads the AppImage's embedded .upd_info ELF section and +// returns a source, or (nil, nil) when the AppImage carries none. +func sourceFromELF(file string) (Source, error) { info, err := ReadUpdateInfo(file) if err != nil { return nil, err diff --git a/internal/appherder/upgrade.go b/internal/appherder/upgrade.go index 9840b8f..d48a772 100644 --- a/internal/appherder/upgrade.go +++ b/internal/appherder/upgrade.go @@ -50,9 +50,7 @@ func (a App) CheckUpgrades(ctx context.Context) ([]UpgradeCheck, error) { } } - return parallelMap(ctx, managed, checkConcurrency, func(ctx context.Context, file string) UpgradeCheck { - return checkOne(ctx, file) - }), nil + return parallelMap(ctx, managed, checkConcurrency, a.checkOne), nil } // ApplyUpgrades downloads and installs updates for the given checks, processing @@ -74,11 +72,10 @@ func (a App) ApplyUpgrades(ctx context.Context, checks []UpgradeCheck) []Upgrade return applied } -// checkOne resolves an AppImage's source and reports whether an update exists. -func checkOne(ctx context.Context, file string) UpgradeCheck { +func (a App) checkOne(ctx context.Context, file string) UpgradeCheck { name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) - src, err := SourceForAppImage(file) + src, err := a.SourceForAppImage(file) if err != nil { return UpgradeCheck{Name: name, Err: err} }