A shepherd for your Appimages.
0

Configure Feed

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

add icon fallbacks and atomic installations

Aly Raffauf (Jun 17, 2026, 3:47 PM EDT) d1b2501a 8713a92f

+133 -152
+4 -23
cmd/appherder/appimage.go
··· 33 33 } 34 34 35 35 dest := filepath.Join(appimagesDir, appName+".appimage") 36 - tmp, err := os.CreateTemp(appimagesDir, "."+appName+".*.tmp") 37 - if err != nil { 38 - return "", fmt.Errorf("create temporary AppImage in %s: %w", appimagesDir, err) 39 - } 40 - tmpName := tmp.Name() 41 - defer os.Remove(tmpName) 42 - 43 - if err := copyTo(file, tmp); err != nil { 44 - _ = tmp.Close() 45 - return "", fmt.Errorf("copy AppImage %s to %s: %w", file, tmpName, err) 46 - } 47 - if err := tmp.Close(); err != nil { 48 - return "", fmt.Errorf("close temporary AppImage %s: %w", tmpName, err) 49 - } 50 - 51 - if err := os.Chmod(tmpName, 0o755); err != nil { 52 - return "", fmt.Errorf("make temporary AppImage executable %s: %w", tmpName, err) 53 - } 54 - if err := os.Rename(tmpName, dest); err != nil { 55 - return "", fmt.Errorf("replace AppImage %s: %w", dest, err) 56 - } 57 - if err := os.Chmod(dest, 0o755); err != nil { 58 - return "", fmt.Errorf("make installed AppImage executable %s: %w", dest, err) 36 + if err := writeAtomic(dest, 0o755, func(w io.Writer) error { 37 + return copyTo(file, w) 38 + }); err != nil { 39 + return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) 59 40 } 60 41 61 42 return dest, nil
+9 -3
cmd/appherder/desktop.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "io" 5 6 "os" 6 7 "path/filepath" 7 8 "sort" ··· 190 191 if d.trailingNewline { 191 192 text += "\n" 192 193 } 193 - return os.WriteFile(path, []byte(text), 0o644) 194 + return writeAtomic(path, 0o644, func(w io.Writer) error { 195 + _, err := w.Write([]byte(text)) 196 + return err 197 + }) 194 198 } 195 199 196 - func (a app) patchDesktopFile(desktop *desktopFile, appName string) error { 200 + func (a app) patchDesktopFile(desktop *desktopFile, appName string, hasIcon bool) error { 197 201 home, err := a.homeDir() 198 202 if err != nil { 199 203 return fmt.Errorf("resolve home directory: %w", err) ··· 202 206 appimages := filepath.Join(home, "AppImages") 203 207 appimage := filepath.Join(appimages, appName+".appimage") 204 208 205 - desktop.set("Icon", filepath.Join(appimages, ".icons", appName), desktopEntrySection) 209 + if hasIcon { 210 + desktop.set("Icon", filepath.Join(appimages, ".icons", appName), desktopEntrySection) 211 + } 206 212 desktop.set("TryExec", appimage, desktopEntrySection) 207 213 208 214 if execCmd, ok := desktop.get("Exec", desktopEntrySection); ok {
+1 -1
cmd/appherder/desktop_test.go
··· 61 61 if err != nil { 62 62 t.Fatal(err) 63 63 } 64 - if err := a.patchDesktopFile(desktop, "example"); err != nil { 64 + if err := a.patchDesktopFile(desktop, "example", true); err != nil { 65 65 t.Fatal(err) 66 66 } 67 67 if err := desktop.write(output); err != nil {
+27 -9
cmd/appherder/files.go
··· 1 1 package main 2 2 3 3 import ( 4 - "fmt" 4 + "io" 5 5 "os" 6 + "path/filepath" 6 7 ) 7 8 8 - func copyFile(src string, dest string) error { 9 - out, err := os.Create(dest) 9 + // writeAtomic writes via a temp file beside path and renames it into place, so 10 + // path never holds a partially written file. 11 + func writeAtomic(path string, perm os.FileMode, write func(io.Writer) error) (err error) { 12 + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") 10 13 if err != nil { 11 - return fmt.Errorf("create destination %s: %w", dest, err) 14 + return err 12 15 } 16 + tmpName := tmp.Name() 17 + defer func() { 18 + if err != nil { 19 + os.Remove(tmpName) 20 + } 21 + }() 13 22 14 - if err := copyTo(src, out); err != nil { 15 - _ = out.Close() 23 + if err = write(tmp); err != nil { 24 + tmp.Close() 16 25 return err 17 26 } 18 - if err := out.Close(); err != nil { 19 - return fmt.Errorf("close destination %s: %w", dest, err) 27 + if err = tmp.Close(); err != nil { 28 + return err 20 29 } 21 - return nil 30 + if err = os.Chmod(tmpName, perm); err != nil { 31 + return err 32 + } 33 + return os.Rename(tmpName, path) 34 + } 35 + 36 + func copyFile(src string, dest string) error { 37 + return writeAtomic(dest, 0o644, func(w io.Writer) error { 38 + return copyTo(src, w) 39 + }) 22 40 }
+65 -4
cmd/appherder/icons.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "io/fs" 5 6 "os" 6 7 "path/filepath" 8 + "strings" 7 9 ) 8 10 11 + // resolveIcon prefers .DirIcon, then any icon at the AppImage root, then icons 12 + // in the standard theme directories. It returns "" when none is found. 9 13 func resolveIcon(extracted string) string { 10 - icon := filepath.Join(extracted, ".DirIcon") 11 - if _, err := os.Stat(icon); err == nil { 12 - return icon 14 + dirIcon := filepath.Join(extracted, ".DirIcon") 15 + if info, err := os.Stat(dirIcon); err == nil && !info.IsDir() { 16 + return dirIcon 17 + } 18 + return findFallbackIcon(extracted) 19 + } 20 + 21 + func findFallbackIcon(extracted string) string { 22 + best := "" 23 + bestRank := -1 24 + var bestSize int64 25 + consider := func(path string, entry fs.DirEntry) { 26 + rank := iconRank(path) 27 + if rank < 0 { 28 + return 29 + } 30 + info, err := entry.Info() 31 + if err != nil { 32 + return 33 + } 34 + // Prefer the higher-ranked extension, then the larger (higher-res) file. 35 + if size := info.Size(); rank > bestRank || (rank == bestRank && size > bestSize) { 36 + best, bestRank, bestSize = path, rank, size 37 + } 38 + } 39 + 40 + if entries, err := os.ReadDir(extracted); err == nil { 41 + for _, entry := range entries { 42 + if !entry.IsDir() { 43 + consider(filepath.Join(extracted, entry.Name()), entry) 44 + } 45 + } 13 46 } 14 - return "" 47 + 48 + for _, dir := range []string{ 49 + filepath.Join(extracted, "usr", "share", "icons"), 50 + filepath.Join(extracted, "usr", "share", "pixmaps"), 51 + } { 52 + _ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { 53 + if err != nil || entry.IsDir() { 54 + return nil 55 + } 56 + consider(path, entry) 57 + return nil 58 + }) 59 + } 60 + 61 + return best 62 + } 63 + 64 + // iconRank ranks icon extensions (higher preferred); -1 means not an icon. 65 + func iconRank(path string) int { 66 + switch strings.ToLower(filepath.Ext(path)) { 67 + case ".svg": 68 + return 2 69 + case ".png": 70 + return 1 71 + case ".xpm": 72 + return 0 73 + default: 74 + return -1 75 + } 15 76 } 16 77 17 78 func (a app) installIcon(icon string, appName string) (string, error) {
+27 -6
cmd/appherder/install.go
··· 32 32 icon := resolveIcon(extracted) 33 33 appName := appNameFromPath(appimage) 34 34 35 + // Patch in memory before any filesystem writes so a failure here installs nothing. 36 + if desktop != nil { 37 + if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { 38 + return err 39 + } 40 + } 41 + 42 + // Roll back written files on a later failure rather than leaving a half-installed app. 43 + var installed []string 44 + rollback := func() { 45 + for _, path := range installed { 46 + _ = os.Remove(path) 47 + } 48 + } 49 + 35 50 if icon != "" { 36 - if _, err := a.installIcon(icon, appName); err != nil { 51 + dest, err := a.installIcon(icon, appName) 52 + if err != nil { 53 + rollback() 37 54 return err 38 55 } 56 + installed = append(installed, dest) 39 57 } 40 58 41 - if _, err := a.installAppImage(appimage, appName); err != nil { 59 + dest, err := a.installAppImage(appimage, appName) 60 + if err != nil { 61 + rollback() 42 62 return err 43 63 } 64 + installed = append(installed, dest) 44 65 45 66 if desktop != nil { 46 - if err := a.patchDesktopFile(desktop, appName); err != nil { 47 - return err 48 - } 49 - if _, err := a.installDesktopFile(desktop, appName); err != nil { 67 + dest, err := a.installDesktopFile(desktop, appName) 68 + if err != nil { 69 + rollback() 50 70 return err 51 71 } 72 + installed = append(installed, dest) 52 73 } 53 74 54 75 return nil
-106
cmd/appherder/install_test.go
··· 1 - package main 2 - 3 - import ( 4 - "os" 5 - "path/filepath" 6 - "testing" 7 - ) 8 - 9 - // fakeExtract simulates `appimage --appimage-extract` by populating a 10 - // squashfs-root directory with a desktop file and icon, mirroring what a real 11 - // AppImage runtime writes during extraction. 12 - func fakeExtract(t *testing.T, desktop string, icon []byte) func(name string, args []string, dir string) error { 13 - t.Helper() 14 - return func(name string, args []string, dir string) error { 15 - root := filepath.Join(dir, "squashfs-root") 16 - if err := os.MkdirAll(root, 0o755); err != nil { 17 - return err 18 - } 19 - if desktop != "" { 20 - if err := os.WriteFile(filepath.Join(root, "example.desktop"), []byte(desktop), 0o644); err != nil { 21 - return err 22 - } 23 - } 24 - if icon != nil { 25 - if err := os.WriteFile(filepath.Join(root, ".DirIcon"), icon, 0o644); err != nil { 26 - return err 27 - } 28 - } 29 - return nil 30 - } 31 - } 32 - 33 - func TestInstallWritesAppImageIconAndDesktopFile(t *testing.T) { 34 - home := t.TempDir() 35 - src := t.TempDir() 36 - appimage := filepath.Join(src, "Example.AppImage") 37 - if err := os.WriteFile(appimage, []byte("appimage-bytes"), 0o644); err != nil { 38 - t.Fatal(err) 39 - } 40 - 41 - iconBytes := []byte("icon-bytes") 42 - a := app{ 43 - homeDir: func() (string, error) { return home, nil }, 44 - run: fakeExtract(t, sampleDesktopFile, iconBytes), 45 - } 46 - 47 - if err := a.install(appimage); err != nil { 48 - t.Fatalf("install: %v", err) 49 - } 50 - 51 - // AppImage copied into ~/AppImages and made executable. 52 - installedAppImage := filepath.Join(home, "AppImages", "Example.appimage") 53 - info, err := os.Stat(installedAppImage) 54 - if err != nil { 55 - t.Fatalf("stat installed appimage: %v", err) 56 - } 57 - if info.Mode().Perm()&0o111 == 0 { 58 - t.Fatalf("installed appimage is not executable: %v", info.Mode()) 59 - } 60 - if got, _ := os.ReadFile(installedAppImage); string(got) != "appimage-bytes" { 61 - t.Fatalf("installed appimage contents = %q", got) 62 - } 63 - 64 - // Icon copied into ~/AppImages/.icons/<app>. 65 - installedIcon := filepath.Join(home, "AppImages", ".icons", "Example") 66 - if got, _ := os.ReadFile(installedIcon); string(got) != string(iconBytes) { 67 - t.Fatalf("installed icon contents = %q", got) 68 - } 69 - 70 - // Desktop file written and patched to point at the installed paths. 71 - installedDesktop := filepath.Join(home, ".local", "share", "applications", "Example.desktop") 72 - desktop, err := readDesktopFile(installedDesktop) 73 - if err != nil { 74 - t.Fatalf("read installed desktop file: %v", err) 75 - } 76 - assertDesktopValue(t, desktop, desktopEntrySection, "TryExec", installedAppImage) 77 - assertDesktopValue(t, desktop, desktopEntrySection, "Icon", installedIcon) 78 - assertDesktopExec(t, desktop, desktopEntrySection, 79 - []string{"env", "FOO=bar", "DESKTOPINTEGRATION=1", installedAppImage, "%U"}) 80 - } 81 - 82 - func TestInstallWithoutDesktopFileStillInstallsAppImage(t *testing.T) { 83 - home := t.TempDir() 84 - src := t.TempDir() 85 - appimage := filepath.Join(src, "Example.AppImage") 86 - if err := os.WriteFile(appimage, []byte("appimage-bytes"), 0o644); err != nil { 87 - t.Fatal(err) 88 - } 89 - 90 - a := app{ 91 - homeDir: func() (string, error) { return home, nil }, 92 - run: fakeExtract(t, "", nil), 93 - } 94 - 95 - if err := a.install(appimage); err != nil { 96 - t.Fatalf("install: %v", err) 97 - } 98 - 99 - if _, err := os.Stat(filepath.Join(home, "AppImages", "Example.appimage")); err != nil { 100 - t.Fatalf("stat installed appimage: %v", err) 101 - } 102 - // No desktop file in the extracted AppImage means none is written. 103 - if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "Example.desktop")); !os.IsNotExist(err) { 104 - t.Fatalf("expected no desktop file, stat err: %v", err) 105 - } 106 - }