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.

browserscan: adopt as single browser source with localized names/actions

Aly Raffauf (Jul 5, 2026, 10:42 PM EDT) 3bc82478 00ffd102

+240 -51
+13 -28
gtk/browser.go
··· 5 5 import ( 6 6 "fmt" 7 7 "os" 8 - "sort" 9 8 10 9 appbrowser "github.com/alyraffauf/switchyard/internal/browser" 10 + "github.com/alyraffauf/switchyard/internal/browserscan" 11 11 "github.com/alyraffauf/switchyard/internal/host" 12 12 "github.com/diamondburned/gotk4/pkg/gdk/v4" 13 13 "github.com/diamondburned/gotk4/pkg/gio/v2" ··· 24 24 // DesktopAction is a desktop-entry action such as "new-private-window". 25 25 type DesktopAction = appbrowser.Action 26 26 27 - // detectBrowsers returns the installed browsers that handle HTTP URLs, sorted 28 - // by name. GIO transparently covers system apps, Flatpaks, and Snaps. 27 + // detectBrowsers gets metadata from browserscan and an optional live GIO handle. 29 28 func detectBrowsers() []*Browser { 30 - appInfos := gio.AppInfoGetRecommendedForType("x-scheme-handler/http") 31 - 32 - browsers := make([]*Browser, 0, len(appInfos)) 33 - selfDesktopID := getAppID() + ".desktop" 34 - 35 - for _, appInfo := range appInfos { 36 - id := appInfo.ID() 37 - if id == "" || id == selfDesktopID { 38 - continue 29 + appInfoByID := make(map[string]*gio.AppInfo) 30 + for _, appInfo := range gio.AppInfoGetRecommendedForType("x-scheme-handler/http") { 31 + if id := appInfo.ID(); id != "" { 32 + appInfoByID[id] = appInfo 39 33 } 34 + } 40 35 41 - icon := "" 42 - if gicon := appInfo.Icon(); gicon != nil { 43 - icon = gicon.String() 44 - } 45 - 36 + installed := browserscan.Installed() 37 + browsers := make([]*Browser, 0, len(installed)) 38 + for i := range installed { 39 + browserModel := installed[i] 46 40 browsers = append(browsers, &Browser{ 47 - Browser: &appbrowser.Browser{ 48 - ID: id, 49 - Name: appInfo.Name(), 50 - Icon: icon, 51 - Exec: appInfo.Commandline(), 52 - }, 53 - appInfo: appInfo, 41 + Browser: &browserModel, 42 + appInfo: appInfoByID[browserModel.ID], 54 43 }) 55 44 } 56 - 57 - sort.Slice(browsers, func(i, j int) bool { 58 - return browsers[i].Name < browsers[j].Name 59 - }) 60 45 61 46 return browsers 62 47 }
+3 -6
gtk/launcher_actions.go
··· 7 7 "fmt" 8 8 "strings" 9 9 10 - appbrowser "github.com/alyraffauf/switchyard/internal/browser" 11 10 appconfig "github.com/alyraffauf/switchyard/internal/config" 12 11 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 13 12 "github.com/diamondburned/gotk4/pkg/gio/v2" ··· 16 15 ) 17 16 18 17 func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { 19 - actions := appbrowser.ListDesktopActions(browser.ID) 20 - if len(actions) == 0 { 18 + if len(browser.Actions) == 0 { 21 19 return 22 20 } 23 21 24 22 menu := gio.NewMenu() 25 - for _, action := range actions { 23 + for _, action := range browser.Actions { 26 24 menu.Append(action.Name, fmt.Sprintf("win.launch-action::%s:%s", browser.ID, action.ID)) 27 25 } 28 26 ··· 107 105 return 108 106 } 109 107 110 - actions := appbrowser.ListDesktopActions(selectedBrowser.ID) 111 - for _, action := range actions { 108 + for _, action := range selectedBrowser.Actions { 112 109 if action.ID == actionID { 113 110 launchBrowserAction(selectedBrowser, action, url) 114 111 onClose()
+1 -1
internal/browser/actions.go
··· 26 26 if err != nil { 27 27 return nil 28 28 } 29 - return file.Actions() 29 + return LocalizedActions(file) 30 30 }
+5 -4
internal/browser/browser.go
··· 7 7 8 8 // Browser is an installed web browser that can open URLs. 9 9 type Browser struct { 10 - ID string // desktop file ID, e.g. "firefox.desktop" 11 - Name string 12 - Icon string // themed icon name; may be empty 13 - Exec string // desktop-entry command line with field codes, e.g. "firefox %u" 10 + ID string // desktop file ID, e.g. "firefox.desktop" 11 + Name string 12 + Icon string 13 + Exec string 14 + Actions []Action 14 15 }
+89
internal/browser/locale.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + import ( 6 + "os" 7 + "strings" 8 + 9 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 10 + ) 11 + 12 + // LocalizedString resolves key for the current Desktop Entry locale. 13 + func LocalizedString(file *desktopfile.File, section, key string) string { 14 + for _, candidate := range localeKeyCandidates(key) { 15 + if value, ok := file.Get(section, candidate); ok && value != "" { 16 + return value 17 + } 18 + } 19 + return "" 20 + } 21 + 22 + // LocalizedActions resolves each action name for the current locale. 23 + func LocalizedActions(file *desktopfile.File) []Action { 24 + actions := file.Actions() 25 + for i := range actions { 26 + section := desktopfile.ActionSectionStart + actions[i].ID 27 + if name := LocalizedString(file, section, "Name"); name != "" { 28 + actions[i].Name = name 29 + } 30 + } 31 + return actions 32 + } 33 + 34 + // For lang_COUNTRY@MODIFIER, the spec order is Key[lang_COUNTRY@MODIFIER], 35 + // Key[lang_COUNTRY], Key[lang@MODIFIER], Key[lang], then Key. 36 + func localeKeyCandidates(key string) []string { 37 + var candidates []string 38 + for _, loc := range preferredLocales() { 39 + lang, country, modifier := splitLocale(loc) 40 + if lang == "" || lang == "C" || lang == "POSIX" { 41 + continue 42 + } 43 + if country != "" && modifier != "" { 44 + candidates = append(candidates, key+"["+lang+"_"+country+"@"+modifier+"]") 45 + } 46 + if country != "" { 47 + candidates = append(candidates, key+"["+lang+"_"+country+"]") 48 + } 49 + if modifier != "" { 50 + candidates = append(candidates, key+"["+lang+"@"+modifier+"]") 51 + } 52 + candidates = append(candidates, key+"["+lang+"]") 53 + } 54 + return append(candidates, key) 55 + } 56 + 57 + func preferredLocales() []string { 58 + if language := os.Getenv("LANGUAGE"); language != "" { 59 + var locales []string 60 + for _, loc := range strings.Split(language, ":") { 61 + if loc = strings.TrimSpace(loc); loc != "" { 62 + locales = append(locales, loc) 63 + } 64 + } 65 + if len(locales) > 0 { 66 + return locales 67 + } 68 + } 69 + for _, env := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { 70 + if value := os.Getenv(env); value != "" { 71 + return []string{value} 72 + } 73 + } 74 + return nil 75 + } 76 + 77 + func splitLocale(loc string) (lang, country, modifier string) { 78 + if i := strings.IndexByte(loc, '@'); i >= 0 { 79 + modifier = loc[i+1:] 80 + loc = loc[:i] 81 + } 82 + if i := strings.IndexByte(loc, '.'); i >= 0 { 83 + loc = loc[:i] 84 + } 85 + if i := strings.IndexByte(loc, '_'); i >= 0 { 86 + return loc[:i], loc[i+1:], modifier 87 + } 88 + return loc, "", modifier 89 + }
+81
internal/browser/locale_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + import ( 6 + "testing" 7 + 8 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 9 + ) 10 + 11 + const localeEntry = `[Desktop Entry] 12 + Type=Application 13 + Name=Web Browser 14 + Name[fr]=Navigateur Web 15 + Name[fr_CA]=Fureteur Web 16 + Exec=browser %u 17 + 18 + [Desktop Action new-window] 19 + Name=New Window 20 + Name[fr]=Nouvelle fenêtre 21 + Exec=browser --new-window %u 22 + ` 23 + 24 + func clearLocaleEnv(t *testing.T) { 25 + t.Helper() 26 + for _, env := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} { 27 + t.Setenv(env, "") 28 + } 29 + } 30 + 31 + func TestLocalizedStringFallback(t *testing.T) { 32 + file := desktopfile.Parse([]byte(localeEntry)) 33 + 34 + tests := []struct { 35 + name string 36 + lang string 37 + want string 38 + }{ 39 + {"unset falls back to plain", "", "Web Browser"}, 40 + {"exact language", "fr_FR.UTF-8", "Navigateur Web"}, 41 + {"country-specific wins", "fr_CA.UTF-8", "Fureteur Web"}, 42 + {"unknown locale falls back", "de_DE.UTF-8", "Web Browser"}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + clearLocaleEnv(t) 48 + if tt.lang != "" { 49 + t.Setenv("LANG", tt.lang) 50 + } 51 + if got := LocalizedString(file, desktopfile.EntrySection, "Name"); got != tt.want { 52 + t.Errorf("got %q, want %q", got, tt.want) 53 + } 54 + }) 55 + } 56 + } 57 + 58 + func TestLocalizedStringLanguagePriority(t *testing.T) { 59 + clearLocaleEnv(t) 60 + t.Setenv("LANG", "de_DE.UTF-8") 61 + t.Setenv("LANGUAGE", "de:fr") 62 + 63 + file := desktopfile.Parse([]byte(localeEntry)) 64 + if got := LocalizedString(file, desktopfile.EntrySection, "Name"); got != "Navigateur Web" { 65 + t.Errorf("got %q, want %q", got, "Navigateur Web") 66 + } 67 + } 68 + 69 + func TestLocalizedActions(t *testing.T) { 70 + clearLocaleEnv(t) 71 + t.Setenv("LANG", "fr_FR.UTF-8") 72 + 73 + file := desktopfile.Parse([]byte(localeEntry)) 74 + actions := LocalizedActions(file) 75 + if len(actions) != 1 { 76 + t.Fatalf("got %d actions, want 1", len(actions)) 77 + } 78 + if actions[0].ID != "new-window" || actions[0].Name != "Nouvelle fenêtre" { 79 + t.Errorf("unexpected action: %+v", actions[0]) 80 + } 81 + }
+5 -5
internal/browserscan/browserscan.go
··· 100 100 return browser.Browser{}, false 101 101 } 102 102 103 - name, _ := file.Get(desktopfile.EntrySection, "Name") 104 103 icon, _ := file.Get(desktopfile.EntrySection, "Icon") 105 104 exec, _ := file.Get(desktopfile.EntrySection, "Exec") 106 105 107 106 return browser.Browser{ 108 - ID: id, 109 - Name: name, 110 - Icon: icon, 111 - Exec: exec, 107 + ID: id, 108 + Name: browser.LocalizedString(file, desktopfile.EntrySection, "Name"), 109 + Icon: icon, 110 + Exec: exec, 111 + Actions: browser.LocalizedActions(file), 112 112 }, true 113 113 } 114 114
+43 -7
internal/browserscan/browserscan_test.go
··· 53 53 return out 54 54 } 55 55 56 + func clearLocaleEnv(t *testing.T) { 57 + t.Helper() 58 + for _, env := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} { 59 + t.Setenv(env, "") 60 + } 61 + } 62 + 56 63 func TestInstalledIncludesBrowserAndFields(t *testing.T) { 57 64 home, _ := isolatedDirs(t) 58 65 writeDesktop(t, home, "test-browser.desktop", browserEntry) ··· 61 68 if len(got) != 1 { 62 69 t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) 63 70 } 64 - want := browser.Browser{ 65 - ID: "test-browser.desktop", 66 - Name: "Test Browser", 67 - Icon: "test-browser", 68 - Exec: "test-browser %u", 71 + b := got[0] 72 + if b.ID != "test-browser.desktop" || b.Name != "Test Browser" || 73 + b.Icon != "test-browser" || b.Exec != "test-browser %u" { 74 + t.Errorf("unexpected fields: %+v", b) 75 + } 76 + if len(b.Actions) != 0 { 77 + t.Errorf("expected no actions, got %v", b.Actions) 69 78 } 70 - if got[0] != want { 71 - t.Errorf("got %+v, want %+v", got[0], want) 79 + } 80 + 81 + func TestInstalledLocalizedNameAndActions(t *testing.T) { 82 + clearLocaleEnv(t) 83 + t.Setenv("LANG", "fr_FR.UTF-8") 84 + 85 + home, _ := isolatedDirs(t) 86 + writeDesktop(t, home, "loc.desktop", `[Desktop Entry] 87 + Type=Application 88 + Name=Web Browser 89 + Name[fr]=Navigateur Web 90 + Exec=browser %u 91 + MimeType=x-scheme-handler/http; 92 + 93 + [Desktop Action new-window] 94 + Name=New Window 95 + Name[fr]=Nouvelle fenêtre 96 + Exec=browser --new-window %u 97 + `) 98 + 99 + got := Installed() 100 + if len(got) != 1 { 101 + t.Fatalf("got %d browsers, want 1", len(got)) 102 + } 103 + if got[0].Name != "Navigateur Web" { 104 + t.Errorf("browser name: got %q, want %q", got[0].Name, "Navigateur Web") 105 + } 106 + if len(got[0].Actions) != 1 || got[0].Actions[0].Name != "Nouvelle fenêtre" { 107 + t.Errorf("actions: got %+v", got[0].Actions) 72 108 } 73 109 } 74 110