A shepherd for your Appimages.
0

Configure Feed

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

init

Aly Raffauf (Jun 17, 2026, 2:50 PM EDT) 82b204fd

+982
+1
.envrc
··· 1 + use flake
+28
.github/workflows/tests.yml
··· 1 + name: Tests 2 + 3 + on: 4 + push: 5 + pull_request: 6 + 7 + jobs: 8 + test: 9 + runs-on: ubuntu-latest 10 + 11 + steps: 12 + - name: Check out repository 13 + uses: actions/checkout@v5 14 + 15 + - name: Set up Go 16 + uses: actions/setup-go@v6 17 + with: 18 + go-version-file: go.mod 19 + cache: true 20 + 21 + - name: Run tests 22 + run: go test ./src 23 + 24 + - name: Vet 25 + run: go vet ./src 26 + 27 + - name: Build 28 + run: go build -o /tmp/appherder ./src
+3
.gitignore
··· 1 + squashfs-root/ 2 + *.appimage 3 + /appherder
+1
.python-version
··· 1 + 3.14
README.md

This is a binary file and will not be displayed.

+25
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1780902259, 6 + "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", 7 + "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", 8 + "revCount": 1005841, 9 + "type": "tarball", 10 + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1005841%2Brev-bd0ff2d3eac24699c3664d5966b9ef36f388e2ca/019ea877-a51e-7d63-a6c5-85b9b069aa6c/source.tar.gz" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://flakehub.com/f/NixOS/nixpkgs/0" 15 + } 16 + }, 17 + "root": { 18 + "inputs": { 19 + "nixpkgs": "nixpkgs" 20 + } 21 + } 22 + }, 23 + "root": "root", 24 + "version": 7 25 + }
+49
flake.nix
··· 1 + { 2 + description = "A herder for your Appimages"; 3 + 4 + inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0"; 5 + 6 + outputs = {self, ...} @ inputs: let 7 + inherit (inputs.nixpkgs) lib; 8 + 9 + supportedSystems = [ 10 + "x86_64-linux" 11 + "aarch64-linux" 12 + "aarch64-darwin" 13 + ]; 14 + 15 + forEachSupportedSystem = f: 16 + lib.genAttrs supportedSystems ( 17 + system: 18 + f { 19 + inherit system; 20 + pkgs = import inputs.nixpkgs { 21 + inherit system; 22 + config.allowUnfree = true; 23 + }; 24 + } 25 + ); 26 + in { 27 + devShells = forEachSupportedSystem ( 28 + { 29 + pkgs, 30 + system, 31 + }: { 32 + default = pkgs.mkShellNoCC { 33 + packages = with pkgs; [ 34 + go 35 + self.formatter.${system} 36 + ]; 37 + }; 38 + } 39 + ); 40 + 41 + formatter = forEachSupportedSystem ({pkgs, ...}: pkgs.alejandra); 42 + 43 + packages = forEachSupportedSystem ( 44 + {pkgs, ...}: { 45 + default = pkgs.callPackage ./package.nix {}; 46 + } 47 + ); 48 + }; 49 + }
+5
go.mod
··· 1 + module github.com/alyraffauf/appherder 2 + 3 + go 1.24 4 + 5 + require github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
+2
go.sum
··· 1 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 2 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+15
package.nix
··· 1 + { 2 + buildGoModule, 3 + lib, 4 + }: 5 + buildGoModule { 6 + pname = "appherder"; 7 + version = "dev"; 8 + src = ./.; 9 + vendorHash = "sha256-oXy9rgCRpuNHsqLW2sRylUamjPjOcHjC66noG4koLXk="; 10 + subPackages = ["src"]; 11 + 12 + postInstall = '' 13 + mv "$out/bin/src" "$out/bin/appherder" 14 + ''; 15 + }
+40
src/app.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "strings" 9 + "time" 10 + ) 11 + 12 + const extractTimeout = 2 * time.Minute 13 + 14 + type app struct { 15 + homeDir func() (string, error) 16 + run func(name string, args []string, dir string) error 17 + } 18 + 19 + func newApp() app { 20 + return app{ 21 + homeDir: os.UserHomeDir, 22 + run: runCommand, 23 + } 24 + } 25 + 26 + func runCommand(name string, args []string, dir string) error { 27 + ctx, cancel := context.WithTimeout(context.Background(), extractTimeout) 28 + defer cancel() 29 + 30 + cmd := exec.CommandContext(ctx, name, args...) 31 + cmd.Dir = dir 32 + output, err := cmd.CombinedOutput() 33 + if ctx.Err() == context.DeadlineExceeded { 34 + return fmt.Errorf("timed out after %s", extractTimeout) 35 + } 36 + if err != nil && len(output) > 0 { 37 + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) 38 + } 39 + return err 40 + }
+73
src/appimage.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "path/filepath" 8 + ) 9 + 10 + func (a app) extractAppImage(file string, dest string) (string, error) { 11 + file, err := filepath.Abs(file) 12 + if err != nil { 13 + return "", fmt.Errorf("resolve AppImage path %q: %w", file, err) 14 + } 15 + if err := os.Chmod(file, 0o755); err != nil { 16 + return "", fmt.Errorf("make AppImage executable %s: %w", file, err) 17 + } 18 + if err := a.run(file, []string{"--appimage-extract"}, dest); err != nil { 19 + return "", fmt.Errorf("extract AppImage %s into %s: %w", file, dest, err) 20 + } 21 + return filepath.Join(dest, "squashfs-root"), nil 22 + } 23 + 24 + func (a app) installAppImage(file string, appName string) (string, error) { 25 + home, err := a.homeDir() 26 + if err != nil { 27 + return "", fmt.Errorf("resolve home directory: %w", err) 28 + } 29 + 30 + appimagesDir := filepath.Join(home, "AppImages") 31 + if err := os.MkdirAll(appimagesDir, 0o755); err != nil { 32 + return "", fmt.Errorf("create AppImages directory %s: %w", appimagesDir, err) 33 + } 34 + 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) 59 + } 60 + 61 + return dest, nil 62 + } 63 + 64 + func copyTo(src string, dest io.Writer) error { 65 + in, err := os.Open(src) 66 + if err != nil { 67 + return fmt.Errorf("open source %s: %w", src, err) 68 + } 69 + defer in.Close() 70 + 71 + _, err = io.Copy(dest, in) 72 + return err 73 + }
+128
src/appimage_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "reflect" 7 + "testing" 8 + ) 9 + 10 + func TestExtractAppImageUsesDestinationAsWorkingDirectory(t *testing.T) { 11 + dir := t.TempDir() 12 + source := filepath.Join(dir, "source.appimage") 13 + extractDest := filepath.Join(dir, "extract") 14 + if err := os.WriteFile(source, []byte("appimage"), 0o644); err != nil { 15 + t.Fatal(err) 16 + } 17 + if err := os.Mkdir(extractDest, 0o755); err != nil { 18 + t.Fatal(err) 19 + } 20 + 21 + var gotName string 22 + var gotArgs []string 23 + var gotDir string 24 + a := app{ 25 + run: func(name string, args []string, dir string) error { 26 + gotName = name 27 + gotArgs = args 28 + gotDir = dir 29 + return nil 30 + }, 31 + } 32 + 33 + extracted, err := a.extractAppImage(source, extractDest) 34 + if err != nil { 35 + t.Fatal(err) 36 + } 37 + 38 + absSource, err := filepath.Abs(source) 39 + if err != nil { 40 + t.Fatal(err) 41 + } 42 + if gotName != absSource { 43 + t.Fatalf("command name = %q, want %q", gotName, absSource) 44 + } 45 + if !reflect.DeepEqual(gotArgs, []string{"--appimage-extract"}) { 46 + t.Fatalf("command args = %#v", gotArgs) 47 + } 48 + if gotDir != extractDest { 49 + t.Fatalf("command dir = %q, want %q", gotDir, extractDest) 50 + } 51 + if extracted != filepath.Join(extractDest, "squashfs-root") { 52 + t.Fatalf("extracted = %q", extracted) 53 + } 54 + if mode := fileMode(source); mode != 0o755 { 55 + t.Fatalf("source mode = %#o, want 0755", mode) 56 + } 57 + } 58 + 59 + func TestInstallAppImageCopiesToAppImagesDir(t *testing.T) { 60 + dir := t.TempDir() 61 + home := filepath.Join(dir, "home") 62 + source := filepath.Join(dir, "source.appimage") 63 + if err := os.WriteFile(source, []byte("appimage"), 0o644); err != nil { 64 + t.Fatal(err) 65 + } 66 + 67 + a := app{homeDir: func() (string, error) { return home, nil }} 68 + dest, err := a.installAppImage(source, "test") 69 + if err != nil { 70 + t.Fatal(err) 71 + } 72 + 73 + want := filepath.Join(home, "AppImages", "test.appimage") 74 + if dest != want { 75 + t.Fatalf("dest = %q, want %q", dest, want) 76 + } 77 + data, err := os.ReadFile(dest) 78 + if err != nil { 79 + t.Fatal(err) 80 + } 81 + if string(data) != "appimage" { 82 + t.Fatalf("installed data = %q", data) 83 + } 84 + if mode := fileMode(dest); mode&0o100 == 0 { 85 + t.Fatalf("installed file is not user-executable: %#o", mode) 86 + } 87 + } 88 + 89 + func TestInstallAppImageReplacesExistingFile(t *testing.T) { 90 + dir := t.TempDir() 91 + home := filepath.Join(dir, "home") 92 + appimages := filepath.Join(home, "AppImages") 93 + if err := os.MkdirAll(appimages, 0o755); err != nil { 94 + t.Fatal(err) 95 + } 96 + 97 + existing := filepath.Join(appimages, "test.appimage") 98 + if err := os.WriteFile(existing, []byte("old"), 0o755); err != nil { 99 + t.Fatal(err) 100 + } 101 + source := filepath.Join(dir, "source.appimage") 102 + if err := os.WriteFile(source, []byte("new"), 0o644); err != nil { 103 + t.Fatal(err) 104 + } 105 + 106 + a := app{homeDir: func() (string, error) { return home, nil }} 107 + dest, err := a.installAppImage(source, "test") 108 + if err != nil { 109 + t.Fatal(err) 110 + } 111 + if dest != existing { 112 + t.Fatalf("dest = %q, want %q", dest, existing) 113 + } 114 + data, err := os.ReadFile(existing) 115 + if err != nil { 116 + t.Fatal(err) 117 + } 118 + if string(data) != "new" { 119 + t.Fatalf("replacement data = %q", data) 120 + } 121 + matches, err := filepath.Glob(filepath.Join(appimages, "*.tmp")) 122 + if err != nil { 123 + t.Fatal(err) 124 + } 125 + if len(matches) != 0 { 126 + t.Fatalf("temporary files left behind: %#v", matches) 127 + } 128 + }
+37
src/cli.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "flag" 6 + "fmt" 7 + "io" 8 + ) 9 + 10 + type config struct { 11 + install string 12 + } 13 + 14 + func parseArgs(args []string, output io.Writer) (config, error) { 15 + fs := flag.NewFlagSet("appherder", flag.ContinueOnError) 16 + fs.SetOutput(output) 17 + fs.Usage = func() { 18 + fmt.Fprintln(fs.Output(), "Usage: appherder -install APPIMAGE") 19 + fmt.Fprintln(fs.Output()) 20 + fmt.Fprintln(fs.Output(), "Manage AppImages with desktop integration.") 21 + fmt.Fprintln(fs.Output()) 22 + fmt.Fprintln(fs.Output(), "Options:") 23 + fs.PrintDefaults() 24 + } 25 + 26 + var cfg config 27 + fs.StringVar(&cfg.install, "install", "", "Install an AppImage") 28 + fs.StringVar(&cfg.install, "i", "", "Install an AppImage") 29 + 30 + if err := fs.Parse(args); err != nil { 31 + return cfg, err 32 + } 33 + if cfg.install == "" { 34 + return cfg, errors.New("missing required -install APPIMAGE") 35 + } 36 + return cfg, nil 37 + }
+238
src/desktop.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "sort" 8 + "strings" 9 + ) 10 + 11 + const ( 12 + desktopEntrySection = "Desktop Entry" 13 + desktopActionSectionStart = "Desktop Action " 14 + ) 15 + 16 + type desktopKey struct { 17 + name string 18 + value string 19 + lineIndex int 20 + } 21 + 22 + type desktopSection struct { 23 + name string 24 + lineIndex int 25 + keys []desktopKey 26 + } 27 + 28 + func (s *desktopSection) get(name string) (string, bool) { 29 + for _, key := range s.keys { 30 + if key.name == name { 31 + return key.value, true 32 + } 33 + } 34 + return "", false 35 + } 36 + 37 + type desktopFile struct { 38 + lines []string 39 + sections []desktopSection 40 + trailingNewline bool 41 + } 42 + 43 + func findDesktopFile(extracted string) (*desktopFile, error) { 44 + candidates, err := filepath.Glob(filepath.Join(extracted, "*.desktop")) 45 + if err != nil { 46 + return nil, err 47 + } 48 + sort.Strings(candidates) 49 + 50 + for _, candidate := range candidates { 51 + if filepath.Base(candidate) == "default.desktop" { 52 + continue 53 + } 54 + desktop, err := readDesktopFile(candidate) 55 + if err != nil { 56 + return nil, err 57 + } 58 + return desktop, nil 59 + } 60 + 61 + return nil, nil 62 + } 63 + 64 + func readDesktopFile(path string) (*desktopFile, error) { 65 + data, err := os.ReadFile(path) 66 + if err != nil { 67 + return nil, err 68 + } 69 + 70 + text := string(data) 71 + body := strings.TrimSuffix(text, "\n") 72 + lines := []string{} 73 + if body != "" { 74 + lines = strings.Split(body, "\n") 75 + } 76 + 77 + desktop := &desktopFile{ 78 + lines: lines, 79 + trailingNewline: strings.HasSuffix(text, "\n"), 80 + } 81 + 82 + var current *desktopSection 83 + for index, rawLine := range lines { 84 + line := strings.TrimSpace(rawLine) 85 + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { 86 + continue 87 + } 88 + 89 + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { 90 + desktop.sections = append(desktop.sections, desktopSection{ 91 + name: strings.TrimSpace(line[1 : len(line)-1]), 92 + lineIndex: index, 93 + }) 94 + current = &desktop.sections[len(desktop.sections)-1] 95 + continue 96 + } 97 + 98 + if current == nil || !strings.Contains(line, "=") { 99 + continue 100 + } 101 + 102 + name, value, _ := strings.Cut(line, "=") 103 + current.keys = append(current.keys, desktopKey{ 104 + name: strings.TrimSpace(name), 105 + value: strings.TrimSpace(value), 106 + lineIndex: index, 107 + }) 108 + } 109 + 110 + return desktop, nil 111 + } 112 + 113 + func (d *desktopFile) section(name string) *desktopSection { 114 + if section := d.findSection(name); section != nil { 115 + return section 116 + } 117 + 118 + if len(d.lines) > 0 && strings.TrimSpace(d.lines[len(d.lines)-1]) != "" { 119 + d.lines = append(d.lines, "") 120 + } 121 + 122 + lineIndex := len(d.lines) 123 + d.lines = append(d.lines, "["+name+"]") 124 + d.sections = append(d.sections, desktopSection{name: name, lineIndex: lineIndex}) 125 + return &d.sections[len(d.sections)-1] 126 + } 127 + 128 + func (d *desktopFile) findSection(name string) *desktopSection { 129 + for i := range d.sections { 130 + if d.sections[i].name == name { 131 + return &d.sections[i] 132 + } 133 + } 134 + return nil 135 + } 136 + 137 + func (d *desktopFile) get(name string, sectionName string) (string, bool) { 138 + section := d.findSection(sectionName) 139 + if section == nil { 140 + return "", false 141 + } 142 + return section.get(name) 143 + } 144 + 145 + func (d *desktopFile) set(name string, value string, sectionName string) { 146 + section := d.section(sectionName) 147 + 148 + for i := range section.keys { 149 + if section.keys[i].name == name { 150 + section.keys[i].value = value 151 + d.lines[section.keys[i].lineIndex] = section.keys[i].name + "=" + value 152 + return 153 + } 154 + } 155 + 156 + insertAt := len(d.lines) 157 + for _, other := range d.sections { 158 + if other.lineIndex > section.lineIndex && other.lineIndex < insertAt { 159 + insertAt = other.lineIndex 160 + } 161 + } 162 + 163 + d.insertLine(insertAt, name+"="+value) 164 + section.keys = append(section.keys, desktopKey{ 165 + name: name, 166 + value: value, 167 + lineIndex: insertAt, 168 + }) 169 + } 170 + 171 + func (d *desktopFile) insertLine(index int, line string) { 172 + d.lines = append(d.lines, "") 173 + copy(d.lines[index+1:], d.lines[index:]) 174 + d.lines[index] = line 175 + 176 + for i := range d.sections { 177 + if d.sections[i].lineIndex >= index { 178 + d.sections[i].lineIndex++ 179 + } 180 + for j := range d.sections[i].keys { 181 + if d.sections[i].keys[j].lineIndex >= index { 182 + d.sections[i].keys[j].lineIndex++ 183 + } 184 + } 185 + } 186 + } 187 + 188 + func (d *desktopFile) write(path string) error { 189 + text := strings.Join(d.lines, "\n") 190 + if d.trailingNewline { 191 + text += "\n" 192 + } 193 + return os.WriteFile(path, []byte(text), 0o644) 194 + } 195 + 196 + func (a app) patchDesktopFile(desktop *desktopFile, appName string) error { 197 + home, err := a.homeDir() 198 + if err != nil { 199 + return fmt.Errorf("resolve home directory: %w", err) 200 + } 201 + 202 + appimages := filepath.Join(home, "AppImages") 203 + appimage := filepath.Join(appimages, appName+".appimage") 204 + 205 + desktop.set("Icon", filepath.Join(appimages, ".icons", appName), desktopEntrySection) 206 + desktop.set("TryExec", appimage, desktopEntrySection) 207 + 208 + if execCmd, ok := desktop.get("Exec", desktopEntrySection); ok { 209 + desktop.set("Exec", patchExecCommand(execCmd, appimage), desktopEntrySection) 210 + } 211 + 212 + for _, section := range desktop.sections { 213 + if !strings.HasPrefix(section.name, desktopActionSectionStart) { 214 + continue 215 + } 216 + if execCmd, ok := section.get("Exec"); ok { 217 + desktop.set("Exec", patchExecCommand(execCmd, appimage), section.name) 218 + } 219 + } 220 + 221 + return nil 222 + } 223 + 224 + func (a app) installDesktopFile(desktop *desktopFile, appName string) (string, error) { 225 + home, err := a.homeDir() 226 + if err != nil { 227 + return "", fmt.Errorf("resolve home directory: %w", err) 228 + } 229 + 230 + dest := filepath.Join(home, ".local", "share", "applications", appName+".desktop") 231 + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { 232 + return "", fmt.Errorf("create desktop file directory %s: %w", filepath.Dir(dest), err) 233 + } 234 + if err := desktop.write(dest); err != nil { 235 + return "", fmt.Errorf("write desktop file %s: %w", dest, err) 236 + } 237 + return dest, nil 238 + }
+89
src/desktop_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + const sampleDesktopFile = `# leading comment 11 + [Desktop Entry] 12 + Name=Helium 13 + Exec=env FOO=bar helium %U 14 + Icon=helium 15 + Actions=new-window;new-private-window; 16 + 17 + [Desktop Action new-window] 18 + Name=New Window 19 + Exec=helium --new-window %U 20 + 21 + [Desktop Action new-private-window] 22 + Name=New Private Window 23 + Exec=helium --private-window %U 24 + ` 25 + 26 + func TestDesktopFileRoundTripMatchesInput(t *testing.T) { 27 + dir := t.TempDir() 28 + source := filepath.Join(dir, "source.desktop") 29 + output := filepath.Join(dir, "output.desktop") 30 + if err := os.WriteFile(source, []byte(sampleDesktopFile), 0o644); err != nil { 31 + t.Fatal(err) 32 + } 33 + 34 + desktop, err := readDesktopFile(source) 35 + if err != nil { 36 + t.Fatal(err) 37 + } 38 + if err := desktop.write(output); err != nil { 39 + t.Fatal(err) 40 + } 41 + 42 + got, err := os.ReadFile(output) 43 + if err != nil { 44 + t.Fatal(err) 45 + } 46 + if string(got) != sampleDesktopFile { 47 + t.Fatalf("round trip mismatch:\n%s", got) 48 + } 49 + } 50 + 51 + func TestPatchDesktopFilePreservesDesktopActions(t *testing.T) { 52 + dir := t.TempDir() 53 + source := filepath.Join(dir, "source.desktop") 54 + output := filepath.Join(dir, "output.desktop") 55 + if err := os.WriteFile(source, []byte(sampleDesktopFile), 0o644); err != nil { 56 + t.Fatal(err) 57 + } 58 + 59 + a := app{homeDir: func() (string, error) { return "/home/test", nil }} 60 + 61 + desktop, err := readDesktopFile(source) 62 + if err != nil { 63 + t.Fatal(err) 64 + } 65 + if err := a.patchDesktopFile(desktop, "helium"); err != nil { 66 + t.Fatal(err) 67 + } 68 + if err := desktop.write(output); err != nil { 69 + t.Fatal(err) 70 + } 71 + 72 + written, err := os.ReadFile(output) 73 + if err != nil { 74 + t.Fatal(err) 75 + } 76 + text := string(written) 77 + for _, expected := range []string{ 78 + "[Desktop Action new-window]", 79 + "[Desktop Action new-private-window]", 80 + "Name=New Private Window", 81 + "Exec=env FOO=bar DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage %U", 82 + "Exec=env DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage --new-window %U", 83 + "Exec=env DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage --private-window %U", 84 + } { 85 + if !strings.Contains(text, expected) { 86 + t.Fatalf("patched desktop file missing %q:\n%s", expected, text) 87 + } 88 + } 89 + }
+62
src/exec.go
··· 1 + package main 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/kballard/go-shellquote" 7 + ) 8 + 9 + func patchExecCommand(execCmd string, appimage string) string { 10 + tokens, err := shellquote.Split(execCmd) 11 + if err != nil { 12 + return execCmd 13 + } 14 + if len(tokens) == 0 { 15 + return execCmd 16 + } 17 + 18 + envVars := []string{} 19 + executableIndex := 0 20 + if tokens[0] == "env" { 21 + executableIndex = 1 22 + } 23 + 24 + for executableIndex < len(tokens) && isEnvVar(tokens[executableIndex]) { 25 + envVars = append(envVars, tokens[executableIndex]) 26 + executableIndex++ 27 + } 28 + 29 + args := []string{} 30 + for _, token := range tokens[executableIndex+1:] { 31 + if isStrippedDesktopExecCode(token) || isEnvVar(token) { 32 + continue 33 + } 34 + args = append(args, token) 35 + } 36 + 37 + hasDesktopIntegration := false 38 + for _, envVar := range envVars { 39 + if strings.HasPrefix(envVar, "DESKTOPINTEGRATION=") { 40 + hasDesktopIntegration = true 41 + break 42 + } 43 + } 44 + if !hasDesktopIntegration { 45 + envVars = append(envVars, "DESKTOPINTEGRATION=1") 46 + } 47 + 48 + cmd := append([]string{appimage}, args...) 49 + if len(envVars) > 0 { 50 + cmd = append([]string{"env"}, append(envVars, cmd...)...) 51 + } 52 + 53 + return shellquote.Join(cmd...) 54 + } 55 + 56 + func isEnvVar(token string) bool { 57 + return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, "-") 58 + } 59 + 60 + func isStrippedDesktopExecCode(token string) bool { 61 + return token == "%i" || token == "%c" || token == "%k" 62 + }
+36
src/exec_test.go
··· 1 + package main 2 + 3 + import "testing" 4 + 5 + func TestPatchExecCommandDropsOriginalEnvWrappedExecutable(t *testing.T) { 6 + got := patchExecCommand( 7 + "env FOO=bar helium --new-window %U", 8 + "/home/test/AppImages/helium.appimage", 9 + ) 10 + want := "env FOO=bar DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage --new-window %U" 11 + if got != want { 12 + t.Fatalf("patchExecCommand() = %q, want %q", got, want) 13 + } 14 + } 15 + 16 + func TestPatchExecCommandStripsMetadataFieldCodes(t *testing.T) { 17 + got := patchExecCommand( 18 + "helium --open %i %c %k %F", 19 + "/home/test/AppImages/helium.appimage", 20 + ) 21 + want := "env DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage --open %F" 22 + if got != want { 23 + t.Fatalf("patchExecCommand() = %q, want %q", got, want) 24 + } 25 + } 26 + 27 + func TestPatchExecCommandPreservesQuotedArguments(t *testing.T) { 28 + got := patchExecCommand( 29 + "helium --name 'Helium Browser' --flag=\"two words\" %U", 30 + "/home/test/AppImages/helium.appimage", 31 + ) 32 + want := "env DESKTOPINTEGRATION=1 /home/test/AppImages/helium.appimage --name 'Helium Browser' '--flag=two words' %U" 33 + if got != want { 34 + t.Fatalf("patchExecCommand() = %q, want %q", got, want) 35 + } 36 + }
+22
src/files.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + ) 7 + 8 + func copyFile(src string, dest string) error { 9 + out, err := os.Create(dest) 10 + if err != nil { 11 + return fmt.Errorf("create destination %s: %w", dest, err) 12 + } 13 + 14 + if err := copyTo(src, out); err != nil { 15 + _ = out.Close() 16 + return err 17 + } 18 + if err := out.Close(); err != nil { 19 + return fmt.Errorf("close destination %s: %w", dest, err) 20 + } 21 + return nil 22 + }
+33
src/icons.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + ) 8 + 9 + func resolveIcon(extracted string) string { 10 + icon := filepath.Join(extracted, ".DirIcon") 11 + if _, err := os.Stat(icon); err == nil { 12 + return icon 13 + } 14 + return "" 15 + } 16 + 17 + func (a app) installIcon(icon string, appName string) (string, error) { 18 + home, err := a.homeDir() 19 + if err != nil { 20 + return "", fmt.Errorf("resolve home directory: %w", err) 21 + } 22 + 23 + iconsDir := filepath.Join(home, "AppImages", ".icons") 24 + if err := os.MkdirAll(iconsDir, 0o755); err != nil { 25 + return "", fmt.Errorf("create icon directory %s: %w", iconsDir, err) 26 + } 27 + 28 + dest := filepath.Join(iconsDir, appName) 29 + if err := copyFile(icon, dest); err != nil { 30 + return "", fmt.Errorf("install icon %s to %s: %w", icon, dest, err) 31 + } 32 + return dest, nil 33 + }
+60
src/install.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + ) 9 + 10 + func (a app) install(appimage string) error { 11 + appimage, err := filepath.Abs(appimage) 12 + if err != nil { 13 + return fmt.Errorf("resolve AppImage path %q: %w", appimage, err) 14 + } 15 + 16 + tmp, err := os.MkdirTemp("", "appherder-") 17 + if err != nil { 18 + return fmt.Errorf("create extraction directory: %w", err) 19 + } 20 + defer os.RemoveAll(tmp) 21 + 22 + extracted, err := a.extractAppImage(appimage, tmp) 23 + if err != nil { 24 + return err 25 + } 26 + 27 + desktop, err := findDesktopFile(extracted) 28 + if err != nil { 29 + return fmt.Errorf("find desktop file in %s: %w", extracted, err) 30 + } 31 + 32 + icon := resolveIcon(extracted) 33 + appName := appNameFromPath(appimage) 34 + 35 + if icon != "" { 36 + if _, err := a.installIcon(icon, appName); err != nil { 37 + return err 38 + } 39 + } 40 + 41 + if _, err := a.installAppImage(appimage, appName); err != nil { 42 + return err 43 + } 44 + 45 + 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 { 50 + return err 51 + } 52 + } 53 + 54 + return nil 55 + } 56 + 57 + func appNameFromPath(path string) string { 58 + base := filepath.Base(path) 59 + return strings.TrimSuffix(base, filepath.Ext(base)) 60 + }
+24
src/main.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "flag" 6 + "fmt" 7 + "os" 8 + ) 9 + 10 + func main() { 11 + cfg, err := parseArgs(os.Args[1:], os.Stderr) 12 + if err != nil { 13 + if errors.Is(err, flag.ErrHelp) { 14 + os.Exit(0) 15 + } 16 + fmt.Fprintln(os.Stderr, err) 17 + os.Exit(2) 18 + } 19 + 20 + if err := newApp().install(cfg.install); err != nil { 21 + fmt.Fprintln(os.Stderr, err) 22 + os.Exit(1) 23 + } 24 + }
+11
src/test_helpers_test.go
··· 1 + package main 2 + 3 + import "os" 4 + 5 + func fileMode(path string) os.FileMode { 6 + info, err := os.Stat(path) 7 + if err != nil { 8 + panic(err) 9 + } 10 + return info.Mode() & 0o777 11 + }