A shepherd for your Appimages.
0

Configure Feed

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

add rollbacks

Aly Raffauf (Jun 18, 2026, 1:30 AM EDT) 214e133d f94717f1

+602 -7
+23
cmd/appherder/cli.go
··· 30 30 newSyncCommand(a), 31 31 newMigrateCommand(a), 32 32 newUpgradeCommand(a), 33 + newRollbackCommand(a), 33 34 newAutosyncCommand(), 34 35 newAutoupgradeCommand(), 35 36 ) ··· 172 173 } 173 174 cmd.Flags().BoolVar(&check, "check", false, "Report available updates without installing them") 174 175 return cmd 176 + } 177 + 178 + func newRollbackCommand(a appherder.App) *cobra.Command { 179 + return &cobra.Command{ 180 + Use: "rollback APP [VERSION]", 181 + Short: "Restore a previously saved version", 182 + Long: "Rolls the app back to a saved version. Without a version, the most recently\n" + 183 + "saved one is restored. appherder saves the current version whenever it is\n" + 184 + "replaced by an install or upgrade.", 185 + Args: cobra.RangeArgs(1, 2), 186 + RunE: func(cmd *cobra.Command, args []string) error { 187 + version := "" 188 + if len(args) > 1 { 189 + version = args[1] 190 + } 191 + if err := a.Rollback(args[0], version); err != nil { 192 + return err 193 + } 194 + fmt.Fprintf(cmd.OutOrStdout(), "rolled back %s\n", appherder.NormalizeAppName(args[0])) 195 + return nil 196 + }, 197 + } 175 198 } 176 199 177 200 func newAutoCommand(use, short, long, offHelp string, enable, disable func() error) *cobra.Command {
+86 -5
internal/appherder/appimage.go
··· 8 8 "io/fs" 9 9 "os" 10 10 "path/filepath" 11 + "sort" 12 + "strings" 11 13 12 14 "github.com/CalebQ42/squashfs" 13 15 ) ··· 111 113 } 112 114 switch { 113 115 case same: 114 - // Installed binary is already byte-identical, so skip the copy. If 115 - // the source is a duplicate dropped into ~/AppImages, remove it. 116 116 if inFolder { 117 117 if err := os.Remove(file); err != nil { 118 118 return "", fmt.Errorf("remove duplicate AppImage %s: %w", file, err) 119 119 } 120 120 } 121 121 case inFolder: 122 - // A differently-named AppImage already in ~/AppImages: move it into 123 - // place instead of leaving a duplicate. 122 + if err := a.saveToVersions(dest, appName); err != nil { 123 + return "", err 124 + } 124 125 if err := os.Rename(file, dest); err != nil { 125 126 return "", fmt.Errorf("move AppImage %s to %s: %w", file, dest, err) 126 127 } 127 128 default: 128 - // Source lives elsewhere: copy it in. 129 + if err := a.saveToVersions(dest, appName); err != nil { 130 + return "", err 131 + } 129 132 if err := writeAtomic(dest, 0o755, func(writer io.Writer) error { 130 133 return copyTo(file, writer) 131 134 }); err != nil { ··· 164 167 _, err = io.Copy(dest, in) 165 168 return err 166 169 } 170 + 171 + // saveToVersions hardlinks src into .versions/appName/<version>.appimage, 172 + // pruning older versions to keep at most maxSavedVersions. 173 + func (a App) saveToVersions(src, appName string) error { 174 + if _, err := os.Stat(src); os.IsNotExist(err) { 175 + return nil 176 + } 177 + version := readAppImageVersion(src) 178 + versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) 179 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 180 + return fmt.Errorf("create versions directory: %w", err) 181 + } 182 + 183 + a.pruneVersions(versionsDir, maxSavedVersions-1) 184 + 185 + dest := filepath.Join(versionsDir, version+".appimage") 186 + os.Remove(dest) 187 + if err := os.Link(src, dest); err != nil { 188 + return fmt.Errorf("save current version %s: %w", version, err) 189 + } 190 + return nil 191 + } 192 + 193 + const maxSavedVersions = 3 194 + 195 + // pruneVersions removes the oldest saved versions when the directory holds 196 + // more than keep files, sorting by mtime. 197 + func (a App) pruneVersions(dir string, keep int) { 198 + entries, err := os.ReadDir(dir) 199 + if err != nil { 200 + return 201 + } 202 + var files []os.DirEntry 203 + for _, entry := range entries { 204 + if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 205 + files = append(files, entry) 206 + } 207 + } 208 + if len(files) <= keep { 209 + return 210 + } 211 + sort.Slice(files, func(i, j int) bool { 212 + infoI, _ := files[i].Info() 213 + infoJ, _ := files[j].Info() 214 + return infoI.ModTime().Before(infoJ.ModTime()) 215 + }) 216 + for _, entry := range files[:len(files)-keep] { 217 + os.Remove(filepath.Join(dir, entry.Name())) 218 + } 219 + } 220 + 221 + // readAppImageVersion returns the embedded version string from the AppImage's 222 + // desktop file, falling back to the file's mtime. 223 + func readAppImageVersion(path string) string { 224 + if fsys, closeFs, err := openAppImage(path); err == nil { 225 + defer closeFs() 226 + if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { 227 + if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok && version != "" { 228 + return sanitizeVersionForFilename(version) 229 + } 230 + } 231 + } 232 + if info, err := os.Stat(path); err == nil { 233 + return info.ModTime().UTC().Format("2006-01-02T150405") 234 + } 235 + return "unknown" 236 + } 237 + 238 + // sanitizeVersionForFilename replaces path separators so the version string is 239 + // safe for a filename. 240 + func sanitizeVersionForFilename(version string) string { 241 + version = strings.ReplaceAll(version, "/", "_") 242 + version = strings.ReplaceAll(version, "\\", "_") 243 + if len(version) > 100 { 244 + version = version[:100] 245 + } 246 + return version 247 + }
+199
internal/appherder/appimage_test.go
··· 6 6 "os" 7 7 "path/filepath" 8 8 "testing" 9 + "time" 9 10 ) 10 11 11 12 func TestAppImageSquashfsOffset(t *testing.T) { ··· 188 189 } 189 190 assertExecutableFile(t, dest, "payload") 190 191 } 192 + 193 + func TestSanitizeVersionForFilename(t *testing.T) { 194 + tests := []struct{ in, want string }{ 195 + {"v1.2.3", "v1.2.3"}, 196 + {"2024/05/01", "2024_05_01"}, 197 + {"path\\separator", "path_separator"}, 198 + {"mixed/path\\thing", "mixed_path_thing"}, 199 + } 200 + for _, tc := range tests { 201 + if got := sanitizeVersionForFilename(tc.in); got != tc.want { 202 + t.Fatalf("sanitizeVersionForFilename(%q) = %q, want %q", tc.in, got, tc.want) 203 + } 204 + } 205 + 206 + long := "" 207 + for i := 0; i < 200; i++ { 208 + long += "x" 209 + } 210 + if got := sanitizeVersionForFilename(long); len(got) != 100 { 211 + t.Fatalf("expected truncated to 100, got %d", len(got)) 212 + } 213 + } 214 + 215 + func TestReadAppImageVersionFallsBackToMtime(t *testing.T) { 216 + dir := t.TempDir() 217 + file := filepath.Join(dir, "not-an-appimage") 218 + if err := os.WriteFile(file, []byte("hello"), 0o644); err != nil { 219 + t.Fatal(err) 220 + } 221 + fixed := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) 222 + if err := os.Chtimes(file, fixed, fixed); err != nil { 223 + t.Fatal(err) 224 + } 225 + if got := readAppImageVersion(file); got != "2026-06-18T120000" { 226 + t.Fatalf("readAppImageVersion = %q, want 2026-06-18T120000", got) 227 + } 228 + } 229 + 230 + func TestSaveToVersionsHardlinksFile(t *testing.T) { 231 + a, home := newTestApp(t) 232 + appimages := filepath.Join(home, "AppImages") 233 + if err := os.MkdirAll(appimages, 0o755); err != nil { 234 + t.Fatal(err) 235 + } 236 + current := filepath.Join(appimages, "foo.appimage") 237 + if err := os.WriteFile(current, []byte("v1"), 0o644); err != nil { 238 + t.Fatal(err) 239 + } 240 + 241 + if err := a.saveToVersions(current, "foo"); err != nil { 242 + t.Fatal(err) 243 + } 244 + 245 + versionsDir := filepath.Join(appimages, ".versions", "foo") 246 + entries, err := os.ReadDir(versionsDir) 247 + if err != nil { 248 + t.Fatal(err) 249 + } 250 + if len(entries) != 1 { 251 + t.Fatalf("expected 1 saved version, got %d", len(entries)) 252 + } 253 + got, _ := os.ReadFile(filepath.Join(versionsDir, entries[0].Name())) 254 + if string(got) != "v1" { 255 + t.Fatalf("saved content = %q, want v1", string(got)) 256 + } 257 + } 258 + 259 + func TestSaveToVersionsNoopOnMissingFile(t *testing.T) { 260 + a, _ := newTestApp(t) 261 + if err := a.saveToVersions("/nonexistent/foo.appimage", "foo"); err != nil { 262 + t.Fatal(err) 263 + } 264 + } 265 + 266 + func TestInstallAppImageSavesPreviousVersion(t *testing.T) { 267 + a, home := newTestApp(t) 268 + appimages := filepath.Join(home, "AppImages") 269 + if err := os.MkdirAll(appimages, 0o755); err != nil { 270 + t.Fatal(err) 271 + } 272 + 273 + current := filepath.Join(appimages, "foo.appimage") 274 + if err := os.WriteFile(current, []byte("v1"), 0o644); err != nil { 275 + t.Fatal(err) 276 + } 277 + 278 + src := filepath.Join(t.TempDir(), "Foo-2.0.AppImage") 279 + if err := os.WriteFile(src, []byte("v2"), 0o644); err != nil { 280 + t.Fatal(err) 281 + } 282 + 283 + _, err := a.installAppImage(src, "foo") 284 + if err != nil { 285 + t.Fatal(err) 286 + } 287 + 288 + got, _ := os.ReadFile(current) 289 + if string(got) != "v2" { 290 + t.Fatalf("current = %q, want v2", string(got)) 291 + } 292 + 293 + versionsDir := filepath.Join(appimages, ".versions", "foo") 294 + entries, _ := os.ReadDir(versionsDir) 295 + if len(entries) != 1 { 296 + t.Fatalf("expected 1 saved version, got %d", len(entries)) 297 + } 298 + savedContent, _ := os.ReadFile(filepath.Join(versionsDir, entries[0].Name())) 299 + if string(savedContent) != "v1" { 300 + t.Fatalf("saved = %q, want v1", string(savedContent)) 301 + } 302 + } 303 + 304 + func TestInstallAppImageSavesPreviousVersionInFolder(t *testing.T) { 305 + a, home := newTestApp(t) 306 + appimages := filepath.Join(home, "AppImages") 307 + if err := os.MkdirAll(appimages, 0o755); err != nil { 308 + t.Fatal(err) 309 + } 310 + 311 + current := filepath.Join(appimages, "foo.appimage") 312 + if err := os.WriteFile(current, []byte("v1"), 0o644); err != nil { 313 + t.Fatal(err) 314 + } 315 + 316 + src := filepath.Join(appimages, "Foo-2.0.appimage") 317 + if err := os.WriteFile(src, []byte("v2"), 0o644); err != nil { 318 + t.Fatal(err) 319 + } 320 + 321 + _, err := a.installAppImage(src, "foo") 322 + if err != nil { 323 + t.Fatal(err) 324 + } 325 + 326 + got, _ := os.ReadFile(current) 327 + if string(got) != "v2" { 328 + t.Fatalf("current = %q, want v2", string(got)) 329 + } 330 + 331 + if _, err := os.Stat(src); !errors.Is(err, os.ErrNotExist) { 332 + t.Fatal("versioned source should be moved away") 333 + } 334 + 335 + versionsDir := filepath.Join(appimages, ".versions", "foo") 336 + entries, _ := os.ReadDir(versionsDir) 337 + if len(entries) != 1 { 338 + t.Fatalf("expected 1 saved version, got %d", len(entries)) 339 + } 340 + } 341 + 342 + func TestSaveToVersionsPrunesOldestVersions(t *testing.T) { 343 + a, home := newTestApp(t) 344 + appimages := filepath.Join(home, "AppImages") 345 + if err := os.MkdirAll(appimages, 0o755); err != nil { 346 + t.Fatal(err) 347 + } 348 + versionsDir := filepath.Join(appimages, ".versions", "foo") 349 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 350 + t.Fatal(err) 351 + } 352 + 353 + // Seed 3 existing versions with distinct mtimes. 354 + t1 := time.Now().Add(-3 * time.Hour) 355 + t2 := time.Now().Add(-2 * time.Hour) 356 + t3 := time.Now().Add(-1 * time.Hour) 357 + for _, spec := range []struct { 358 + name string 359 + mtime time.Time 360 + }{ 361 + {"v1.appimage", t1}, 362 + {"v2.appimage", t2}, 363 + {"v3.appimage", t3}, 364 + } { 365 + path := filepath.Join(versionsDir, spec.name) 366 + os.WriteFile(path, []byte(spec.name), 0o644) 367 + os.Chtimes(path, spec.mtime, spec.mtime) 368 + } 369 + 370 + current := filepath.Join(appimages, "foo.appimage") 371 + if err := os.WriteFile(current, []byte("current"), 0o644); err != nil { 372 + t.Fatal(err) 373 + } 374 + 375 + // Saving a 4th version should prune v1 (oldest) 376 + if err := a.saveToVersions(current, "foo"); err != nil { 377 + t.Fatal(err) 378 + } 379 + 380 + entries, _ := os.ReadDir(versionsDir) 381 + if len(entries) != 3 { 382 + t.Fatalf("expected 3 versions after prune, got %d", len(entries)) 383 + } 384 + for _, entry := range entries { 385 + if entry.Name() == "v1.appimage" { 386 + t.Fatal("v1.appimage should have been pruned") 387 + } 388 + } 389 + }
+82
internal/appherder/rollback.go
··· 1 + package appherder 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "time" 9 + ) 10 + 11 + // Rollback restores a saved version of the app. When version is "", it picks 12 + // the most recently saved one (by mtime). 13 + func (a App) Rollback(appName string, version string) error { 14 + appName = NormalizeAppName(appName) 15 + versionsDir := filepath.Join(a.appimagesDir, ".versions", appName) 16 + current := filepath.Join(a.appimagesDir, appName+".appimage") 17 + 18 + if version == "" { 19 + var err error 20 + version, err = newestVersion(versionsDir) 21 + if err != nil { 22 + return err 23 + } 24 + } 25 + 26 + saved := filepath.Join(versionsDir, version+".appimage") 27 + if _, err := os.Stat(saved); os.IsNotExist(err) { 28 + return fmt.Errorf("version %q not found for %s", version, appName) 29 + } 30 + 31 + if _, err := os.Stat(current); err == nil { 32 + currentVersion := readAppImageVersion(current) 33 + currentSaved := filepath.Join(versionsDir, currentVersion+".appimage") 34 + if currentSaved != saved { 35 + if err := os.Rename(current, currentSaved); err != nil { 36 + return fmt.Errorf("save current version before rollback: %w", err) 37 + } 38 + } 39 + } 40 + 41 + if err := os.Rename(saved, current); err != nil { 42 + return fmt.Errorf("restore version %s: %w", version, err) 43 + } 44 + if err := os.Chmod(current, 0o755); err != nil { 45 + return fmt.Errorf("make AppImage executable: %w", err) 46 + } 47 + return nil 48 + } 49 + 50 + // newestVersion returns the name (without .appimage) of the most recently 51 + // modified saved version. 52 + func newestVersion(versionsDir string) (string, error) { 53 + entries, err := os.ReadDir(versionsDir) 54 + if err != nil { 55 + if os.IsNotExist(err) { 56 + return "", fmt.Errorf("no saved versions in %s", filepath.Dir(versionsDir)) 57 + } 58 + return "", err 59 + } 60 + var newest string 61 + var newestTime time.Time 62 + for _, entry := range entries { 63 + if entry.IsDir() { 64 + continue 65 + } 66 + if !strings.EqualFold(filepath.Ext(entry.Name()), ".appimage") { 67 + continue 68 + } 69 + info, err := entry.Info() 70 + if err != nil { 71 + continue 72 + } 73 + if info.ModTime().After(newestTime) { 74 + newest = entry.Name() 75 + newestTime = info.ModTime() 76 + } 77 + } 78 + if newest == "" { 79 + return "", fmt.Errorf("no saved versions in %s", filepath.Dir(versionsDir)) 80 + } 81 + return strings.TrimSuffix(newest, ".appimage"), nil 82 + }
+210
internal/appherder/rollback_test.go
··· 1 + package appherder 2 + 3 + import ( 4 + "errors" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + func TestRollbackWithExplicitVersion(t *testing.T) { 12 + a, home := newTestApp(t) 13 + appimages := filepath.Join(home, "AppImages") 14 + if err := os.MkdirAll(appimages, 0o755); err != nil { 15 + t.Fatal(err) 16 + } 17 + 18 + versionsDir := filepath.Join(appimages, ".versions", "foo") 19 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 20 + t.Fatal(err) 21 + } 22 + if err := os.WriteFile(filepath.Join(versionsDir, "v1.0.appimage"), []byte("old"), 0o644); err != nil { 23 + t.Fatal(err) 24 + } 25 + 26 + current := filepath.Join(appimages, "foo.appimage") 27 + if err := os.WriteFile(current, []byte("current"), 0o644); err != nil { 28 + t.Fatal(err) 29 + } 30 + 31 + if err := a.Rollback("foo", "v1.0"); err != nil { 32 + t.Fatal(err) 33 + } 34 + 35 + got, _ := os.ReadFile(current) 36 + if string(got) != "old" { 37 + t.Fatalf("current = %q, want old", string(got)) 38 + } 39 + } 40 + 41 + func TestRollbackPicksMostRecentByMtime(t *testing.T) { 42 + a, home := newTestApp(t) 43 + appimages := filepath.Join(home, "AppImages") 44 + if err := os.MkdirAll(appimages, 0o755); err != nil { 45 + t.Fatal(err) 46 + } 47 + 48 + versionsDir := filepath.Join(appimages, ".versions", "foo") 49 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 50 + t.Fatal(err) 51 + } 52 + 53 + v1 := filepath.Join(versionsDir, "v1.appimage") 54 + v2 := filepath.Join(versionsDir, "v2.appimage") 55 + if err := os.WriteFile(v1, []byte("oldest"), 0o644); err != nil { 56 + t.Fatal(err) 57 + } 58 + if err := os.WriteFile(v2, []byte("older"), 0o644); err != nil { 59 + t.Fatal(err) 60 + } 61 + 62 + t1 := time.Now().Add(-2 * time.Hour) 63 + t2 := time.Now().Add(-1 * time.Hour) 64 + os.Chtimes(v1, t1, t1) 65 + os.Chtimes(v2, t2, t2) 66 + 67 + current := filepath.Join(appimages, "foo.appimage") 68 + if err := os.WriteFile(current, []byte("current"), 0o644); err != nil { 69 + t.Fatal(err) 70 + } 71 + 72 + if err := a.Rollback("foo", ""); err != nil { 73 + t.Fatal(err) 74 + } 75 + 76 + got, _ := os.ReadFile(current) 77 + if string(got) != "older" { 78 + t.Fatalf("current = %q, want older (most recent mtime)", string(got)) 79 + } 80 + } 81 + 82 + func TestRollbackSavesCurrentVersionBeforeRestoring(t *testing.T) { 83 + a, home := newTestApp(t) 84 + appimages := filepath.Join(home, "AppImages") 85 + if err := os.MkdirAll(appimages, 0o755); err != nil { 86 + t.Fatal(err) 87 + } 88 + 89 + versionsDir := filepath.Join(appimages, ".versions", "foo") 90 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 91 + t.Fatal(err) 92 + } 93 + if err := os.WriteFile(filepath.Join(versionsDir, "v1.appimage"), []byte("old"), 0o644); err != nil { 94 + t.Fatal(err) 95 + } 96 + 97 + current := filepath.Join(appimages, "foo.appimage") 98 + if err := os.WriteFile(current, []byte("current"), 0o644); err != nil { 99 + t.Fatal(err) 100 + } 101 + 102 + if err := a.Rollback("foo", "v1"); err != nil { 103 + t.Fatal(err) 104 + } 105 + 106 + // Current should now be "old" 107 + got, _ := os.ReadFile(current) 108 + if string(got) != "old" { 109 + t.Fatalf("current = %q, want old", string(got)) 110 + } 111 + 112 + // Previous current should be saved; the restored version moves to active. 113 + entries, _ := os.ReadDir(versionsDir) 114 + if len(entries) != 1 { 115 + t.Fatalf("expected 1 saved version (pre-rollback current), got %d", len(entries)) 116 + } 117 + } 118 + 119 + func TestRollbackErrorOnMissingVersion(t *testing.T) { 120 + a, _ := newTestApp(t) 121 + if err := a.Rollback("foo", "v99"); err == nil { 122 + t.Fatal("expected error for nonexistent version") 123 + } 124 + } 125 + 126 + func TestRollbackErrorOnNoSavedVersions(t *testing.T) { 127 + a, _ := newTestApp(t) 128 + if err := a.Rollback("foo", ""); err == nil { 129 + t.Fatal("expected error when no saved versions exist") 130 + } 131 + } 132 + 133 + func TestNewestVersionPicksByMtime(t *testing.T) { 134 + dir := t.TempDir() 135 + v1 := filepath.Join(dir, "v1.appimage") 136 + v2 := filepath.Join(dir, "v2.appimage") 137 + if err := os.WriteFile(v1, []byte("x"), 0o644); err != nil { 138 + t.Fatal(err) 139 + } 140 + if err := os.WriteFile(v2, []byte("x"), 0o644); err != nil { 141 + t.Fatal(err) 142 + } 143 + 144 + t1 := time.Now().Add(-2 * time.Hour) 145 + t2 := time.Now().Add(-1 * time.Hour) 146 + os.Chtimes(v1, t1, t1) 147 + os.Chtimes(v2, t2, t2) 148 + 149 + got, err := newestVersion(dir) 150 + if err != nil { 151 + t.Fatal(err) 152 + } 153 + if got != "v2" { 154 + t.Fatalf("newestVersion = %q, want v2", got) 155 + } 156 + } 157 + 158 + func TestNewestVersionSkipsDirectoriesAndNonAppImages(t *testing.T) { 159 + dir := t.TempDir() 160 + os.Mkdir(filepath.Join(dir, "subdir"), 0o755) 161 + os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644) 162 + os.WriteFile(filepath.Join(dir, "v1.appimage"), []byte("x"), 0o644) 163 + 164 + got, err := newestVersion(dir) 165 + if err != nil { 166 + t.Fatal(err) 167 + } 168 + if got != "v1" { 169 + t.Fatalf("newestVersion = %q, want v1", got) 170 + } 171 + } 172 + 173 + func TestNewestVersionErrorOnEmptyDir(t *testing.T) { 174 + dir := t.TempDir() 175 + if _, err := newestVersion(dir); err == nil { 176 + t.Fatal("expected error for empty directory") 177 + } 178 + } 179 + 180 + func TestNewestVersionErrorOnMissingDir(t *testing.T) { 181 + if _, err := newestVersion("/nonexistent"); err == nil { 182 + t.Fatal("expected error for missing directory") 183 + } 184 + } 185 + 186 + func TestUninstallCleansUpVersions(t *testing.T) { 187 + a, home := newTestApp(t) 188 + appimages := filepath.Join(home, "AppImages") 189 + versionsDir := filepath.Join(appimages, ".versions", "foo") 190 + if err := os.MkdirAll(versionsDir, 0o755); err != nil { 191 + t.Fatal(err) 192 + } 193 + if err := os.WriteFile(filepath.Join(versionsDir, "v1.appimage"), []byte("x"), 0o644); err != nil { 194 + t.Fatal(err) 195 + } 196 + if err := os.WriteFile(filepath.Join(versionsDir, "v2.appimage"), []byte("x"), 0o644); err != nil { 197 + t.Fatal(err) 198 + } 199 + 200 + // Uninstall needs a managed desktop entry to succeed without --force. 201 + writeManagedDesktop(t, home, "foo") 202 + 203 + if err := a.Uninstall("foo", false); err != nil { 204 + t.Fatal(err) 205 + } 206 + 207 + if _, err := os.Stat(versionsDir); !errors.Is(err, os.ErrNotExist) { 208 + t.Fatal(".versions/ directory should be removed by uninstall") 209 + } 210 + }
+2 -2
internal/appherder/uninstall.go
··· 17 17 if err != nil { 18 18 return err 19 19 } 20 - // Leave launchers we didn't install (a name clash, or a pre-marker 21 - // install); --force overrides this. 22 20 if !managed { 23 21 continue 24 22 } ··· 27 25 return fmt.Errorf("remove %s: %w", path, err) 28 26 } 29 27 } 28 + 29 + _ = os.RemoveAll(filepath.Join(a.appimagesDir, ".versions", appName)) 30 30 31 31 return nil 32 32 }