A shepherd for your Appimages.
0

Configure Feed

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

sync: make concurrent

Aly Raffauf (Jun 17, 2026, 10:19 PM EDT) 8e6d3e6e 4c6f6eff

+60 -14
+2 -2
cmd/appherder/cli.go
··· 66 66 Short: "Reconcile ~/AppImages with installed applications", 67 67 Args: cobra.NoArgs, 68 68 RunE: func(cmd *cobra.Command, args []string) error { 69 - return a.sync(cmd.OutOrStdout(), force) 69 + return a.sync(cmd.Context(), cmd.OutOrStdout(), force) 70 70 }, 71 71 } 72 72 cmd.Flags().BoolVarP(&force, "force", "f", false, ··· 80 80 Short: "Adopt launchers from another tool and remove their broken orphans", 81 81 Args: cobra.NoArgs, 82 82 RunE: func(cmd *cobra.Command, args []string) error { 83 - return a.sync(cmd.OutOrStdout(), true) 83 + return a.sync(cmd.Context(), cmd.OutOrStdout(), true) 84 84 }, 85 85 } 86 86 }
+18 -5
cmd/appherder/sync.go
··· 1 1 package main 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "io" 6 7 "os" ··· 9 10 "strings" 10 11 ) 11 12 13 + // installConcurrency caps parallel installs: enough to overlap metadata I/O 14 + // and squashfs decompression without copying several 200 MB binaries at once. 15 + const installConcurrency = 4 16 + 17 + type syncResult struct { 18 + file string 19 + err error 20 + } 21 + 12 22 // sync reconciles ~/AppImages with installed state: install every AppImage in 13 23 // the folder, remove launchers whose AppImage is gone. With force, also remove 14 24 // unmanaged launchers whose TryExec/Exec points at a missing file in ~/AppImages 15 25 // (entries left by another tool). Per-file errors are reported and skipped so 16 26 // one bad file does not abort the pass. 17 - func (a app) sync(out io.Writer, force bool) error { 27 + func (a app) sync(ctx context.Context, out io.Writer, force bool) error { 18 28 home, err := a.homeDir() 19 29 if err != nil { 20 30 return fmt.Errorf("resolve home directory: %w", err) ··· 26 36 return err 27 37 } 28 38 29 - for _, f := range files { 30 - if err := a.install(f); err != nil { 31 - fmt.Fprintf(out, "skip %s: %v\n", filepath.Base(f), err) 32 - continue 39 + // parallelMap preserves input order, so skip output stays deterministic. 40 + results := parallelMap(ctx, files, installConcurrency, func(_ context.Context, f string) syncResult { 41 + return syncResult{file: f, err: a.install(f)} 42 + }) 43 + for _, r := range results { 44 + if r.err != nil { 45 + fmt.Fprintf(out, "skip %s: %v\n", filepath.Base(r.file), r.err) 33 46 } 34 47 } 35 48
+40 -7
cmd/appherder/sync_test.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "context" 5 6 "errors" 6 7 "os" 7 8 "path/filepath" 9 + "strings" 8 10 "testing" 9 11 ) 10 12 ··· 29 31 30 32 var out bytes.Buffer 31 33 a := app{homeDir: func() (string, error) { return home, nil }} 32 - if err := a.sync(&out, false); err != nil { 34 + if err := a.sync(context.Background(), &out, false); err != nil { 33 35 t.Fatal(err) 34 36 } 35 37 ··· 56 58 57 59 var out bytes.Buffer 58 60 a := app{homeDir: func() (string, error) { return home, nil }} 59 - if err := a.sync(&out, false); err != nil { 61 + if err := a.sync(context.Background(), &out, false); err != nil { 60 62 t.Fatal(err) 61 63 } 62 64 ··· 86 88 a := app{homeDir: func() (string, error) { return home, nil }} 87 89 for _, force := range []bool{false, true} { 88 90 var out bytes.Buffer 89 - if err := a.sync(&out, force); err != nil { 91 + if err := a.sync(context.Background(), &out, force); err != nil { 90 92 t.Fatal(err) 91 93 } 92 94 if _, err := os.Stat(handmade); err != nil { ··· 114 116 115 117 var out bytes.Buffer 116 118 a := app{homeDir: func() (string, error) { return home, nil }} 117 - if err := a.sync(&out, true); err != nil { 119 + if err := a.sync(context.Background(), &out, true); err != nil { 118 120 t.Fatal(err) 119 121 } 120 122 if _, err := os.Stat(filepath.Join(dir, "gone.desktop")); !errors.Is(err, os.ErrNotExist) { ··· 147 149 148 150 var out bytes.Buffer 149 151 a := app{homeDir: func() (string, error) { return home, nil }} 150 - if err := a.sync(&out, true); err != nil { 152 + if err := a.sync(context.Background(), &out, true); err != nil { 151 153 t.Fatal(err) 152 154 } 153 155 if _, err := os.Stat(filepath.Join(dir, "present.desktop")); err != nil { ··· 170 172 171 173 var out bytes.Buffer 172 174 a := app{homeDir: func() (string, error) { return home, nil }} 173 - if err := a.sync(&out, false); err != nil { 175 + if err := a.sync(context.Background(), &out, false); err != nil { 174 176 t.Fatal(err) 175 177 } 176 178 if out.Len() != 0 { ··· 184 186 185 187 var out bytes.Buffer 186 188 a := app{homeDir: func() (string, error) { return home, nil }} 187 - if err := a.sync(&out, false); err != nil { 189 + if err := a.sync(context.Background(), &out, false); err != nil { 188 190 t.Fatal(err) 189 191 } 190 192 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "orphan.desktop")); !errors.Is(err, os.ErrNotExist) { 191 193 t.Fatalf("missing ~/AppImages should still reconcile orphans away, stat err: %v", err) 192 194 } 193 195 } 196 + 197 + func TestSyncReportsSkipsInInputOrder(t *testing.T) { 198 + home := t.TempDir() 199 + appimages := filepath.Join(home, "AppImages") 200 + if err := os.MkdirAll(appimages, 0o755); err != nil { 201 + t.Fatal(err) 202 + } 203 + // Concurrent installs must still produce skip lines in input order. 204 + for _, name := range []string{"charlie.appimage", "alpha.appimage", "bravo.appimage"} { 205 + if err := os.WriteFile(filepath.Join(appimages, name), []byte("not an appimage"), 0o644); err != nil { 206 + t.Fatal(err) 207 + } 208 + } 209 + 210 + var out bytes.Buffer 211 + a := app{homeDir: func() (string, error) { return home, nil }} 212 + if err := a.sync(context.Background(), &out, false); err != nil { 213 + t.Fatal(err) 214 + } 215 + 216 + got := out.String() 217 + want := []string{"alpha.appimage", "bravo.appimage", "charlie.appimage"} 218 + pos := 0 219 + for _, w := range want { 220 + i := strings.Index(got[pos:], w) 221 + if i < 0 { 222 + t.Fatalf("output missing %s in order:\n%s", w, got) 223 + } 224 + pos += i + len(w) 225 + } 226 + }