A shepherd for your Appimages.
0

Configure Feed

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

idempotent app installs

Aly Raffauf (Jun 17, 2026, 5:26 PM EDT) 6f0aef91 6b741c48

+255 -11
+47 -4
cmd/appherder/appimage.go
··· 108 108 } 109 109 110 110 dest := filepath.Join(appimagesDir, appName+".appimage") 111 - if err := writeAtomic(dest, 0o755, func(w io.Writer) error { 112 - return copyTo(file, w) 113 - }); err != nil { 114 - return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) 111 + inFolder := samePath(filepath.Dir(file), appimagesDir) 112 + 113 + if !samePath(file, dest) { 114 + same, err := sameContent(file, dest) 115 + if err != nil { 116 + return "", fmt.Errorf("compare AppImage %s with %s: %w", file, dest, err) 117 + } 118 + switch { 119 + case same: 120 + // Installed binary is already byte-identical, so skip the copy. If 121 + // the source is a duplicate dropped into ~/AppImages, remove it. 122 + if inFolder { 123 + if err := os.Remove(file); err != nil { 124 + return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) 125 + } 126 + } 127 + case inFolder: 128 + // A differently-named AppImage already in ~/AppImages: move it into 129 + // place instead of leaving a duplicate. 130 + if err := os.Rename(file, dest); err != nil { 131 + return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) 132 + } 133 + default: 134 + // Source lives elsewhere: copy it in. 135 + if err := writeAtomic(dest, 0o755, func(w io.Writer) error { 136 + return copyTo(file, w) 137 + }); err != nil { 138 + return "", fmt.Errorf("install AppImage %s to %s: %w", file, dest, err) 139 + } 140 + } 115 141 } 116 142 143 + if err := os.Chmod(dest, 0o755); err != nil { 144 + return "", fmt.Errorf("make AppImage executable %s: %w", dest, err) 145 + } 117 146 return dest, nil 147 + } 148 + 149 + // samePath reports whether a and b resolve to the same file, accounting for 150 + // symlinks and mounts (e.g. /home -> /var/home). 151 + func samePath(a, b string) bool { 152 + ai, err := os.Stat(a) 153 + if err != nil { 154 + return false 155 + } 156 + bi, err := os.Stat(b) 157 + if err != nil { 158 + return false 159 + } 160 + return os.SameFile(ai, bi) 118 161 } 119 162 120 163 func copyTo(src string, dest io.Writer) error {
+154
cmd/appherder/appimage_test.go
··· 3 3 import ( 4 4 "bytes" 5 5 "encoding/binary" 6 + "errors" 7 + "os" 8 + "path/filepath" 6 9 "testing" 7 10 ) 8 11 ··· 50 53 t.Fatal("expected error for type-1 AppImage") 51 54 } 52 55 } 56 + 57 + func assertExecutableFile(t *testing.T, path, want string) { 58 + t.Helper() 59 + info, err := os.Stat(path) 60 + if err != nil { 61 + t.Fatal(err) 62 + } 63 + if info.Mode().Perm()&0o111 == 0 { 64 + t.Fatalf("%s is not executable: %v", path, info.Mode()) 65 + } 66 + if got, _ := os.ReadFile(path); string(got) != want { 67 + t.Fatalf("%s contents = %q, want %q", path, got, want) 68 + } 69 + } 70 + 71 + func TestInstallAppImageCopiesFromElsewhere(t *testing.T) { 72 + home := t.TempDir() 73 + src := filepath.Join(t.TempDir(), "Foo-1.0-x86_64.AppImage") 74 + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { 75 + t.Fatal(err) 76 + } 77 + a := app{homeDir: func() (string, error) { return home, nil }} 78 + 79 + dest, err := a.installAppImage(src, "foo") 80 + if err != nil { 81 + t.Fatal(err) 82 + } 83 + if want := filepath.Join(home, "AppImages", "foo.appimage"); dest != want { 84 + t.Fatalf("dest = %q, want %q", dest, want) 85 + } 86 + assertExecutableFile(t, dest, "payload") 87 + if _, err := os.Stat(src); err != nil { 88 + t.Fatalf("source should remain after copy: %v", err) 89 + } 90 + } 91 + 92 + func TestInstallAppImageMovesVersionedFileInPlace(t *testing.T) { 93 + home := t.TempDir() 94 + appimages := filepath.Join(home, "AppImages") 95 + if err := os.MkdirAll(appimages, 0o755); err != nil { 96 + t.Fatal(err) 97 + } 98 + src := filepath.Join(appimages, "Foo-1.0-x86_64.appimage") 99 + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { 100 + t.Fatal(err) 101 + } 102 + a := app{homeDir: func() (string, error) { return home, nil }} 103 + 104 + dest, err := a.installAppImage(src, "foo") 105 + if err != nil { 106 + t.Fatal(err) 107 + } 108 + assertExecutableFile(t, dest, "payload") 109 + if _, err := os.Stat(src); !errors.Is(err, os.ErrNotExist) { 110 + t.Fatalf("versioned source should be moved away, stat err: %v", err) 111 + } 112 + } 113 + 114 + func TestInstallAppImageSkipsCopyWhenIdentical(t *testing.T) { 115 + home := t.TempDir() 116 + appimages := filepath.Join(home, "AppImages") 117 + if err := os.MkdirAll(appimages, 0o755); err != nil { 118 + t.Fatal(err) 119 + } 120 + dest := filepath.Join(appimages, "foo.appimage") 121 + if err := os.WriteFile(dest, []byte("payload"), 0o755); err != nil { 122 + t.Fatal(err) 123 + } 124 + src := filepath.Join(t.TempDir(), "Foo-1.0.AppImage") 125 + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { 126 + t.Fatal(err) 127 + } 128 + 129 + before, err := os.Stat(dest) 130 + if err != nil { 131 + t.Fatal(err) 132 + } 133 + a := app{homeDir: func() (string, error) { return home, nil }} 134 + if _, err := a.installAppImage(src, "foo"); err != nil { 135 + t.Fatal(err) 136 + } 137 + 138 + after, err := os.Stat(dest) 139 + if err != nil { 140 + t.Fatal(err) 141 + } 142 + if !os.SameFile(before, after) { 143 + t.Fatal("dest was rewritten despite identical content") 144 + } 145 + if _, err := os.Stat(src); err != nil { 146 + t.Fatalf("external source should remain: %v", err) 147 + } 148 + } 149 + 150 + func TestInstallAppImageRemovesIdenticalDuplicateInFolder(t *testing.T) { 151 + home := t.TempDir() 152 + appimages := filepath.Join(home, "AppImages") 153 + if err := os.MkdirAll(appimages, 0o755); err != nil { 154 + t.Fatal(err) 155 + } 156 + dest := filepath.Join(appimages, "foo.appimage") 157 + if err := os.WriteFile(dest, []byte("payload"), 0o755); err != nil { 158 + t.Fatal(err) 159 + } 160 + dup := filepath.Join(appimages, "Foo-1.0.appimage") 161 + if err := os.WriteFile(dup, []byte("payload"), 0o644); err != nil { 162 + t.Fatal(err) 163 + } 164 + 165 + before, err := os.Stat(dest) 166 + if err != nil { 167 + t.Fatal(err) 168 + } 169 + a := app{homeDir: func() (string, error) { return home, nil }} 170 + if _, err := a.installAppImage(dup, "foo"); err != nil { 171 + t.Fatal(err) 172 + } 173 + 174 + after, err := os.Stat(dest) 175 + if err != nil { 176 + t.Fatal(err) 177 + } 178 + if !os.SameFile(before, after) { 179 + t.Fatal("canonical AppImage was rewritten despite identical content") 180 + } 181 + if _, err := os.Stat(dup); !errors.Is(err, os.ErrNotExist) { 182 + t.Fatalf("identical duplicate should be removed, stat err: %v", err) 183 + } 184 + } 185 + 186 + func TestInstallAppImageNoOpWhenAlreadyCanonical(t *testing.T) { 187 + home := t.TempDir() 188 + appimages := filepath.Join(home, "AppImages") 189 + if err := os.MkdirAll(appimages, 0o755); err != nil { 190 + t.Fatal(err) 191 + } 192 + dest := filepath.Join(appimages, "foo.appimage") 193 + if err := os.WriteFile(dest, []byte("payload"), 0o644); err != nil { 194 + t.Fatal(err) 195 + } 196 + a := app{homeDir: func() (string, error) { return home, nil }} 197 + 198 + got, err := a.installAppImage(dest, "foo") 199 + if err != nil { 200 + t.Fatal(err) 201 + } 202 + if got != dest { 203 + t.Fatalf("dest = %q, want %q", got, dest) 204 + } 205 + assertExecutableFile(t, dest, "payload") 206 + }
+46
cmd/appherder/files.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "errors" 4 7 "io" 5 8 "io/fs" 6 9 "os" ··· 32 35 return err 33 36 } 34 37 return os.Rename(tmpName, path) 38 + } 39 + 40 + // sameContent reports whether a and b are byte-identical, comparing size first 41 + // to avoid hashing files that obviously differ. A missing b reports false. 42 + func sameContent(a, b string) (bool, error) { 43 + ia, err := os.Stat(a) 44 + if err != nil { 45 + return false, err 46 + } 47 + ib, err := os.Stat(b) 48 + if err != nil { 49 + if errors.Is(err, os.ErrNotExist) { 50 + return false, nil 51 + } 52 + return false, err 53 + } 54 + if ia.Size() != ib.Size() { 55 + return false, nil 56 + } 57 + 58 + ha, err := fileHash(a) 59 + if err != nil { 60 + return false, err 61 + } 62 + hb, err := fileHash(b) 63 + if err != nil { 64 + return false, err 65 + } 66 + return bytes.Equal(ha, hb), nil 67 + } 68 + 69 + func fileHash(path string) ([]byte, error) { 70 + f, err := os.Open(path) 71 + if err != nil { 72 + return nil, err 73 + } 74 + defer f.Close() 75 + 76 + h := sha256.New() 77 + if _, err := io.Copy(h, f); err != nil { 78 + return nil, err 79 + } 80 + return h.Sum(nil), nil 35 81 } 36 82 37 83 func copyFromFS(fsys fs.FS, name string, dest string) error {
+8 -7
cmd/appherder/install.go
··· 58 58 installed = append(installed, dest) 59 59 } 60 60 61 - dest, err := a.installAppImage(appimage, appName) 62 - if err != nil { 63 - rollback() 64 - return err 65 - } 66 - installed = append(installed, dest) 67 - 68 61 if desktop != nil { 69 62 dest, err := a.installDesktopFile(desktop, appName) 70 63 if err != nil { ··· 72 65 return err 73 66 } 74 67 installed = append(installed, dest) 68 + } 69 + 70 + // Materialize the AppImage last: when the source is already in ~/AppImages 71 + // it gets moved, so an earlier failure must not roll back over the user's 72 + // file. 73 + if _, err := a.installAppImage(appimage, appName); err != nil { 74 + rollback() 75 + return err 75 76 } 76 77 77 78 return nil