A shepherd for your Appimages.
0

Configure Feed

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

preserve app icon extensions

Aly Raffauf (Jun 19, 2026, 1:09 PM EDT) 92e46aa5 4fda6a24

+153 -15
+3 -3
internal/appherder/desktop.go
··· 55 55 return apps, nil 56 56 } 57 57 58 - func (a App) patchDesktopFile(desktop *desktopfile.File, appName string, hasIcon bool) error { 58 + func (a App) patchDesktopFile(desktop *desktopfile.File, appName string, iconPath string) error { 59 59 appimage := filepath.Join(a.appimagesDir, appName+".appimage") 60 60 61 61 desktop.Set(desktopEntrySection, desktopOwnerKey, "true") 62 - if hasIcon { 63 - desktop.Set(desktopEntrySection, "Icon", filepath.Join(a.iconsDir, appName)) 62 + if iconPath != "" { 63 + desktop.Set(desktopEntrySection, "Icon", iconPath) 64 64 } 65 65 desktop.Set(desktopEntrySection, "TryExec", appimage) 66 66
+6 -5
internal/appherder/desktop_test.go
··· 25 25 ` 26 26 27 27 func TestPatchDesktopFilePreservesDesktopActions(t *testing.T) { 28 - a, home := newTestApp(t) 28 + app, home := newTestApp(t) 29 29 30 30 desktop := desktopfile.Parse([]byte(sampleDesktopFile)) 31 - if err := a.patchDesktopFile(desktop, "example", true); err != nil { 31 + iconPath := filepath.Join(home, "AppImages", ".icons", "example.png") 32 + if err := app.patchDesktopFile(desktop, "example", iconPath); err != nil { 32 33 t.Fatal(err) 33 34 } 34 35 35 36 assertDesktopValue(t, desktop, desktopEntrySection, desktopOwnerKey, "true") 36 - assertDesktopValue(t, desktop, desktopEntrySection, "Icon", filepath.Join(home, "AppImages", ".icons", "example")) 37 + assertDesktopValue(t, desktop, desktopEntrySection, "Icon", iconPath) 37 38 assertDesktopValue(t, desktop, desktopEntrySection, "TryExec", filepath.Join(home, "AppImages", "example.appimage")) 38 39 assertDesktopExec(t, desktop, desktopEntrySection, []string{"env", "FOO=bar", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "%U"}) 39 40 assertDesktopExec(t, desktop, "Desktop Action new-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--new-window", "%U"}) ··· 96 97 } 97 98 98 99 func TestPatchDesktopFileSetsExecWhenMissing(t *testing.T) { 99 - a, home := newTestApp(t) 100 + app, home := newTestApp(t) 100 101 desktop := desktopfile.Parse([]byte( 101 102 "[Desktop Entry]\nType=Application\nName=Foo\nTerminal=true\n", 102 103 )) 103 - if err := a.patchDesktopFile(desktop, "foo", false); err != nil { 104 + if err := app.patchDesktopFile(desktop, "foo", ""); err != nil { 104 105 t.Fatal(err) 105 106 } 106 107 assertDesktopValue(t, desktop, desktopEntrySection, "Terminal", "true")
+61 -4
internal/appherder/icons.go
··· 1 1 package appherder 2 2 3 3 import ( 4 + "bytes" 4 5 "fmt" 5 6 "io/fs" 6 7 "os" ··· 78 79 } 79 80 } 80 81 82 + type iconInstall struct { 83 + source string 84 + path string 85 + content []byte 86 + } 87 + 81 88 func (a App) installIcon(fsys fs.FS, icon string, appName string) (string, error) { 89 + preparedIcon, err := a.prepareIconInstall(fsys, icon, appName) 90 + if err != nil { 91 + return "", err 92 + } 93 + return a.installPreparedIcon(preparedIcon, appName) 94 + } 95 + 96 + func (a App) prepareIconInstall(fsys fs.FS, icon string, appName string) (iconInstall, error) { 97 + content, err := fs.ReadFile(fsys, icon) 98 + if err != nil { 99 + return iconInstall{}, fmt.Errorf("read icon %s: %w", icon, err) 100 + } 101 + ext := iconExt(icon, content) 102 + if ext == "" { 103 + return iconInstall{}, fmt.Errorf("unsupported icon format %s", icon) 104 + } 105 + return iconInstall{ 106 + source: icon, 107 + path: filepath.Join(a.iconsDir, appName+ext), 108 + content: content, 109 + }, nil 110 + } 111 + 112 + func (a App) installPreparedIcon(preparedIcon iconInstall, appName string) (string, error) { 82 113 if err := os.MkdirAll(a.iconsDir, 0o755); err != nil { 83 114 return "", fmt.Errorf("create icon directory %s: %w", a.iconsDir, err) 84 115 } 116 + if err := writeIfChanged(preparedIcon.path, 0o644, preparedIcon.content); err != nil { 117 + return "", fmt.Errorf("install icon %s to %s: %w", preparedIcon.source, preparedIcon.path, err) 118 + } 119 + for _, path := range a.installedIconPaths(appName) { 120 + if path != preparedIcon.path { 121 + _ = os.Remove(path) 122 + } 123 + } 124 + _ = os.Remove(filepath.Join(a.iconsDir, appName)) 125 + return preparedIcon.path, nil 126 + } 85 127 86 - dest := filepath.Join(a.iconsDir, appName) 87 - if err := copyFromFS(fsys, icon, dest); err != nil { 88 - return "", fmt.Errorf("install icon %s to %s: %w", icon, dest, err) 128 + func iconExt(name string, content []byte) string { 129 + switch strings.ToLower(path.Ext(name)) { 130 + case ".svg": 131 + return ".svg" 132 + case ".png": 133 + return ".png" 134 + case ".xpm": 135 + return ".xpm" 89 136 } 90 - return dest, nil 137 + if bytes.HasPrefix(content, []byte("\x89PNG\r\n\x1a\n")) { 138 + return ".png" 139 + } 140 + head := bytes.ToLower(bytes.TrimSpace(content[:min(len(content), 1024)])) 141 + if bytes.Contains(head, []byte("<svg")) { 142 + return ".svg" 143 + } 144 + if bytes.HasPrefix(head, []byte("/* xpm */")) { 145 + return ".xpm" 146 + } 147 + return "" 91 148 }
+59
internal/appherder/icons_test.go
··· 1 1 package appherder 2 2 3 3 import ( 4 + "os" 5 + "path/filepath" 4 6 "testing" 5 7 "testing/fstest" 6 8 ) ··· 65 67 t.Fatalf("resolveIcon = %q, want empty", got) 66 68 } 67 69 } 70 + 71 + func TestInstallIconPreservesExtension(t *testing.T) { 72 + app, home := newTestApp(t) 73 + fsys := fstest.MapFS{ 74 + "keepassxc.png": {Data: []byte("\x89PNG\r\n\x1a\npng")}, 75 + } 76 + 77 + got, err := app.installIcon(fsys, "keepassxc.png", "keepassxc") 78 + if err != nil { 79 + t.Fatal(err) 80 + } 81 + 82 + want := filepath.Join(home, "AppImages", ".icons", "keepassxc.png") 83 + if got != want { 84 + t.Fatalf("installIcon = %q, want %q", got, want) 85 + } 86 + } 87 + 88 + func TestInstallIconDetectsDirIconPNG(t *testing.T) { 89 + app, home := newTestApp(t) 90 + fsys := fstest.MapFS{ 91 + ".DirIcon": {Data: []byte("\x89PNG\r\n\x1a\npng")}, 92 + } 93 + 94 + got, err := app.installIcon(fsys, ".DirIcon", "keepassxc") 95 + if err != nil { 96 + t.Fatal(err) 97 + } 98 + 99 + want := filepath.Join(home, "AppImages", ".icons", "keepassxc.png") 100 + if got != want { 101 + t.Fatalf("installIcon = %q, want %q", got, want) 102 + } 103 + } 104 + 105 + func TestInstallIconRemovesStaleSiblings(t *testing.T) { 106 + app, home := newTestApp(t) 107 + iconDir := filepath.Join(home, "AppImages", ".icons") 108 + if err := os.MkdirAll(iconDir, 0o755); err != nil { 109 + t.Fatal(err) 110 + } 111 + stale := filepath.Join(iconDir, "keepassxc.svg") 112 + if err := os.WriteFile(stale, []byte("<svg/>"), 0o644); err != nil { 113 + t.Fatal(err) 114 + } 115 + fsys := fstest.MapFS{ 116 + "keepassxc.png": {Data: []byte("\x89PNG\r\n\x1a\npng")}, 117 + } 118 + 119 + if _, err := app.installIcon(fsys, "keepassxc.png", "keepassxc"); err != nil { 120 + t.Fatal(err) 121 + } 122 + 123 + if _, err := os.Stat(stale); !os.IsNotExist(err) { 124 + t.Fatalf("expected stale icon to be removed, stat err: %v", err) 125 + } 126 + }
+14 -3
internal/appherder/install.go
··· 61 61 } 62 62 pin := fingerprint 63 63 64 + var preparedIcon iconInstall 65 + iconPath := "" 66 + if icon != "" { 67 + prepared, err := a.prepareIconInstall(fsys, icon, appName) 68 + if err != nil { 69 + return "", err 70 + } 71 + preparedIcon = prepared 72 + iconPath = preparedIcon.path 73 + } 74 + 64 75 // No desktop file inside the AppImage: synthesize a terminal launcher so 65 76 // CLI apps still get a menu entry and are tracked by managedApps. 66 77 if desktop == nil { ··· 71 82 } 72 83 73 84 // Patch in memory before any filesystem writes so a failure here installs nothing. 74 - if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { 85 + if err := a.patchDesktopFile(desktop, appName, iconPath); err != nil { 75 86 return "", err 76 87 } 77 88 if pin != "" { ··· 86 97 } 87 98 } 88 99 89 - if icon != "" { 90 - dest, err := a.installIcon(fsys, icon, appName) 100 + if iconPath != "" { 101 + dest, err := a.installPreparedIcon(preparedIcon, appName) 91 102 if err != nil { 92 103 rollback() 93 104 return "", err
+10
internal/appherder/uninstall.go
··· 25 25 return fmt.Errorf("remove %s: %w", path, err) 26 26 } 27 27 } 28 + for _, path := range a.installedIconPaths(appName) { 29 + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { 30 + return fmt.Errorf("remove %s: %w", path, err) 31 + } 32 + } 28 33 29 34 _ = os.RemoveAll(filepath.Join(a.appimagesDir, ".versions", appName)) 30 35 ··· 39 44 a.linkPath(appName), 40 45 } 41 46 } 47 + 48 + func (a App) installedIconPaths(appName string) []string { 49 + matches, _ := filepath.Glob(filepath.Join(a.iconsDir, appName+".*")) 50 + return matches 51 + }