From 92e46aa5acc48ddf62e718380dd0ffe16bb12412 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Fri, 19 Jun 2026 13:09:14 -0400 Subject: [PATCH] preserve app icon extensions --- internal/appherder/desktop.go | 6 +-- internal/appherder/desktop_test.go | 11 ++--- internal/appherder/icons.go | 65 ++++++++++++++++++++++++++++-- internal/appherder/icons_test.go | 59 +++++++++++++++++++++++++++ internal/appherder/install.go | 17 ++++++-- internal/appherder/uninstall.go | 10 +++++ 6 files changed, 153 insertions(+), 15 deletions(-) diff --git a/internal/appherder/desktop.go b/internal/appherder/desktop.go index b78f2b4..55be894 100644 --- a/internal/appherder/desktop.go +++ b/internal/appherder/desktop.go @@ -55,12 +55,12 @@ func managedApps(applicationsDir string) ([]string, error) { return apps, nil } -func (a App) patchDesktopFile(desktop *desktopfile.File, appName string, hasIcon bool) error { +func (a App) patchDesktopFile(desktop *desktopfile.File, appName string, iconPath string) error { appimage := filepath.Join(a.appimagesDir, appName+".appimage") desktop.Set(desktopEntrySection, desktopOwnerKey, "true") - if hasIcon { - desktop.Set(desktopEntrySection, "Icon", filepath.Join(a.iconsDir, appName)) + if iconPath != "" { + desktop.Set(desktopEntrySection, "Icon", iconPath) } desktop.Set(desktopEntrySection, "TryExec", appimage) diff --git a/internal/appherder/desktop_test.go b/internal/appherder/desktop_test.go index 2c35cf9..5957f8b 100644 --- a/internal/appherder/desktop_test.go +++ b/internal/appherder/desktop_test.go @@ -25,15 +25,16 @@ Exec=upstream-app --private-window %U ` func TestPatchDesktopFilePreservesDesktopActions(t *testing.T) { - a, home := newTestApp(t) + app, home := newTestApp(t) desktop := desktopfile.Parse([]byte(sampleDesktopFile)) - if err := a.patchDesktopFile(desktop, "example", true); err != nil { + iconPath := filepath.Join(home, "AppImages", ".icons", "example.png") + if err := app.patchDesktopFile(desktop, "example", iconPath); err != nil { t.Fatal(err) } assertDesktopValue(t, desktop, desktopEntrySection, desktopOwnerKey, "true") - assertDesktopValue(t, desktop, desktopEntrySection, "Icon", filepath.Join(home, "AppImages", ".icons", "example")) + assertDesktopValue(t, desktop, desktopEntrySection, "Icon", iconPath) assertDesktopValue(t, desktop, desktopEntrySection, "TryExec", filepath.Join(home, "AppImages", "example.appimage")) assertDesktopExec(t, desktop, desktopEntrySection, []string{"env", "FOO=bar", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "%U"}) assertDesktopExec(t, desktop, "Desktop Action new-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--new-window", "%U"}) @@ -96,11 +97,11 @@ func TestDeriveAppName(t *testing.T) { } func TestPatchDesktopFileSetsExecWhenMissing(t *testing.T) { - a, home := newTestApp(t) + app, home := newTestApp(t) desktop := desktopfile.Parse([]byte( "[Desktop Entry]\nType=Application\nName=Foo\nTerminal=true\n", )) - if err := a.patchDesktopFile(desktop, "foo", false); err != nil { + if err := app.patchDesktopFile(desktop, "foo", ""); err != nil { t.Fatal(err) } assertDesktopValue(t, desktop, desktopEntrySection, "Terminal", "true") diff --git a/internal/appherder/icons.go b/internal/appherder/icons.go index ffa7336..2576011 100644 --- a/internal/appherder/icons.go +++ b/internal/appherder/icons.go @@ -1,6 +1,7 @@ package appherder import ( + "bytes" "fmt" "io/fs" "os" @@ -78,14 +79,70 @@ func iconRank(name string) int { } } +type iconInstall struct { + source string + path string + content []byte +} + func (a App) installIcon(fsys fs.FS, icon string, appName string) (string, error) { + preparedIcon, err := a.prepareIconInstall(fsys, icon, appName) + if err != nil { + return "", err + } + return a.installPreparedIcon(preparedIcon, appName) +} + +func (a App) prepareIconInstall(fsys fs.FS, icon string, appName string) (iconInstall, error) { + content, err := fs.ReadFile(fsys, icon) + if err != nil { + return iconInstall{}, fmt.Errorf("read icon %s: %w", icon, err) + } + ext := iconExt(icon, content) + if ext == "" { + return iconInstall{}, fmt.Errorf("unsupported icon format %s", icon) + } + return iconInstall{ + source: icon, + path: filepath.Join(a.iconsDir, appName+ext), + content: content, + }, nil +} + +func (a App) installPreparedIcon(preparedIcon iconInstall, appName string) (string, error) { if err := os.MkdirAll(a.iconsDir, 0o755); err != nil { return "", fmt.Errorf("create icon directory %s: %w", a.iconsDir, err) } + if err := writeIfChanged(preparedIcon.path, 0o644, preparedIcon.content); err != nil { + return "", fmt.Errorf("install icon %s to %s: %w", preparedIcon.source, preparedIcon.path, err) + } + for _, path := range a.installedIconPaths(appName) { + if path != preparedIcon.path { + _ = os.Remove(path) + } + } + _ = os.Remove(filepath.Join(a.iconsDir, appName)) + return preparedIcon.path, nil +} - dest := filepath.Join(a.iconsDir, appName) - if err := copyFromFS(fsys, icon, dest); err != nil { - return "", fmt.Errorf("install icon %s to %s: %w", icon, dest, err) +func iconExt(name string, content []byte) string { + switch strings.ToLower(path.Ext(name)) { + case ".svg": + return ".svg" + case ".png": + return ".png" + case ".xpm": + return ".xpm" + } + if bytes.HasPrefix(content, []byte("\x89PNG\r\n\x1a\n")) { + return ".png" + } + head := bytes.ToLower(bytes.TrimSpace(content[:min(len(content), 1024)])) + if bytes.Contains(head, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + fsys := fstest.MapFS{ + "keepassxc.png": {Data: []byte("\x89PNG\r\n\x1a\npng")}, + } + + if _, err := app.installIcon(fsys, "keepassxc.png", "keepassxc"); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("expected stale icon to be removed, stat err: %v", err) + } +} diff --git a/internal/appherder/install.go b/internal/appherder/install.go index dcb2801..12683f0 100644 --- a/internal/appherder/install.go +++ b/internal/appherder/install.go @@ -61,6 +61,17 @@ func (a App) install(ctx context.Context, appimage string, want expectedChecksum } pin := fingerprint + var preparedIcon iconInstall + iconPath := "" + if icon != "" { + prepared, err := a.prepareIconInstall(fsys, icon, appName) + if err != nil { + return "", err + } + preparedIcon = prepared + iconPath = preparedIcon.path + } + // No desktop file inside the AppImage: synthesize a terminal launcher so // CLI apps still get a menu entry and are tracked by managedApps. if desktop == nil { @@ -71,7 +82,7 @@ func (a App) install(ctx context.Context, appimage string, want expectedChecksum } // Patch in memory before any filesystem writes so a failure here installs nothing. - if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { + if err := a.patchDesktopFile(desktop, appName, iconPath); err != nil { return "", err } if pin != "" { @@ -86,8 +97,8 @@ func (a App) install(ctx context.Context, appimage string, want expectedChecksum } } - if icon != "" { - dest, err := a.installIcon(fsys, icon, appName) + if iconPath != "" { + dest, err := a.installPreparedIcon(preparedIcon, appName) if err != nil { rollback() return "", err diff --git a/internal/appherder/uninstall.go b/internal/appherder/uninstall.go index 60cb360..e7fba0d 100644 --- a/internal/appherder/uninstall.go +++ b/internal/appherder/uninstall.go @@ -25,6 +25,11 @@ func (a App) Uninstall(name string, force bool) error { return fmt.Errorf("remove %s: %w", path, err) } } + for _, path := range a.installedIconPaths(appName) { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + } _ = os.RemoveAll(filepath.Join(a.appimagesDir, ".versions", appName)) @@ -39,3 +44,8 @@ func (a App) installedPaths(appName string) []string { a.linkPath(appName), } } + +func (a App) installedIconPaths(appName string) []string { + matches, _ := filepath.Glob(filepath.Join(a.iconsDir, appName+".*")) + return matches +} -- 2.51.2