Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

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

internal/browserscan: init

Aly Raffauf (Jul 5, 2026, 9:26 PM EDT) 49266e27 768a4f0a

+368
+131
internal/browserscan/browserscan.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + // Package browserscan discovers installed HTTP(S) browsers by parsing the 4 + // .desktop files on the XDG data search path. It has no GTK/GIO dependency. 5 + package browserscan 6 + 7 + import ( 8 + "cmp" 9 + "io/fs" 10 + "path/filepath" 11 + "slices" 12 + "strings" 13 + 14 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 15 + "github.com/alyraffauf/goxdgdesktop/xdg" 16 + "github.com/alyraffauf/switchyard/internal/browser" 17 + ) 18 + 19 + // A browser switcher must never list itself, across all packaged variants. 20 + const selfIDPrefix = "io.github.alyraffauf.Switchyard" 21 + 22 + var httpSchemeHandlers = []string{ 23 + "x-scheme-handler/http", 24 + "x-scheme-handler/https", 25 + } 26 + 27 + // applicationsDirs is a variable so tests can override the search path. 28 + var applicationsDirs = xdg.ApplicationsDirs 29 + 30 + // Installed returns the installed HTTP(S) browsers, sorted by Name. Switchyard's 31 + // own entries are excluded; filtering config-hidden browsers is the caller's job. 32 + func Installed() []browser.Browser { 33 + var browsers []browser.Browser 34 + seen := map[string]bool{} 35 + 36 + for _, dir := range applicationsDirs() { 37 + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 38 + if err != nil { 39 + // Skip missing/unreadable dirs rather than aborting the walk. 40 + if d != nil && d.IsDir() { 41 + return fs.SkipDir 42 + } 43 + return nil 44 + } 45 + if d.IsDir() || !strings.HasSuffix(d.Name(), ".desktop") { 46 + return nil 47 + } 48 + 49 + id := desktopID(dir, path) 50 + // First dir to define an ID wins. Mark seen even on rejection, so a 51 + // shadowed copy in a lower-priority dir can't resurface. 52 + if seen[id] { 53 + return nil 54 + } 55 + seen[id] = true 56 + 57 + if strings.HasPrefix(id, selfIDPrefix) { 58 + return nil 59 + } 60 + 61 + if parsed, ok := parseBrowser(id, path); ok { 62 + browsers = append(browsers, parsed) 63 + } 64 + return nil 65 + }) 66 + } 67 + 68 + slices.SortFunc(browsers, func(first, second browser.Browser) int { 69 + return cmp.Compare(first.Name, second.Name) 70 + }) 71 + 72 + return browsers 73 + } 74 + 75 + // desktopID is path's desktop-file ID relative to dir, with separators replaced 76 + // by "-" per the spec (sub/foo.desktop -> sub-foo.desktop). 77 + func desktopID(dir, path string) string { 78 + rel, err := filepath.Rel(dir, path) 79 + if err != nil { 80 + rel = filepath.Base(path) 81 + } 82 + return strings.ReplaceAll(filepath.ToSlash(rel), "/", "-") 83 + } 84 + 85 + // parseBrowser returns the Browser at path, or ok=false if it isn't a 86 + // displayable HTTP(S) browser. 87 + func parseBrowser(id, path string) (browser.Browser, bool) { 88 + file, err := desktopfile.Read(path) 89 + if err != nil { 90 + return browser.Browser{}, false 91 + } 92 + 93 + if typ, _ := file.Get(desktopfile.EntrySection, "Type"); typ != "Application" { 94 + return browser.Browser{}, false 95 + } 96 + if isTrue(file, "Hidden") || isTrue(file, "NoDisplay") { 97 + return browser.Browser{}, false 98 + } 99 + if !handlesHTTP(file) { 100 + return browser.Browser{}, false 101 + } 102 + 103 + name, _ := file.Get(desktopfile.EntrySection, "Name") 104 + icon, _ := file.Get(desktopfile.EntrySection, "Icon") 105 + exec, _ := file.Get(desktopfile.EntrySection, "Exec") 106 + 107 + return browser.Browser{ 108 + ID: id, 109 + Name: name, 110 + Icon: icon, 111 + Exec: exec, 112 + }, true 113 + } 114 + 115 + func isTrue(file *desktopfile.File, key string) bool { 116 + value, _ := file.Get(desktopfile.EntrySection, key) 117 + return strings.EqualFold(value, "true") 118 + } 119 + 120 + func handlesHTTP(file *desktopfile.File) bool { 121 + mimeType, ok := file.Get(desktopfile.EntrySection, "MimeType") 122 + if !ok { 123 + return false 124 + } 125 + for mime := range strings.SplitSeq(mimeType, ";") { 126 + if slices.Contains(httpSchemeHandlers, strings.TrimSpace(mime)) { 127 + return true 128 + } 129 + } 130 + return false 131 + }
+237
internal/browserscan/browserscan_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browserscan 4 + 5 + import ( 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + 10 + "github.com/alyraffauf/switchyard/internal/browser" 11 + ) 12 + 13 + // browserEntry is a minimal desktop file that handles HTTP(S). 14 + const browserEntry = `[Desktop Entry] 15 + Type=Application 16 + Name=Test Browser 17 + Icon=test-browser 18 + Exec=test-browser %u 19 + MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https; 20 + ` 21 + 22 + // writeDesktop writes a .desktop file at dir/<rel> (rel may contain slashes for 23 + // nested subdirs), creating parent dirs as needed. 24 + func writeDesktop(t *testing.T, dir, rel, contents string) { 25 + t.Helper() 26 + path := filepath.Join(dir, filepath.FromSlash(rel)) 27 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 28 + t.Fatalf("mkdir: %v", err) 29 + } 30 + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { 31 + t.Fatalf("write %s: %v", rel, err) 32 + } 33 + } 34 + 35 + // isolatedDirs overrides the applications search path with two temp dirs (a 36 + // higher-priority "home" dir and a lower-priority "system" dir), fully isolating 37 + // the scan from the host's real desktop files. It returns those dirs. 38 + func isolatedDirs(t *testing.T) (home, system string) { 39 + t.Helper() 40 + home = filepath.Join(t.TempDir(), "applications") 41 + system = filepath.Join(t.TempDir(), "applications") 42 + orig := applicationsDirs 43 + applicationsDirs = func() []string { return []string{home, system} } 44 + t.Cleanup(func() { applicationsDirs = orig }) 45 + return home, system 46 + } 47 + 48 + func ids(browsers []browser.Browser) []string { 49 + out := make([]string, len(browsers)) 50 + for i, b := range browsers { 51 + out[i] = b.ID 52 + } 53 + return out 54 + } 55 + 56 + func TestInstalledIncludesBrowserAndFields(t *testing.T) { 57 + home, _ := isolatedDirs(t) 58 + writeDesktop(t, home, "test-browser.desktop", browserEntry) 59 + 60 + got := Installed() 61 + if len(got) != 1 { 62 + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) 63 + } 64 + want := browser.Browser{ 65 + ID: "test-browser.desktop", 66 + Name: "Test Browser", 67 + Icon: "test-browser", 68 + Exec: "test-browser %u", 69 + } 70 + if got[0] != want { 71 + t.Errorf("got %+v, want %+v", got[0], want) 72 + } 73 + } 74 + 75 + func TestInstalledFiltering(t *testing.T) { 76 + tests := []struct { 77 + name string 78 + contents string 79 + }{ 80 + { 81 + name: "no http mimetype", 82 + contents: `[Desktop Entry] 83 + Type=Application 84 + Name=Not A Browser 85 + MimeType=text/html; 86 + `, 87 + }, 88 + { 89 + name: "no mimetype at all", 90 + contents: `[Desktop Entry] 91 + Type=Application 92 + Name=Plain App 93 + `, 94 + }, 95 + { 96 + name: "nodisplay", 97 + contents: `[Desktop Entry] 98 + Type=Application 99 + Name=Hidden Browser 100 + NoDisplay=true 101 + MimeType=x-scheme-handler/http; 102 + `, 103 + }, 104 + { 105 + name: "hidden", 106 + contents: `[Desktop Entry] 107 + Type=Application 108 + Name=Hidden Browser 109 + Hidden=TRUE 110 + MimeType=x-scheme-handler/http; 111 + `, 112 + }, 113 + { 114 + name: "wrong type", 115 + contents: `[Desktop Entry] 116 + Type=Link 117 + Name=Link Entry 118 + URL=https://example.com 119 + MimeType=x-scheme-handler/http; 120 + `, 121 + }, 122 + { 123 + name: "missing type", 124 + contents: `[Desktop Entry] 125 + Name=No Type 126 + MimeType=x-scheme-handler/http; 127 + `, 128 + }, 129 + } 130 + 131 + for _, tt := range tests { 132 + t.Run(tt.name, func(t *testing.T) { 133 + home, _ := isolatedDirs(t) 134 + writeDesktop(t, home, "candidate.desktop", tt.contents) 135 + 136 + if got := Installed(); len(got) != 0 { 137 + t.Errorf("expected entry excluded, got %v", ids(got)) 138 + } 139 + }) 140 + } 141 + } 142 + 143 + func TestInstalledExcludesSwitchyardFamily(t *testing.T) { 144 + home, _ := isolatedDirs(t) 145 + // Both the stable and Devel variants are installed; neither may appear. 146 + writeDesktop(t, home, "io.github.alyraffauf.Switchyard.desktop", browserEntry) 147 + writeDesktop(t, home, "io.github.alyraffauf.Switchyard.Devel.desktop", browserEntry) 148 + writeDesktop(t, home, "real-browser.desktop", browserEntry) 149 + 150 + got := ids(Installed()) 151 + want := []string{"real-browser.desktop"} 152 + if len(got) != len(want) || got[0] != want[0] { 153 + t.Errorf("got %v, want %v", got, want) 154 + } 155 + } 156 + 157 + func TestInstalledShadowingHomeWins(t *testing.T) { 158 + home, system := isolatedDirs(t) 159 + // Same ID in both dirs with different names; the data-home copy must win. 160 + writeDesktop(t, home, "browser.desktop", `[Desktop Entry] 161 + Type=Application 162 + Name=Home Browser 163 + MimeType=x-scheme-handler/http; 164 + `) 165 + writeDesktop(t, system, "browser.desktop", `[Desktop Entry] 166 + Type=Application 167 + Name=System Browser 168 + MimeType=x-scheme-handler/http; 169 + `) 170 + 171 + got := Installed() 172 + if len(got) != 1 { 173 + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) 174 + } 175 + if got[0].Name != "Home Browser" { 176 + t.Errorf("got %q, want %q", got[0].Name, "Home Browser") 177 + } 178 + } 179 + 180 + func TestInstalledShadowingHiddenHomeSuppressesSystem(t *testing.T) { 181 + home, system := isolatedDirs(t) 182 + // A hidden copy in data-home shadows a valid copy in the system dir. 183 + writeDesktop(t, home, "browser.desktop", `[Desktop Entry] 184 + Type=Application 185 + Name=Home Browser 186 + NoDisplay=true 187 + MimeType=x-scheme-handler/http; 188 + `) 189 + writeDesktop(t, system, "browser.desktop", browserEntry) 190 + 191 + if got := Installed(); len(got) != 0 { 192 + t.Errorf("expected shadowed system copy suppressed, got %v", ids(got)) 193 + } 194 + } 195 + 196 + func TestInstalledNestedSubdirID(t *testing.T) { 197 + home, _ := isolatedDirs(t) 198 + writeDesktop(t, home, "sub/foo.desktop", browserEntry) 199 + 200 + got := Installed() 201 + if len(got) != 1 { 202 + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) 203 + } 204 + if got[0].ID != "sub-foo.desktop" { 205 + t.Errorf("got ID %q, want %q", got[0].ID, "sub-foo.desktop") 206 + } 207 + } 208 + 209 + func TestInstalledSortedByName(t *testing.T) { 210 + home, _ := isolatedDirs(t) 211 + writeDesktop(t, home, "charlie.desktop", `[Desktop Entry] 212 + Type=Application 213 + Name=Charlie 214 + MimeType=x-scheme-handler/http; 215 + `) 216 + writeDesktop(t, home, "alice.desktop", `[Desktop Entry] 217 + Type=Application 218 + Name=Alice 219 + MimeType=x-scheme-handler/http; 220 + `) 221 + writeDesktop(t, home, "bob.desktop", `[Desktop Entry] 222 + Type=Application 223 + Name=Bob 224 + MimeType=x-scheme-handler/https; 225 + `) 226 + 227 + got := ids(Installed()) 228 + want := []string{"alice.desktop", "bob.desktop", "charlie.desktop"} 229 + if len(got) != len(want) { 230 + t.Fatalf("got %v, want %v", got, want) 231 + } 232 + for i := range want { 233 + if got[i] != want[i] { 234 + t.Fatalf("got %v, want %v", got, want) 235 + } 236 + } 237 + }