A shepherd for your Appimages.
0

Configure Feed

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

use goxdgdesktop

Aly Raffauf (Jun 18, 2026, 1:55 AM EDT) 3b900e5a 214e133d

+75 -316
+2 -1
go.mod
··· 4 4 5 5 require ( 6 6 github.com/CalebQ42/squashfs v1.4.1 7 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 7 + github.com/alyraffauf/goxdgdesktop v0.1.0 8 8 github.com/spf13/cobra v1.10.2 9 9 ) 10 10 11 11 require ( 12 12 github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 13 14 github.com/klauspost/compress v1.18.0 // indirect 14 15 github.com/mikelolasagasti/xz v1.0.1 // indirect 15 16 github.com/pierrec/lz4/v4 v4.1.22 // indirect
+2
go.sum
··· 1 1 github.com/CalebQ42/squashfs v1.4.1 h1:tBcFMQSRQvWcY50e9r9cv2uVzNf06fcUhly0LeZg8bI= 2 2 github.com/CalebQ42/squashfs v1.4.1/go.mod h1:/As5wg6ScFFaab9SaNFNHyCOsd73Q5IFPOFJCVnwWzQ= 3 + github.com/alyraffauf/goxdgdesktop v0.1.0 h1:n5+3AjF2ntvON6W0nN4O5bHDmcjYuFtKVCwBr6ekqj0= 4 + github.com/alyraffauf/goxdgdesktop v0.1.0/go.mod h1:K91gqx5usBl0hjhNUxHcBSRaOaU8gaggPxfKpCnTXzQ= 3 5 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 6 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 7 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+1 -1
internal/appherder/appimage.go
··· 224 224 if fsys, closeFs, err := openAppImage(path); err == nil { 225 225 defer closeFs() 226 226 if desktop, _, err := findDesktopFile(fsys); err == nil && desktop != nil { 227 - if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok && version != "" { 227 + if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok && version != "" { 228 228 return sanitizeVersionForFilename(version) 229 229 } 230 230 }
+24 -208
internal/appherder/desktop.go
··· 6 6 "io/fs" 7 7 "os" 8 8 "path/filepath" 9 - "sort" 10 9 "strings" 10 + 11 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 11 12 ) 12 13 13 14 const ( 14 - desktopEntrySection = "Desktop Entry" 15 - desktopActionSectionStart = "Desktop Action " 16 - // desktopOwnerKey marks a desktop entry as installed by appherder, so we 17 - // only ever remove launchers we created. 15 + desktopEntrySection = desktopfile.EntrySection 16 + // desktopOwnerKey marks launchers appherder owns. 18 17 desktopOwnerKey = "X-AppHerder" 19 18 ) 20 19 21 - type desktopKey struct { 22 - name string 23 - value string 24 - lineIndex int 25 - } 26 - 27 - type desktopSection struct { 28 - name string 29 - lineIndex int 30 - keys []desktopKey 31 - } 32 - 33 - func (s *desktopSection) get(name string) (string, bool) { 34 - for _, key := range s.keys { 35 - if key.name == name { 36 - return key.value, true 37 - } 38 - } 39 - return "", false 40 - } 41 - 42 - type desktopFile struct { 43 - lines []string 44 - sections []desktopSection 45 - trailingNewline bool 46 - } 47 - 48 - // findDesktopFile returns the AppImage's desktop entry and its filename, 49 - // skipping the AppImage runtime's default.desktop. The "*.desktop" glob only 50 - // matches the root, so candidates are bare filenames. 51 - func findDesktopFile(fsys fs.FS) (*desktopFile, string, error) { 52 - candidates, err := fs.Glob(fsys, "*.desktop") 53 - if err != nil { 54 - return nil, "", err 55 - } 56 - sort.Strings(candidates) 57 - 58 - for _, candidate := range candidates { 59 - if candidate == "default.desktop" { 60 - continue 61 - } 62 - data, err := fs.ReadFile(fsys, candidate) 63 - if err != nil { 64 - return nil, "", err 65 - } 66 - return parseDesktopFile(data), candidate, nil 67 - } 68 - 69 - return nil, "", nil 70 - } 71 - 72 - func readDesktopFile(path string) (*desktopFile, error) { 73 - data, err := os.ReadFile(path) 74 - if err != nil { 75 - return nil, err 76 - } 77 - return parseDesktopFile(data), nil 78 - } 79 - 80 - func parseDesktopFile(data []byte) *desktopFile { 81 - text := string(data) 82 - body := strings.TrimSuffix(text, "\n") 83 - lines := []string{} 84 - if body != "" { 85 - lines = strings.Split(body, "\n") 86 - } 87 - 88 - desktop := &desktopFile{ 89 - lines: lines, 90 - trailingNewline: strings.HasSuffix(text, "\n"), 91 - } 92 - 93 - var current *desktopSection 94 - for index, rawLine := range lines { 95 - line := strings.TrimSpace(rawLine) 96 - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { 97 - continue 98 - } 99 - 100 - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { 101 - desktop.sections = append(desktop.sections, desktopSection{ 102 - name: strings.TrimSpace(line[1 : len(line)-1]), 103 - lineIndex: index, 104 - }) 105 - current = &desktop.sections[len(desktop.sections)-1] 106 - continue 107 - } 108 - 109 - if current == nil || !strings.Contains(line, "=") { 110 - continue 111 - } 112 - 113 - name, value, _ := strings.Cut(line, "=") 114 - current.keys = append(current.keys, desktopKey{ 115 - name: strings.TrimSpace(name), 116 - value: strings.TrimSpace(value), 117 - lineIndex: index, 118 - }) 119 - } 120 - 121 - return desktop 20 + // findDesktopFile returns the AppImage's desktop entry and filename. 21 + func findDesktopFile(fsys fs.FS) (*desktopfile.File, string, error) { 22 + return desktopfile.FirstInFS(fsys, "*.desktop", "default.desktop") 122 23 } 123 24 124 - // isManagedDesktop reports whether the desktop entry at path carries 125 - // appherder's ownership marker. A missing file is reported as unmanaged. 25 + // isManagedDesktop reports whether path is owned by appherder. 126 26 func isManagedDesktop(path string) (bool, error) { 127 - desktop, err := readDesktopFile(path) 27 + desktop, err := desktopfile.Read(path) 128 28 if err != nil { 129 29 if errors.Is(err, os.ErrNotExist) { 130 30 return false, nil 131 31 } 132 32 return false, fmt.Errorf("read desktop file %s: %w", path, err) 133 33 } 134 - value, ok := desktop.get(desktopOwnerKey, desktopEntrySection) 34 + value, ok := desktop.Get(desktopEntrySection, desktopOwnerKey) 135 35 return ok && value == "true", nil 136 36 } 137 37 138 - // managedApps returns the ids of desktop entries appherder installed, found by 139 - // scanning the user applications directory for the ownership marker. 38 + // managedApps returns the app IDs of launchers appherder installed. 140 39 func managedApps(applicationsDir string) ([]string, error) { 141 40 matches, err := filepath.Glob(filepath.Join(applicationsDir, "*.desktop")) 142 41 if err != nil { ··· 156 55 return apps, nil 157 56 } 158 57 159 - func (d *desktopFile) section(name string) *desktopSection { 160 - if section := d.findSection(name); section != nil { 161 - return section 162 - } 163 - 164 - if len(d.lines) > 0 && strings.TrimSpace(d.lines[len(d.lines)-1]) != "" { 165 - d.lines = append(d.lines, "") 166 - } 167 - 168 - lineIndex := len(d.lines) 169 - d.lines = append(d.lines, "["+name+"]") 170 - d.sections = append(d.sections, desktopSection{name: name, lineIndex: lineIndex}) 171 - return &d.sections[len(d.sections)-1] 172 - } 173 - 174 - func (d *desktopFile) findSection(name string) *desktopSection { 175 - for i := range d.sections { 176 - if d.sections[i].name == name { 177 - return &d.sections[i] 178 - } 179 - } 180 - return nil 181 - } 182 - 183 - func (d *desktopFile) get(name string, sectionName string) (string, bool) { 184 - section := d.findSection(sectionName) 185 - if section == nil { 186 - return "", false 187 - } 188 - return section.get(name) 189 - } 190 - 191 - func (d *desktopFile) set(name string, value string, sectionName string) { 192 - section := d.section(sectionName) 193 - 194 - for i := range section.keys { 195 - if section.keys[i].name == name { 196 - section.keys[i].value = value 197 - d.lines[section.keys[i].lineIndex] = section.keys[i].name + "=" + value 198 - return 199 - } 200 - } 201 - 202 - insertAt := len(d.lines) 203 - for _, other := range d.sections { 204 - if other.lineIndex > section.lineIndex && other.lineIndex < insertAt { 205 - insertAt = other.lineIndex 206 - } 207 - } 208 - 209 - d.insertLine(insertAt, name+"="+value) 210 - section.keys = append(section.keys, desktopKey{ 211 - name: name, 212 - value: value, 213 - lineIndex: insertAt, 214 - }) 215 - } 216 - 217 - func (d *desktopFile) insertLine(index int, line string) { 218 - d.lines = append(d.lines, "") 219 - copy(d.lines[index+1:], d.lines[index:]) 220 - d.lines[index] = line 221 - 222 - for i := range d.sections { 223 - if d.sections[i].lineIndex >= index { 224 - d.sections[i].lineIndex++ 225 - } 226 - for j := range d.sections[i].keys { 227 - if d.sections[i].keys[j].lineIndex >= index { 228 - d.sections[i].keys[j].lineIndex++ 229 - } 230 - } 231 - } 232 - } 233 - 234 - func (d *desktopFile) write(path string) error { 235 - text := strings.Join(d.lines, "\n") 236 - if d.trailingNewline { 237 - text += "\n" 238 - } 239 - return writeIfChanged(path, 0o644, []byte(text)) 240 - } 241 - 242 - func (a App) patchDesktopFile(desktop *desktopFile, appName string, hasIcon bool) error { 58 + func (a App) patchDesktopFile(desktop *desktopfile.File, appName string, hasIcon bool) error { 243 59 appimage := filepath.Join(a.appimagesDir, appName+".appimage") 244 60 245 - desktop.set(desktopOwnerKey, "true", desktopEntrySection) 61 + desktop.Set(desktopEntrySection, desktopOwnerKey, "true") 246 62 if hasIcon { 247 - desktop.set("Icon", filepath.Join(a.iconsDir, appName), desktopEntrySection) 63 + desktop.Set(desktopEntrySection, "Icon", filepath.Join(a.iconsDir, appName)) 248 64 } 249 - desktop.set("TryExec", appimage, desktopEntrySection) 65 + desktop.Set(desktopEntrySection, "TryExec", appimage) 250 66 251 - if execCmd, ok := desktop.get("Exec", desktopEntrySection); ok { 252 - desktop.set("Exec", patchExecCommand(execCmd, appimage), desktopEntrySection) 67 + if execCmd, ok := desktop.Get(desktopEntrySection, "Exec"); ok { 68 + desktop.Set(desktopEntrySection, "Exec", patchExecCommand(execCmd, appimage)) 253 69 } else { 254 - desktop.set("Exec", appimage, desktopEntrySection) 70 + desktop.Set(desktopEntrySection, "Exec", appimage) 255 71 } 256 72 257 - for _, section := range desktop.sections { 258 - if !strings.HasPrefix(section.name, desktopActionSectionStart) { 73 + for _, section := range desktop.Sections() { 74 + if !strings.HasPrefix(section.Name, desktopfile.ActionSectionStart) { 259 75 continue 260 76 } 261 - if execCmd, ok := section.get("Exec"); ok { 262 - desktop.set("Exec", patchExecCommand(execCmd, appimage), section.name) 77 + if execCmd, ok := section.Get("Exec"); ok { 78 + desktop.Set(section.Name, "Exec", patchExecCommand(execCmd, appimage)) 263 79 } 264 80 } 265 81 266 82 return nil 267 83 } 268 84 269 - func (a App) installDesktopFile(desktop *desktopFile, appName string) (string, error) { 85 + func (a App) installDesktopFile(desktop *desktopfile.File, appName string) (string, error) { 270 86 dest := filepath.Join(a.applicationsDir, appName+".desktop") 271 87 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { 272 88 return "", fmt.Errorf("create desktop file directory %s: %w", filepath.Dir(dest), err) 273 89 } 274 - if err := desktop.write(dest); err != nil { 90 + if err := writeIfChanged(dest, 0o644, desktop.Bytes()); err != nil { 275 91 return "", fmt.Errorf("write desktop file %s: %w", dest, err) 276 92 } 277 93 return dest, nil
+19 -61
internal/appherder/desktop_test.go
··· 1 1 package appherder 2 2 3 3 import ( 4 - "os" 5 4 "path/filepath" 6 5 "testing" 7 6 "testing/fstest" 7 + 8 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 8 9 ) 9 10 10 11 const sampleDesktopFile = `# leading comment ··· 23 24 Exec=upstream-app --private-window %U 24 25 ` 25 26 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 27 func TestPatchDesktopFilePreservesDesktopActions(t *testing.T) { 52 28 a, home := newTestApp(t) 53 29 54 - dir := t.TempDir() 55 - source := filepath.Join(dir, "source.desktop") 56 - if err := os.WriteFile(source, []byte(sampleDesktopFile), 0o644); err != nil { 57 - t.Fatal(err) 58 - } 59 - 60 - desktop, err := readDesktopFile(source) 61 - if err != nil { 62 - t.Fatal(err) 63 - } 30 + desktop := desktopfile.Parse([]byte(sampleDesktopFile)) 64 31 if err := a.patchDesktopFile(desktop, "example", true); err != nil { 65 32 t.Fatal(err) 66 33 } 67 - output := filepath.Join(dir, "output.desktop") 68 - if err := desktop.write(output); err != nil { 69 - t.Fatal(err) 70 - } 71 34 72 - patched, err := readDesktopFile(output) 73 - if err != nil { 74 - t.Fatal(err) 75 - } 76 - 77 - assertDesktopValue(t, patched, desktopEntrySection, desktopOwnerKey, "true") 78 - assertDesktopValue(t, patched, desktopEntrySection, "Icon", filepath.Join(home, "AppImages", ".icons", "example")) 79 - assertDesktopValue(t, patched, desktopEntrySection, "TryExec", filepath.Join(home, "AppImages", "example.appimage")) 80 - assertDesktopExec(t, patched, desktopEntrySection, []string{"env", "FOO=bar", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "%U"}) 81 - assertDesktopExec(t, patched, "Desktop Action new-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--new-window", "%U"}) 82 - assertDesktopExec(t, patched, "Desktop Action new-private-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--private-window", "%U"}) 83 - assertDesktopValue(t, patched, "Desktop Action new-private-window", "Name", "New Private Window") 35 + assertDesktopValue(t, desktop, desktopEntrySection, desktopOwnerKey, "true") 36 + assertDesktopValue(t, desktop, desktopEntrySection, "Icon", filepath.Join(home, "AppImages", ".icons", "example")) 37 + assertDesktopValue(t, desktop, desktopEntrySection, "TryExec", filepath.Join(home, "AppImages", "example.appimage")) 38 + assertDesktopExec(t, desktop, desktopEntrySection, []string{"env", "FOO=bar", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "%U"}) 39 + assertDesktopExec(t, desktop, "Desktop Action new-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--new-window", "%U"}) 40 + assertDesktopExec(t, desktop, "Desktop Action new-private-window", []string{"env", "DESKTOPINTEGRATION=1", filepath.Join(home, "AppImages", "example.appimage"), "--private-window", "%U"}) 41 + assertDesktopValue(t, desktop, "Desktop Action new-private-window", "Name", "New Private Window") 84 42 } 85 43 86 44 func TestFindDesktopFileSkipsDefault(t *testing.T) { ··· 117 75 func TestDeriveAppName(t *testing.T) { 118 76 tests := []struct { 119 77 name string 120 - desktop *desktopFile 78 + desktop *desktopfile.File 121 79 desktopName string 122 80 appimagePath string 123 81 want string 124 82 }{ 125 - {"Name with hyphen", parseDesktopFile([]byte("[Desktop Entry]\nName=ES-DE\n")), "org.es_de.frontend.desktop", "", "esde"}, 126 - {"Name with spaces", parseDesktopFile([]byte("[Desktop Entry]\nName=Visual Studio Code\n")), "code.desktop", "", "visual_studio_code"}, 127 - {"Name lowercased", parseDesktopFile([]byte("[Desktop Entry]\nName=Krita\n")), "krita.desktop", "", "krita"}, 83 + {"Name with hyphen", desktopfile.Parse([]byte("[Desktop Entry]\nName=ES-DE\n")), "org.es_de.frontend.desktop", "", "esde"}, 84 + {"Name with spaces", desktopfile.Parse([]byte("[Desktop Entry]\nName=Visual Studio Code\n")), "code.desktop", "", "visual_studio_code"}, 85 + {"Name lowercased", desktopfile.Parse([]byte("[Desktop Entry]\nName=Krita\n")), "krita.desktop", "", "krita"}, 128 86 {"desktop id fallback", nil, "org.kde.krita.desktop", "", "org.kde.krita"}, 129 87 {"filename fallback", nil, "", "/dl/Krita-5.2.0-x86_64.AppImage", "krita5.2.0x86_64"}, 130 88 } ··· 139 97 140 98 func TestPatchDesktopFileSetsExecWhenMissing(t *testing.T) { 141 99 a, home := newTestApp(t) 142 - desktop := parseDesktopFile([]byte( 100 + desktop := desktopfile.Parse([]byte( 143 101 "[Desktop Entry]\nType=Application\nName=Foo\nTerminal=true\n", 144 102 )) 145 103 if err := a.patchDesktopFile(desktop, "foo", false); err != nil { ··· 151 109 assertDesktopValue(t, desktop, desktopEntrySection, desktopOwnerKey, "true") 152 110 } 153 111 154 - func assertDesktopValue(t *testing.T, desktop *desktopFile, section string, key string, want string) { 112 + func assertDesktopValue(t *testing.T, desktop *desktopfile.File, section string, key string, want string) { 155 113 t.Helper() 156 114 157 - got, ok := desktop.get(key, section) 115 + got, ok := desktop.Get(section, key) 158 116 if !ok { 159 117 t.Fatalf("missing %s in section %s", key, section) 160 118 } ··· 163 121 } 164 122 } 165 123 166 - func assertDesktopExec(t *testing.T, desktop *desktopFile, section string, want []string) { 124 + func assertDesktopExec(t *testing.T, desktop *desktopfile.File, section string, want []string) { 167 125 t.Helper() 168 126 169 - execCmd, ok := desktop.get("Exec", section) 127 + execCmd, ok := desktop.Get(section, "Exec") 170 128 if !ok { 171 129 t.Fatalf("missing Exec in section %s", section) 172 130 }
+7 -30
internal/appherder/exec.go
··· 3 3 import ( 4 4 "strings" 5 5 6 - "github.com/kballard/go-shellquote" 6 + "github.com/alyraffauf/goxdgdesktop/desktopexec" 7 7 ) 8 8 9 9 func patchExecCommand(execCmd string, appimage string) string { 10 - tokens, err := shellquote.Split(execCmd) 10 + tokens, err := desktopexec.Split(execCmd) 11 11 if err != nil { 12 12 return execCmd 13 13 } ··· 21 21 executableIndex = 1 22 22 } 23 23 24 - for executableIndex < len(tokens) && isEnvVar(tokens[executableIndex]) { 24 + for executableIndex < len(tokens) && desktopexec.IsEnvAssignment(tokens[executableIndex]) { 25 25 envVars = append(envVars, tokens[executableIndex]) 26 26 executableIndex++ 27 27 } 28 28 29 29 args := []string{} 30 30 for _, token := range tokens[executableIndex+1:] { 31 - if isStrippedDesktopExecCode(token) { 31 + if desktopexec.IsMetadataFieldCode(token) { 32 32 continue 33 33 } 34 34 args = append(args, token) ··· 50 50 cmd = append([]string{"env"}, append(envVars, cmd...)...) 51 51 } 52 52 53 - return shellquote.Join(cmd...) 53 + return desktopexec.Join(cmd...) 54 54 } 55 55 56 - func isEnvVar(token string) bool { 57 - return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, "-") 58 - } 59 - 60 - // execPath returns the executable path from a desktop Exec/TryExec command, 61 - // skipping a leading env and KEY=VALUE assignments. 56 + // execPath returns the executable from a desktop Exec/TryExec line. 62 57 func execPath(cmd string) string { 63 - tokens, err := shellquote.Split(cmd) 64 - if err != nil { 65 - return "" 66 - } 67 - idx := 0 68 - if idx < len(tokens) && tokens[idx] == "env" { 69 - idx++ 70 - } 71 - for idx < len(tokens) && isEnvVar(tokens[idx]) { 72 - idx++ 73 - } 74 - if idx < len(tokens) { 75 - return tokens[idx] 76 - } 77 - return "" 78 - } 79 - 80 - func isStrippedDesktopExecCode(token string) bool { 81 - return token == "%i" || token == "%c" || token == "%k" 58 + return desktopexec.ExecutablePath(cmd) 82 59 }
+2 -2
internal/appherder/exec_test.go
··· 4 4 "reflect" 5 5 "testing" 6 6 7 - "github.com/kballard/go-shellquote" 7 + "github.com/alyraffauf/goxdgdesktop/desktopexec" 8 8 ) 9 9 10 10 func TestPatchExecCommandDropsOriginalEnvWrappedExecutable(t *testing.T) { ··· 55 55 func mustSplit(t *testing.T, cmd string) []string { 56 56 t.Helper() 57 57 58 - tokens, err := shellquote.Split(cmd) 58 + tokens, err := desktopexec.Split(cmd) 59 59 if err != nil { 60 60 t.Fatalf("split %q: %v", cmd, err) 61 61 }
+5 -3
internal/appherder/install.go
··· 6 6 "os" 7 7 "path/filepath" 8 8 "strings" 9 + 10 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 9 11 ) 10 12 11 13 func (a App) Install(appimage string) (appName string, err error) { ··· 38 40 // No desktop file inside the AppImage: synthesize a terminal launcher so 39 41 // CLI apps still get a menu entry and are tracked by managedApps. 40 42 if desktop == nil { 41 - desktop = parseDesktopFile([]byte(fmt.Sprintf( 43 + desktop = desktopfile.Parse([]byte(fmt.Sprintf( 42 44 "[Desktop Entry]\nType=Application\nName=%s\nTerminal=true\n", 43 45 appNameFromPath(appimage), 44 46 ))) ··· 97 99 // deriveAppName picks the canonical install name, matching GearLever so the 98 100 // two tools land at the same path: the desktop entry's Name field (e.g. 99 101 // "ES-DE" -> "esde"), then the desktop-file id, then the source filename. 100 - func deriveAppName(desktop *desktopFile, desktopName string, appimagePath string) string { 102 + func deriveAppName(desktop *desktopfile.File, desktopName string, appimagePath string) string { 101 103 if desktop != nil { 102 - if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" { 104 + if name, ok := desktop.Get(desktopEntrySection, "Name"); ok && name != "" { 103 105 return sanitizeAppName(name) 104 106 } 105 107 }
+5 -3
internal/appherder/list.go
··· 4 4 "os" 5 5 "path/filepath" 6 6 "sort" 7 + 8 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 7 9 ) 8 10 9 11 // AppInfo is one managed app's display metadata, as returned by List. ··· 37 39 func gatherAppInfo(appsDir, appimagesDir, appid string) AppInfo { 38 40 info := AppInfo{AppID: appid, Source: "none"} 39 41 40 - if desktop, err := readDesktopFile(filepath.Join(appsDir, appid+".desktop")); err == nil { 41 - if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" { 42 + if desktop, err := desktopfile.Read(filepath.Join(appsDir, appid+".desktop")); err == nil { 43 + if name, ok := desktop.Get(desktopEntrySection, "Name"); ok && name != "" { 42 44 info.Name = name 43 45 } 44 - if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok { 46 + if version, ok := desktop.Get(desktopEntrySection, "X-AppImage-Version"); ok { 45 47 info.Version = version 46 48 } 47 49 }
+7 -5
internal/appherder/sync.go
··· 7 7 "path/filepath" 8 8 "sort" 9 9 "strings" 10 + 11 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 10 12 ) 11 13 12 14 // installConcurrency caps parallel installs: enough to overlap metadata I/O ··· 159 161 prefix := appimagesDir + string(filepath.Separator) 160 162 var orphans []string 161 163 for _, path := range matches { 162 - desktop, err := readDesktopFile(path) 164 + desktop, err := desktopfile.Read(path) 163 165 if err != nil { 164 166 return nil, fmt.Errorf("read desktop file %s: %w", path, err) 165 167 } 166 - if value, ok := desktop.get(desktopOwnerKey, desktopEntrySection); ok && value == "true" { 168 + if value, ok := desktop.Get(desktopEntrySection, desktopOwnerKey); ok && value == "true" { 167 169 continue 168 170 } 169 171 target := desktopTarget(desktop) ··· 182 184 183 185 // desktopTarget returns the executable path a launcher points at, preferring 184 186 // TryExec. 185 - func desktopTarget(desktop *desktopFile) string { 186 - if tryExec, ok := desktop.get("TryExec", desktopEntrySection); ok && tryExec != "" { 187 + func desktopTarget(desktop *desktopfile.File) string { 188 + if tryExec, ok := desktop.Get(desktopEntrySection, "TryExec"); ok && tryExec != "" { 187 189 return tryExec 188 190 } 189 - if exec, ok := desktop.get("Exec", desktopEntrySection); ok { 191 + if exec, ok := desktop.Get(desktopEntrySection, "Exec"); ok { 190 192 return execPath(exec) 191 193 } 192 194 return ""
+1 -2
package.nix
··· 1 1 { 2 2 buildGoModule, 3 - lib, 4 3 }: let 5 4 version = "dev"; 6 5 in ··· 8 7 pname = "appherder"; 9 8 inherit version; 10 9 src = ./.; 11 - vendorHash = "sha256-cnxgWLpc8l/dvJHgj1PkJrSYmsH88pCTISM+pf7Ulg4="; 10 + vendorHash = "sha256-hjyL3/01LWN2VTEBMwJmUyTi+tfh4zMVPWiNTkzCWfk="; 12 11 subPackages = ["cmd/appherder"]; 13 12 ldflags = ["-X main.version=${version}"]; 14 13 }