A shepherd for your Appimages.
0

Configure Feed

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

allow migrations

Aly Raffauf (Jun 17, 2026, 7:11 PM EDT) d2e28afe 5a69db91

+170 -20
+18 -3
cmd/appherder/cli.go
··· 15 15 } 16 16 cmd.SetOut(stdout) 17 17 cmd.SetErr(stderr) 18 - cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a)) 18 + cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a)) 19 19 return cmd 20 20 } 21 21 ··· 46 46 } 47 47 48 48 func newSyncCommand(a app) *cobra.Command { 49 - return &cobra.Command{ 49 + var force bool 50 + cmd := &cobra.Command{ 50 51 Use: "sync", 51 52 Short: "Reconcile ~/AppImages with installed applications", 52 53 Args: cobra.NoArgs, 53 54 RunE: func(cmd *cobra.Command, args []string) error { 54 - return a.sync(cmd.OutOrStdout()) 55 + return a.sync(cmd.OutOrStdout(), force) 56 + }, 57 + } 58 + cmd.Flags().BoolVarP(&force, "force", "f", false, 59 + "Also remove launchers appherder didn't install, when their AppImage is gone") 60 + return cmd 61 + } 62 + 63 + func newMigrateCommand(a app) *cobra.Command { 64 + return &cobra.Command{ 65 + Use: "migrate", 66 + Short: "Adopt launchers from another tool and remove their broken orphans", 67 + Args: cobra.NoArgs, 68 + RunE: func(cmd *cobra.Command, args []string) error { 69 + return a.sync(cmd.OutOrStdout(), true) 55 70 }, 56 71 } 57 72 }
+20
cmd/appherder/exec.go
··· 57 57 return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, "-") 58 58 } 59 59 60 + // execPath returns the executable path from a desktop Exec/TryExec command, 61 + // skipping a leading env and KEY=VALUE assignments. 62 + func execPath(cmd string) string { 63 + tokens, err := shellquote.Split(cmd) 64 + if err != nil { 65 + return "" 66 + } 67 + i := 0 68 + if i < len(tokens) && tokens[i] == "env" { 69 + i++ 70 + } 71 + for i < len(tokens) && isEnvVar(tokens[i]) { 72 + i++ 73 + } 74 + if i < len(tokens) { 75 + return tokens[i] 76 + } 77 + return "" 78 + } 79 + 60 80 func isStrippedDesktopExecCode(token string) bool { 61 81 return token == "%i" || token == "%c" || token == "%k" 62 82 }
+61 -9
cmd/appherder/sync.go
··· 10 10 ) 11 11 12 12 // sync reconciles ~/AppImages with installed state: install every AppImage in 13 - // the folder, remove managed launchers whose AppImage is gone. Per-file errors 14 - // are reported and skipped so one bad file does not abort the pass. 15 - func (a app) sync(out io.Writer) error { 13 + // the folder, remove launchers whose AppImage is gone. With force, also remove 14 + // unmanaged launchers whose TryExec/Exec points at a missing file in ~/AppImages 15 + // (entries left by another tool). Per-file errors are reported and skipped so 16 + // one bad file does not abort the pass. 17 + func (a app) sync(out io.Writer, force bool) error { 16 18 home, err := a.homeDir() 17 19 if err != nil { 18 20 return fmt.Errorf("resolve home directory: %w", err) ··· 31 33 } 32 34 } 33 35 34 - managed, err := managedApps(home) 36 + candidates, err := managedApps(home) 35 37 if err != nil { 36 38 return err 37 39 } 38 - for _, appid := range managed { 39 - // Look for the managed app's AppImage under any extension casing. 40 + if force { 41 + extra, err := appImageBackedOrphans(home, appimagesDir) 42 + if err != nil { 43 + return err 44 + } 45 + candidates = append(candidates, extra...) 46 + } 47 + for _, appid := range candidates { 40 48 present, err := appImagePresent(appimagesDir, appid) 41 49 if err != nil { 42 50 return err ··· 44 52 if present { 45 53 continue 46 54 } 47 - if err := a.uninstall(appid, false); err != nil { 55 + if err := a.uninstall(appid, force); err != nil { 48 56 fmt.Fprintf(out, "skip remove %s: %v\n", appid, err) 49 57 continue 50 58 } ··· 78 86 } 79 87 80 88 // appImagePresent reports whether <appid>.appimage exists in dir, matching the 81 - // extension case-insensitively. Install normalizes the name to <appid>.appimage 82 - // on success; on install failure the original casing may remain. 89 + // extension case-insensitively. 83 90 func appImagePresent(dir, appid string) (bool, error) { 84 91 entries, err := os.ReadDir(dir) 85 92 if err != nil { ··· 98 105 } 99 106 return false, nil 100 107 } 108 + 109 + // appImageBackedOrphans returns appids of unmanaged desktop entries whose 110 + // TryExec or Exec points at a missing file inside appimagesDir — launchers left 111 + // by another tool after their AppImage was deleted. 112 + func appImageBackedOrphans(home, appimagesDir string) ([]string, error) { 113 + appsDir := filepath.Join(home, ".local", "share", "applications") 114 + matches, err := filepath.Glob(filepath.Join(appsDir, "*.desktop")) 115 + if err != nil { 116 + return nil, err 117 + } 118 + prefix := appimagesDir + string(filepath.Separator) 119 + var orphans []string 120 + for _, path := range matches { 121 + desktop, err := readDesktopFile(path) 122 + if err != nil { 123 + return nil, fmt.Errorf("read desktop file %s: %w", path, err) 124 + } 125 + if v, ok := desktop.get(desktopOwnerKey, desktopEntrySection); ok && v == "true" { 126 + continue 127 + } 128 + target := desktopTarget(desktop) 129 + if target == "" || !strings.HasPrefix(target, prefix) { 130 + continue 131 + } 132 + if _, err := os.Stat(target); err == nil { 133 + continue 134 + } else if !os.IsNotExist(err) { 135 + return nil, err 136 + } 137 + orphans = append(orphans, strings.TrimSuffix(filepath.Base(path), ".desktop")) 138 + } 139 + return orphans, nil 140 + } 141 + 142 + // desktopTarget returns the executable path a launcher points at, preferring 143 + // TryExec. 144 + func desktopTarget(desktop *desktopFile) string { 145 + if tryExec, ok := desktop.get("TryExec", desktopEntrySection); ok && tryExec != "" { 146 + return tryExec 147 + } 148 + if exec, ok := desktop.get("Exec", desktopEntrySection); ok { 149 + return execPath(exec) 150 + } 151 + return "" 152 + }
+71 -8
cmd/appherder/sync_test.go
··· 29 29 30 30 var out bytes.Buffer 31 31 a := app{homeDir: func() (string, error) { return home, nil }} 32 - if err := a.sync(&out); err != nil { 32 + if err := a.sync(&out, false); err != nil { 33 33 t.Fatal(err) 34 34 } 35 35 ··· 56 56 57 57 var out bytes.Buffer 58 58 a := app{homeDir: func() (string, error) { return home, nil }} 59 - if err := a.sync(&out); err != nil { 59 + if err := a.sync(&out, false); err != nil { 60 60 t.Fatal(err) 61 61 } 62 62 ··· 77 77 if err := os.MkdirAll(dir, 0o755); err != nil { 78 78 t.Fatal(err) 79 79 } 80 - if err := os.WriteFile(filepath.Join(dir, "handmade.desktop"), []byte("[Desktop Entry]\nName=Mine\n"), 0o644); err != nil { 80 + // No TryExec under ~/AppImages: --force must leave it alone. 81 + handmade := filepath.Join(dir, "handmade.desktop") 82 + if err := os.WriteFile(handmade, []byte("[Desktop Entry]\nName=Mine\nTryExec=/usr/bin/foo\n"), 0o644); err != nil { 83 + t.Fatal(err) 84 + } 85 + 86 + a := app{homeDir: func() (string, error) { return home, nil }} 87 + for _, force := range []bool{false, true} { 88 + var out bytes.Buffer 89 + if err := a.sync(&out, force); err != nil { 90 + t.Fatal(err) 91 + } 92 + if _, err := os.Stat(handmade); err != nil { 93 + t.Fatalf("force=%v: unmanaged launcher must not be touched, stat err: %v", force, err) 94 + } 95 + } 96 + } 97 + 98 + func TestSyncForceRemovesAppImageBackedOrphan(t *testing.T) { 99 + home := t.TempDir() 100 + appimages := filepath.Join(home, "AppImages") 101 + if err := os.MkdirAll(appimages, 0o755); err != nil { 102 + t.Fatal(err) 103 + } 104 + dir := filepath.Join(home, ".local", "share", "applications") 105 + if err := os.MkdirAll(dir, 0o755); err != nil { 106 + t.Fatal(err) 107 + } 108 + // Unmanaged launcher pointing at a missing AppImage: --force removes it. 109 + target := filepath.Join(appimages, "gone.appimage") 110 + if err := os.WriteFile(filepath.Join(dir, "gone.desktop"), 111 + []byte("[Desktop Entry]\nName=Gone\nTryExec="+target+"\n"), 0o644); err != nil { 81 112 t.Fatal(err) 82 113 } 83 114 84 115 var out bytes.Buffer 85 116 a := app{homeDir: func() (string, error) { return home, nil }} 86 - if err := a.sync(&out); err != nil { 117 + if err := a.sync(&out, true); err != nil { 118 + t.Fatal(err) 119 + } 120 + if _, err := os.Stat(filepath.Join(dir, "gone.desktop")); !errors.Is(err, os.ErrNotExist) { 121 + t.Fatalf("--force should remove the AppImage-backed orphan, stat err: %v", err) 122 + } 123 + if !bytes.Contains(out.Bytes(), []byte("removed gone")) { 124 + t.Fatalf("sync output = %q, want removal reported", out.String()) 125 + } 126 + } 127 + 128 + func TestSyncForceKeepsOrphanWhenAppImageStillPresent(t *testing.T) { 129 + home := t.TempDir() 130 + appimages := filepath.Join(home, "AppImages") 131 + if err := os.MkdirAll(appimages, 0o755); err != nil { 132 + t.Fatal(err) 133 + } 134 + dir := filepath.Join(home, ".local", "share", "applications") 135 + if err := os.MkdirAll(dir, 0o755); err != nil { 136 + t.Fatal(err) 137 + } 138 + // Present (if unparseable) AppImage: --force keeps it; install adopts it. 139 + target := filepath.Join(appimages, "present.appimage") 140 + if err := os.WriteFile(target, []byte("not an appimage"), 0o644); err != nil { 141 + t.Fatal(err) 142 + } 143 + if err := os.WriteFile(filepath.Join(dir, "present.desktop"), 144 + []byte("[Desktop Entry]\nName=Present\nTryExec="+target+"\n"), 0o644); err != nil { 87 145 t.Fatal(err) 88 146 } 89 147 90 - if _, err := os.Stat(filepath.Join(dir, "handmade.desktop")); err != nil { 91 - t.Fatalf("unmanaged launcher must not be touched by sync: %v", err) 148 + var out bytes.Buffer 149 + a := app{homeDir: func() (string, error) { return home, nil }} 150 + if err := a.sync(&out, true); err != nil { 151 + t.Fatal(err) 152 + } 153 + if _, err := os.Stat(filepath.Join(dir, "present.desktop")); err != nil { 154 + t.Fatalf("--force must keep a launcher whose AppImage is still present: %v", err) 92 155 } 93 156 } 94 157 ··· 107 170 108 171 var out bytes.Buffer 109 172 a := app{homeDir: func() (string, error) { return home, nil }} 110 - if err := a.sync(&out); err != nil { 173 + if err := a.sync(&out, false); err != nil { 111 174 t.Fatal(err) 112 175 } 113 176 if out.Len() != 0 { ··· 121 184 122 185 var out bytes.Buffer 123 186 a := app{homeDir: func() (string, error) { return home, nil }} 124 - if err := a.sync(&out); err != nil { 187 + if err := a.sync(&out, false); err != nil { 125 188 t.Fatal(err) 126 189 } 127 190 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "orphan.desktop")); !errors.Is(err, os.ErrNotExist) {