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.

gtk: add launch-by-id and app-info lookup helpers

Aly Raffauf (Jul 5, 2026, 11:03 PM EDT) 19134562 b0cb84c4

+184 -18
+22
gtk/browser.go
··· 51 51 return browsers 52 52 } 53 53 54 + func appInfoForID(id string) *gio.AppInfo { 55 + for _, appInfo := range gio.AppInfoGetRecommendedForType("x-scheme-handler/http") { 56 + if appInfo.ID() == id { 57 + return appInfo 58 + } 59 + } 60 + return nil 61 + } 62 + 63 + func launchBrowserByID(id, url string) bool { 64 + browserModel, ok := browserscan.Find(id) 65 + if !ok { 66 + return false 67 + } 68 + 69 + launchBrowser(&Browser{ 70 + Browser: &browserModel, 71 + appInfo: appInfoForID(browserModel.ID), 72 + }, url) 73 + return true 74 + } 75 + 54 76 func launchBrowser(b *Browser, url string) { 55 77 if b.Exec == "" { 56 78 fmt.Fprintf(os.Stderr, "Error: No command line for browser %s\n", b.Name)
+12 -16
gtk/run.go
··· 49 49 app.Hold() 50 50 } 51 51 setupApp(cfg) 52 - browsers := detectBrowsers() 53 52 54 53 if len(files) == 0 { 55 - showSettingsWindow(app, browsers, cfg) 54 + showSettingsWindow(app, detectBrowsers(), cfg) 56 55 return 57 56 } 58 57 59 58 rawURL := files[0].URI() 60 59 61 60 if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { 62 - handleSwitchyardURL(app, browsers, cfg, rawURL) 61 + handleSwitchyardURL(app, cfg, rawURL) 63 62 return 64 63 } 65 64 ··· 71 70 return 72 71 } 73 72 74 - handleURL(app, browsers, cfg, sanitized) 73 + handleURL(app, cfg, sanitized) 75 74 }) 76 75 77 76 if code := app.Run(os.Args); code > 0 { ··· 99 98 } 100 99 101 100 // handleSwitchyardURL processes switchyard:// URLs with browser preferences 102 - func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) { 101 + func handleSwitchyardURL(app *adw.Application, cfg *Config, rawURL string) { 103 102 targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) 104 103 if err != nil { 105 104 // Invalid switchyard URL - ignore ··· 122 121 if !strings.HasSuffix(id, ".desktop") { 123 122 id = id + ".desktop" 124 123 } 125 - if browser := findBrowserByID(browsers, id); browser != nil { 126 - launchBrowser(browser, sanitized) 124 + if launchBrowserByID(id, sanitized) { 127 125 return 128 126 } 129 127 } ··· 131 129 } 132 130 133 131 // No browser specified or none matched — use standard routing. 134 - handleURL(app, browsers, cfg, sanitized) 132 + handleURL(app, cfg, sanitized) 135 133 } 136 134 137 135 // handleURL routes a URL to the appropriate browser based on rules 138 - func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) { 136 + func handleURL(app *adw.Application, cfg *Config, urlStr string) { 139 137 browserID, alwaysAsk, matched := cfg.MatchRule(urlStr) 140 138 if matched { 141 139 if alwaysAsk { 142 - showLauncherWindow(app, urlStr, browsers, cfg) 140 + showLauncherWindow(app, urlStr, detectBrowsers(), cfg) 143 141 return 144 142 } 145 143 146 - if browser := findBrowserByID(browsers, browserID); browser != nil { 147 - launchBrowser(browser, urlStr) 144 + if launchBrowserByID(browserID, urlStr) { 148 145 return 149 146 } 150 147 // Rule matched but browser not found — show launcher. 151 - showLauncherWindow(app, urlStr, browsers, cfg) 148 + showLauncherWindow(app, urlStr, detectBrowsers(), cfg) 152 149 return 153 150 } 154 151 155 152 // No rule matched: fall back to the favorite browser, else prompt. 156 153 if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { 157 - if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil { 158 - launchBrowser(browser, urlStr) 154 + if launchBrowserByID(cfg.FavoriteBrowser, urlStr) { 159 155 return 160 156 } 161 157 } 162 158 163 - showLauncherWindow(app, urlStr, browsers, cfg) 159 + showLauncherWindow(app, urlStr, detectBrowsers(), cfg) 164 160 }
+1 -2
internal/browserscan/actions.go
··· 4 4 5 5 import ( 6 6 "github.com/alyraffauf/goxdgdesktop/desktopfile" 7 - "github.com/alyraffauf/goxdgdesktop/xdg" 8 7 "github.com/alyraffauf/switchyard/internal/browser" 9 8 ) 10 9 ··· 13 12 return nil 14 13 } 15 14 16 - desktopFilePath := xdg.FindDesktopFile(appID) 15 + desktopFilePath := findDesktopFile(appID) 17 16 if desktopFilePath == "" { 18 17 return nil 19 18 }
+53
internal/browserscan/browserscan.go
··· 7 7 import ( 8 8 "cmp" 9 9 "io/fs" 10 + "os" 10 11 "path/filepath" 11 12 "slices" 12 13 "strings" ··· 70 71 }) 71 72 72 73 return browsers 74 + } 75 + 76 + // Find returns a displayable HTTP(S) browser by desktop file ID. 77 + func Find(id string) (browser.Browser, bool) { 78 + if id == "" || strings.HasPrefix(id, selfIDPrefix) { 79 + return browser.Browser{}, false 80 + } 81 + 82 + for _, dir := range applicationsDirs() { 83 + path, ok := desktopFilePath(dir, id) 84 + if !ok { 85 + continue 86 + } 87 + return parseBrowser(id, path) 88 + } 89 + 90 + return browser.Browser{}, false 91 + } 92 + 93 + func findDesktopFile(id string) string { 94 + for _, dir := range applicationsDirs() { 95 + if path, ok := desktopFilePath(dir, id); ok { 96 + return path 97 + } 98 + } 99 + return "" 100 + } 101 + 102 + func desktopFilePath(dir, id string) (string, bool) { 103 + path := filepath.Join(dir, id) 104 + if _, err := os.Stat(path); err == nil { 105 + return path, true 106 + } 107 + 108 + var match string 109 + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 110 + if err != nil { 111 + if d != nil && d.IsDir() { 112 + return fs.SkipDir 113 + } 114 + return nil 115 + } 116 + if d.IsDir() || !strings.HasSuffix(d.Name(), ".desktop") { 117 + return nil 118 + } 119 + if desktopID(dir, path) != id { 120 + return nil 121 + } 122 + match = path 123 + return fs.SkipAll 124 + }) 125 + return match, match != "" 73 126 } 74 127 75 128 // desktopID is path's desktop-file ID relative to dir, with separators replaced
+96
internal/browserscan/browserscan_test.go
··· 273 273 } 274 274 } 275 275 276 + func TestFindBrowser(t *testing.T) { 277 + home, _ := isolatedDirs(t) 278 + writeDesktop(t, home, "test-browser.desktop", browserEntry) 279 + 280 + got, ok := Find("test-browser.desktop") 281 + if !ok { 282 + t.Fatal("Find returned false") 283 + } 284 + if got.ID != "test-browser.desktop" || got.Name != "Test Browser" || 285 + got.Icon != "test-browser" || got.Exec != "test-browser %u" { 286 + t.Errorf("unexpected browser: %+v", got) 287 + } 288 + } 289 + 290 + func TestFindMissingOrRejected(t *testing.T) { 291 + tests := []struct { 292 + name string 293 + id string 294 + contents string 295 + }{ 296 + { 297 + name: "missing", 298 + id: "missing.desktop", 299 + }, 300 + { 301 + name: "hidden", 302 + id: "hidden.desktop", 303 + contents: `[Desktop Entry] 304 + Type=Application 305 + Name=Hidden 306 + Hidden=true 307 + MimeType=x-scheme-handler/http; 308 + `, 309 + }, 310 + { 311 + name: "not browser", 312 + id: "app.desktop", 313 + contents: `[Desktop Entry] 314 + Type=Application 315 + Name=App 316 + MimeType=text/plain; 317 + `, 318 + }, 319 + } 320 + 321 + for _, tt := range tests { 322 + t.Run(tt.name, func(t *testing.T) { 323 + home, _ := isolatedDirs(t) 324 + if tt.contents != "" { 325 + writeDesktop(t, home, tt.id, tt.contents) 326 + } 327 + 328 + if got, ok := Find(tt.id); ok { 329 + t.Errorf("Find returned %+v, want false", got) 330 + } 331 + }) 332 + } 333 + } 334 + 335 + func TestFindShadowingRejectedHomeSuppressesSystem(t *testing.T) { 336 + home, system := isolatedDirs(t) 337 + writeDesktop(t, home, "browser.desktop", `[Desktop Entry] 338 + Type=Application 339 + Name=Hidden Home Browser 340 + NoDisplay=true 341 + MimeType=x-scheme-handler/http; 342 + `) 343 + writeDesktop(t, system, "browser.desktop", browserEntry) 344 + 345 + if got, ok := Find("browser.desktop"); ok { 346 + t.Errorf("Find returned %+v, want false", got) 347 + } 348 + } 349 + 350 + func TestFindNestedSubdirID(t *testing.T) { 351 + home, _ := isolatedDirs(t) 352 + writeDesktop(t, home, "sub/foo.desktop", browserEntry) 353 + 354 + got, ok := Find("sub-foo.desktop") 355 + if !ok { 356 + t.Fatal("Find returned false") 357 + } 358 + if got.ID != "sub-foo.desktop" { 359 + t.Errorf("got ID %q, want %q", got.ID, "sub-foo.desktop") 360 + } 361 + } 362 + 363 + func TestFindExcludesSwitchyardFamily(t *testing.T) { 364 + home, _ := isolatedDirs(t) 365 + writeDesktop(t, home, "io.github.alyraffauf.Switchyard.desktop", browserEntry) 366 + 367 + if got, ok := Find("io.github.alyraffauf.Switchyard.desktop"); ok { 368 + t.Errorf("Find returned %+v, want false", got) 369 + } 370 + } 371 + 276 372 func BenchmarkInstalledManyDesktopFiles(b *testing.B) { 277 373 home := filepath.Join(b.TempDir(), "applications") 278 374 system := filepath.Join(b.TempDir(), "applications")