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.

refactor browser detection, host interaction, and gtk to their own packages

Aly Raffauf (Jul 5, 2026, 1:45 PM EDT) 4aa4ca7f a8c6443c

+524 -452
+2
.github/workflows/ci.yml
··· 33 33 run: just test-routing-coverage 34 34 - name: Test config package 35 35 run: just test-config 36 + - name: Test browser package 37 + run: just test-browser 36 38 - name: Display coverage summary 37 39 run: | 38 40 echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
+1 -1
cmd/switchyard/app.go gtk/app.go
··· 1 1 // Switchyard - A configurable default browser for Linux 2 2 // SPDX-License-Identifier: GPL-3.0-or-later 3 3 4 - package main 4 + package gtk 5 5 6 6 import "os" 7 7
-80
cmd/switchyard/browser.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "fmt" 7 - "os" 8 - "sort" 9 - 10 - "github.com/diamondburned/gotk4/pkg/gio/v2" 11 - ) 12 - 13 - type Browser struct { 14 - ID string // desktop file ID (e.g., "firefox.desktop") 15 - Name string 16 - Icon string 17 - AppInfo *gio.AppInfo // Store the GIO AppInfo for launching 18 - } 19 - 20 - func detectBrowsers() []*Browser { 21 - var browsers []*Browser 22 - 23 - // Use GIO to get all applications that handle HTTP URLs 24 - // This automatically handles system apps, Flatpaks, Snaps, etc. 25 - appInfos := gio.AppInfoGetRecommendedForType("x-scheme-handler/http") 26 - 27 - for _, appInfo := range appInfos { 28 - id := appInfo.ID() 29 - if id == "" { 30 - continue 31 - } 32 - 33 - // Skip ourselves 34 - if id == getAppID()+".desktop" { 35 - continue 36 - } 37 - 38 - name := appInfo.Name() 39 - icon := "" 40 - if gicon := appInfo.Icon(); gicon != nil { 41 - icon = gicon.String() 42 - } 43 - 44 - browsers = append(browsers, &Browser{ 45 - ID: id, 46 - Name: name, 47 - Icon: icon, 48 - AppInfo: appInfo, 49 - }) 50 - } 51 - 52 - // Sort browsers alphabetically by name 53 - sort.Slice(browsers, func(i, j int) bool { 54 - return browsers[i].Name < browsers[j].Name 55 - }) 56 - 57 - return browsers 58 - } 59 - 60 - func launchBrowser(b *Browser, url string) { 61 - cmdline := b.AppInfo.Commandline() 62 - if cmdline == "" { 63 - fmt.Fprintf(os.Stderr, "Error: No command line for browser %s\n", b.Name) 64 - return 65 - } 66 - if err := launchCommand(cmdline, url, b.AppInfo); err != nil { 67 - fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) 68 - } 69 - } 70 - 71 - // launchBrowserAction launches a browser with a specific desktop file action (e.g., "new-private-window") 72 - func launchBrowserAction(b *Browser, action DesktopAction, url string) { 73 - if action.Exec == "" { 74 - fmt.Fprintf(os.Stderr, "Error: No exec line for action %s\n", action.ID) 75 - return 76 - } 77 - if err := launchCommand(action.Exec, url, b.AppInfo); err != nil { 78 - fmt.Fprintf(os.Stderr, "Error launching browser action: %v\n", err) 79 - } 80 - }
+4 -13
cmd/switchyard/config.go gtk/config.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "fmt" 7 7 "os" 8 - "os/exec" 9 8 "strings" 10 9 11 10 appconfig "github.com/alyraffauf/switchyard/internal/config" 11 + "github.com/alyraffauf/switchyard/internal/host" 12 12 "github.com/alyraffauf/switchyard/internal/routing" 13 13 ) 14 14 ··· 34 34 return appconfig.Save(configPath(), config) 35 35 } 36 36 37 - // hostCommand runs commands on the host when Switchyard is sandboxed by Flatpak. 38 - func hostCommand(name string, args ...string) *exec.Cmd { 39 - if os.Getenv("FLATPAK_ID") != "" { 40 - hostArgs := append([]string{"--host", name}, args...) 41 - return exec.Command("flatpak-spawn", hostArgs...) 42 - } 43 - return exec.Command(name, args...) 44 - } 45 - 46 37 func isDefaultBrowser() bool { 47 - cmd := hostCommand("xdg-settings", "get", "default-web-browser") 38 + cmd := host.HostCommand("xdg-settings", "get", "default-web-browser") 48 39 49 40 output, err := cmd.Output() 50 41 if err != nil { ··· 58 49 59 50 func setAsDefaultBrowser() error { 60 51 desktopFile := getAppID() + ".desktop" 61 - cmd := hostCommand("xdg-settings", "set", "default-web-browser", desktopFile) 52 + cmd := host.HostCommand("xdg-settings", "set", "default-web-browser", desktopFile) 62 53 return cmd.Run() 63 54 } 64 55
-33
cmd/switchyard/desktop_actions.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "github.com/alyraffauf/goxdgdesktop/desktopfile" 7 - "github.com/alyraffauf/goxdgdesktop/xdg" 8 - "github.com/diamondburned/gotk4/pkg/gio/v2" 9 - ) 10 - 11 - type DesktopAction = desktopfile.Action 12 - 13 - func ListDesktopActions(appInfo *gio.AppInfo) []DesktopAction { 14 - if appInfo == nil { 15 - return nil 16 - } 17 - 18 - appID := appInfo.ID() 19 - if appID == "" { 20 - return nil 21 - } 22 - 23 - desktopFilePath := xdg.FindDesktopFile(appID) 24 - if desktopFilePath == "" { 25 - return nil 26 - } 27 - 28 - file, err := desktopfile.Read(desktopFilePath) 29 - if err != nil { 30 - return nil 31 - } 32 - return file.Actions() 33 - }
+1 -2
cmd/switchyard/dialog_about.go gtk/dialog_about.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 19 19 about.SetWebsite(WebsiteURL) 20 20 about.SetIssueURL(IssueURL) 21 21 22 - // Set developers from contributors list 23 22 developerStrings := make([]string, len(Contributors)) 24 23 for i, contributor := range Contributors { 25 24 developerStrings[i] = contributor.Name + " " + contributor.URL
+1 -1
cmd/switchyard/dialog_default_browser.go gtk/dialog_default_browser.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw"
+1 -1
cmd/switchyard/dialog_helpers.go gtk/dialog_helpers.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw"
+2 -11
cmd/switchyard/dialog_hidden_browsers.go gtk/dialog_hidden_browsers.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 18 18 19 19 listBox := createBoxedListBox() 20 20 21 - // Create a map for quick lookup of hidden browsers 22 21 hiddenSet := make(map[string]bool) 23 22 for _, id := range cfg.HiddenBrowsers { 24 23 hiddenSet[id] = true 25 24 } 26 25 27 - // Add a row for each browser 28 26 for _, browser := range browsers { 29 27 b := browser // capture for closure 30 28 ··· 37 35 rowBox.SetMarginTop(8) 38 36 rowBox.SetMarginBottom(8) 39 37 40 - // Browser icon 41 38 icon := loadBrowserIcon(b, 24) 42 39 rowBox.Append(icon) 43 40 44 - // Browser name 45 41 nameLabel := gtk.NewLabel(b.Name) 46 42 nameLabel.SetXAlign(0) 47 43 nameLabel.SetHExpand(true) 48 44 rowBox.Append(nameLabel) 49 45 50 - // Checkbox 51 46 checkBox := gtk.NewCheckButton() 52 47 checkBox.SetActive(hiddenSet[b.ID]) 53 48 checkBox.SetVAlign(gtk.AlignCenter) 54 49 55 - // Connect handler to update config 56 50 checkBox.ConnectToggled(func() { 57 51 isHidden := checkBox.Active() 58 52 59 - // Update the hidden browsers list 60 53 if isHidden { 61 - // Add to hidden list if not already present 54 + // Append only if not already hidden. 62 55 found := false 63 56 for _, id := range cfg.HiddenBrowsers { 64 57 if id == b.ID { ··· 70 63 cfg.HiddenBrowsers = append(cfg.HiddenBrowsers, b.ID) 71 64 } 72 65 } else { 73 - // Remove from hidden list 74 66 newHidden := make([]string, 0) 75 67 for _, id := range cfg.HiddenBrowsers { 76 68 if id != b.ID { ··· 80 72 cfg.HiddenBrowsers = newHidden 81 73 } 82 74 83 - // Save config 84 75 saveConfigWithFlag(cfg) 85 76 }) 86 77
+1 -1
cmd/switchyard/dialog_redirection.go gtk/dialog_redirection.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/alyraffauf/switchyard/internal/routing"
+1 -1
cmd/switchyard/dialog_rule_add.go gtk/dialog_rule_add.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/alyraffauf/switchyard/internal/routing"
+1 -1
cmd/switchyard/dialog_rule_common.go gtk/dialog_rule_common.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/alyraffauf/switchyard/internal/routing"
+1 -1
cmd/switchyard/dialog_rule_edit.go gtk/dialog_rule_edit.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/alyraffauf/switchyard/internal/routing"
-51
cmd/switchyard/launch.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "os" 7 - "os/exec" 8 - "strings" 9 - 10 - "github.com/alyraffauf/goxdgdesktop/desktopexec" 11 - "github.com/diamondburned/gotk4/pkg/gdk/v4" 12 - "github.com/diamondburned/gotk4/pkg/gio/v2" 13 - ) 14 - 15 - // launchCommand executes a desktop file command line with URL substitution 16 - // and proper activation token handling for window raising on Wayland. 17 - func launchCommand(cmdline, url string, appInfo *gio.AppInfo) error { 18 - cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url) 19 - 20 - if strings.TrimSpace(cmdline) == "" { 21 - return nil 22 - } 23 - 24 - // Get activation token from GDK launch context for window raising on Wayland 25 - var activationToken string 26 - if display := gdk.DisplayGetDefault(); display != nil { 27 - activationToken = display.AppLaunchContext().StartupNotifyID(appInfo, nil) 28 - } 29 - 30 - var cmd *exec.Cmd 31 - // When running in Flatpak, wrap with flatpak-spawn --host 32 - // and pass activation token via --env flag 33 - if os.Getenv("FLATPAK_ID") != "" && !strings.HasPrefix(cmdline, "flatpak-spawn") { 34 - cmd = exec.Command("flatpak-spawn", "--host", "--env=XDG_ACTIVATION_TOKEN="+activationToken, "sh", "-c", cmdline) 35 - activationToken = "" // Already handled via flatpak-spawn 36 - } else { 37 - // let the shell handle quote parsing 38 - // https://github.com/alyraffauf/switchyard/issues/27 39 - cmd = exec.Command("sh", "-c", cmdline) 40 - if activationToken != "" { 41 - cmd.Env = append(os.Environ(), "XDG_ACTIVATION_TOKEN="+activationToken) 42 - } 43 - } 44 - 45 - if err := cmd.Start(); err != nil { 46 - return err 47 - } 48 - 49 - go cmd.Wait() 50 - return nil 51 - }
+4 -4
cmd/switchyard/launcher_actions.go gtk/launcher_actions.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "context" 7 7 "fmt" 8 8 "strings" 9 9 10 + appbrowser "github.com/alyraffauf/switchyard/internal/browser" 10 11 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 12 "github.com/diamondburned/gotk4/pkg/gio/v2" 12 13 "github.com/diamondburned/gotk4/pkg/glib/v2" ··· 14 15 ) 15 16 16 17 func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { 17 - actions := ListDesktopActions(browser.AppInfo) 18 + actions := appbrowser.ListDesktopActions(browser.ID) 18 19 if len(actions) == 0 { 19 20 return 20 21 } ··· 77 78 }) 78 79 actionGroup.AddAction(shortcutsAction) 79 80 80 - // Action to launch browser with a specific desktop action 81 81 launchActionAction := gio.NewSimpleAction("launch-action", glib.NewVariantType("s")) 82 82 launchActionAction.ConnectActivate(func(param *glib.Variant) { 83 83 if param == nil { ··· 105 105 return 106 106 } 107 107 108 - actions := ListDesktopActions(selectedBrowser.AppInfo) 108 + actions := appbrowser.ListDesktopActions(selectedBrowser.ID) 109 109 for _, action := range actions { 110 110 if action.ID == actionID { 111 111 launchBrowserAction(selectedBrowser, action, url)
+2 -174
cmd/switchyard/main.go
··· 3 3 4 4 package main 5 5 6 - import ( 7 - "net/url" 8 - "os" 9 - "strings" 10 - 11 - "github.com/alyraffauf/switchyard/internal/routing" 12 - "github.com/diamondburned/gotk4-adwaita/pkg/adw" 13 - "github.com/diamondburned/gotk4/pkg/gdk/v4" 14 - "github.com/diamondburned/gotk4/pkg/gio/v2" 15 - "github.com/diamondburned/gotk4/pkg/glib/v2" 16 - "github.com/diamondburned/gotk4/pkg/gtk/v4" 17 - ) 6 + import "github.com/alyraffauf/switchyard/gtk" 18 7 19 8 func main() { 20 - app := adw.NewApplication(getAppID(), gio.ApplicationHandlesOpen) 21 - 22 - app.AddMainOption("native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 23 - "Run as native-messaging host (invoked by browsers)", "") 24 - 25 - app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int { 26 - if options.Contains("native-host") { 27 - runNativeMessagingHost() 28 - return 0 29 - } 30 - return -1 31 - }) 32 - 33 - app.ConnectActivate(func() { 34 - cfg := loadConfig() 35 - if cfg.StayAlive { 36 - app.Hold() 37 - } 38 - setupApp(cfg) 39 - browsers := detectBrowsers() 40 - showSettingsWindow(app, browsers, cfg) 41 - }) 42 - 43 - app.ConnectOpen(func(files []gio.Filer, hint string) { 44 - cfg := loadConfig() 45 - if cfg.StayAlive { 46 - app.Hold() 47 - } 48 - setupApp(cfg) 49 - browsers := detectBrowsers() 50 - 51 - if len(files) == 0 { 52 - showSettingsWindow(app, browsers, cfg) 53 - return 54 - } 55 - 56 - rawURL := files[0].URI() 57 - 58 - // Check if this is a switchyard:// URL 59 - if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { 60 - handleSwitchyardURL(app, browsers, cfg, rawURL) 61 - return 62 - } 63 - 64 - sanitized := prepareURLForRouting(rawURL, cfg) 65 - if sanitized == "" { 66 - // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open 67 - cmd := hostCommand("xdg-open", rawURL) 68 - cmd.Start() 69 - return 70 - } 71 - 72 - handleURL(app, browsers, cfg, sanitized) 73 - }) 74 - 75 - if code := app.Run(os.Args); code > 0 { 76 - os.Exit(code) 77 - } 78 - } 79 - 80 - // setupApp initializes app-wide settings like dark mode and icon paths 81 - func setupApp(cfg *Config) { 82 - // Apply dark mode app-wide 83 - if cfg.ForceDarkMode { 84 - adw.StyleManagerGetDefault().SetColorScheme(adw.ColorSchemeForceDark) 85 - } 86 - 87 - // Add host system icon paths when running in Flatpak 88 - if os.Getenv("FLATPAK_ID") != "" { 89 - iconTheme := gtk.IconThemeGetForDisplay(gdk.DisplayGetDefault()) 90 - if iconTheme != nil { 91 - iconTheme.AddSearchPath("/var/lib/flatpak/exports/share/icons") 92 - home, _ := os.UserHomeDir() 93 - if home != "" { 94 - iconTheme.AddSearchPath(home + "/.local/share/flatpak/exports/share/icons") 95 - } 96 - } 97 - } 98 - } 99 - 100 - // handleSwitchyardURL processes switchyard:// URLs with browser preferences 101 - func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) { 102 - targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) 103 - if err != nil { 104 - // Invalid switchyard URL - ignore 105 - return 106 - } 107 - 108 - sanitized := prepareURLForRouting(targetURL, cfg) 109 - if sanitized == "" { 110 - // Pass non-browser URLs to xdg-open 111 - cmd := hostCommand("xdg-open", targetURL) 112 - cmd.Start() 113 - return 114 - } 115 - 116 - // If browser preferences specified, try each in order 117 - if len(browserPrefs) > 0 { 118 - for _, pref := range browserPrefs { 119 - // Try with and without .desktop suffix 120 - id := pref 121 - if !strings.HasSuffix(id, ".desktop") { 122 - id = id + ".desktop" 123 - } 124 - if browser := findBrowserByID(browsers, id); browser != nil { 125 - launchBrowser(browser, sanitized) 126 - return 127 - } 128 - } 129 - // No preferred browser found - show launcher 130 - showLauncherWindow(app, sanitized, browsers, cfg) 131 - return 132 - } 133 - 134 - // No browser specified - use standard routing 135 - handleURL(app, browsers, cfg, sanitized) 136 - } 137 - 138 - func prepareURLForRouting(rawURL string, cfg *Config) string { 139 - sanitized := routing.SanitizeURL(rawURL) 140 - if sanitized == "" { 141 - return "" 142 - } 143 - 144 - if cfg.RemoveTrackingParameters { 145 - sanitized = routing.RemoveTrackingParameters(sanitized) 146 - } 147 - 148 - if len(cfg.Redirections) > 0 { 149 - sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections) 150 - } 151 - 152 - return sanitized 153 - } 154 - 155 - // handleURL routes a URL to the appropriate browser based on rules 156 - func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) { 157 - // Try to match a rule 158 - browserID, alwaysAsk, matched := cfg.MatchRule(urlStr) 159 - if matched { 160 - // Check if rule has AlwaysAsk enabled 161 - if alwaysAsk { 162 - showLauncherWindow(app, urlStr, browsers, cfg) 163 - return 164 - } 165 - 166 - // Find the browser and launch it 167 - if browser := findBrowserByID(browsers, browserID); browser != nil { 168 - launchBrowser(browser, urlStr) 169 - return 170 - } 171 - } 172 - 173 - // No rule matched 174 - if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { 175 - if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil { 176 - launchBrowser(browser, urlStr) 177 - return 178 - } 179 - } 180 - 181 - showLauncherWindow(app, urlStr, browsers, cfg) 9 + gtk.Run() 182 10 }
+1 -1
cmd/switchyard/native_host.go gtk/native_host.go
··· 2 2 3 3 // Native-messaging host: stdin/stdout JSON framing with browser extensions. 4 4 5 - package main 5 + package gtk 6 6 7 7 import ( 8 8 "encoding/binary"
+3 -7
cmd/switchyard/settings_advanced.go gtk/settings_advanced.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "context" 7 7 "fmt" 8 8 "os" 9 9 10 + "github.com/alyraffauf/switchyard/internal/host" 10 11 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 12 "github.com/diamondburned/gotk4/pkg/gio/v2" 12 13 "github.com/diamondburned/gotk4/pkg/gtk/v4" 13 14 ) 14 - 15 - // Note: gio/v2 is needed for gio.AsyncResulter in file dialog callbacks 16 15 17 16 func createAdvancedPage(win *adw.Window, cfg *Config) gtk.Widgetter { 18 17 toolbarView, content, _ := settingsPageLayout("Advanced") 19 18 20 - // Config file info 21 19 configGroup := adw.NewPreferencesGroup() 22 20 configGroup.SetTitle("Configuration") 23 21 ··· 28 26 configRow.AddSuffix(gtk.NewImageFromIconName("document-edit-symbolic")) 29 27 configRow.ConnectActivated(func() { 30 28 saveConfig(cfg) 31 - cmd := hostCommand("xdg-open", configPath()) 29 + cmd := host.HostCommand("xdg-open", configPath()) 32 30 if err := cmd.Start(); err != nil { 33 31 fmt.Printf("Failed to open config file: %v\n", err) 34 32 } ··· 36 34 }) 37 35 configGroup.Add(configRow) 38 36 39 - // Export config 40 37 exportRow := adw.NewActionRow() 41 38 exportRow.SetTitle("Export Configuration") 42 39 exportRow.SetSubtitle("Save configuration to a file") ··· 64 61 }) 65 62 configGroup.Add(exportRow) 66 63 67 - // Import config 68 64 importRow := adw.NewActionRow() 69 65 importRow.SetTitle("Import Configuration") 70 66 importRow.SetSubtitle("Load configuration from a file")
+1 -5
cmd/switchyard/settings_appearance.go gtk/settings_appearance.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 10 10 func createAppearancePage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widgetter { 11 11 toolbarView, content, _ := settingsPageLayout("Appearance") 12 12 13 - // General section 14 13 appearanceGroup := adw.NewPreferencesGroup() 15 14 appearanceGroup.SetTitle("General") 16 15 ··· 22 21 23 22 content.Append(appearanceGroup) 24 23 25 - // Launcher section 26 24 launcherGroup := adw.NewPreferencesGroup() 27 25 launcherGroup.SetTitle("Launcher") 28 26 ··· 32 30 showNamesRow.SetActive(cfg.ShowAppNames) 33 31 launcherGroup.Add(showNamesRow) 34 32 35 - // Hidden browsers row 36 33 hiddenBrowsersRow := adw.NewActionRow() 37 34 hiddenBrowsersRow.SetTitle("Hidden browsers") 38 35 hiddenBrowsersRow.SetSubtitle("Hide browsers you don't use") ··· 49 46 50 47 content.Append(launcherGroup) 51 48 52 - // Connect change handlers 53 49 forceDarkRow.Connect("notify::active", func() { 54 50 cfg.ForceDarkMode = forceDarkRow.Active() 55 51 saveConfigWithFlag(cfg)
+1 -4
cmd/switchyard/settings_behavior.go gtk/settings_behavior.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 10 10 func createBehaviorPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widgetter { 11 11 toolbarView, content, _ := settingsPageLayout("Behavior") 12 12 13 - // General Behavior section 14 13 behaviorGroup := adw.NewPreferencesGroup() 15 14 behaviorGroup.SetTitle("General") 16 15 ··· 32 31 removeTrackingRow.SetActive(cfg.RemoveTrackingParameters) 33 32 behaviorGroup.Add(removeTrackingRow) 34 33 35 - // Favorite browser dropdown 36 34 browserNames := make([]string, len(browsers)+1) 37 35 browserNames[0] = "None" 38 36 for i, b := range browsers { ··· 65 63 66 64 content.Append(behaviorGroup) 67 65 68 - // Connect change handlers 69 66 checkDefaultRow.Connect("notify::active", func() { 70 67 cfg.CheckDefaultBrowser = checkDefaultRow.Active() 71 68 saveConfigWithFlag(cfg)
+1 -1
cmd/switchyard/settings_redirections.go gtk/settings_redirections.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "fmt"
+7 -14
cmd/switchyard/settings_rules.go gtk/settings_rules.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "fmt" 7 7 8 + appbrowser "github.com/alyraffauf/switchyard/internal/browser" 8 9 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 9 10 "github.com/diamondburned/gotk4/pkg/gtk/v4" 10 11 ) ··· 84 85 titleLabel.AddCSSClass("title") 85 86 header.SetTitleWidget(titleLabel) 86 87 87 - // Add Rule button in header 88 88 addButton := gtk.NewButton() 89 89 addButton.SetIconName("list-add-symbolic") 90 90 addButton.SetTooltipText("Add Rule") ··· 101 101 content.SetMarginTop(12) 102 102 content.SetMarginBottom(12) 103 103 104 - // Info banner 105 104 infoLabel := gtk.NewLabel("Rules route links to browsers. First match wins.") 106 105 infoLabel.SetWrap(true) 107 106 infoLabel.SetXAlign(0) ··· 114 113 rulesListBox := createBoxedListBox() 115 114 emptyState := createEmptyState("view-list-symbolic", "No Browser Rules", "Add rules to automatically open links in specific browsers") 116 115 117 - // Helper to get browser name from ID 118 116 getBrowserName := func(id string) string { 119 117 if browser := findBrowserByID(browsers, id); browser != nil { 120 118 return browser.Name ··· 139 137 } 140 138 row.SetActivatable(true) 141 139 142 - // Browser icon 143 140 var icon *gtk.Image 144 141 if rule.AlwaysAsk { 145 142 appBrowser := &Browser{ 146 - ID: getAppID(), 147 - Icon: getAppID(), 148 - AppInfo: nil, 143 + Browser: &appbrowser.Browser{ 144 + ID: getAppID(), 145 + Icon: getAppID(), 146 + }, 149 147 } 150 148 icon = loadBrowserIcon(appBrowser, 24) 151 149 } else { ··· 159 157 } 160 158 row.AddPrefix(icon) 161 159 162 - // Reorder buttons 163 160 reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) 164 161 reorderBox.SetVAlign(gtk.AlignCenter) 165 162 ··· 193 190 194 191 row.AddSuffix(reorderBox) 195 192 196 - // Delete button 197 193 deleteBtn := gtk.NewButton() 198 194 deleteBtn.SetIconName("edit-delete-symbolic") 199 195 deleteBtn.AddCSSClass("flat") ··· 206 202 }) 207 203 row.AddSuffix(deleteBtn) 208 204 209 - // Edit on click 210 205 row.ConnectActivated(func() { 211 206 showEditRuleDialog(win, cfg, rule, browsers, rebuildRulesList) 212 207 }) ··· 217 212 rebuildRulesList = func() { 218 213 clearListBox(rulesListBox) 219 214 220 - // Show/hide empty state vs rules list 215 + // Swap between the empty-state page and the populated list. 221 216 if len(cfg.Rules) == 0 { 222 217 infoLabel.SetVisible(false) 223 218 rulesListBox.SetVisible(false) ··· 234 229 } 235 230 } 236 231 237 - // Initial build 238 232 rebuildRulesList() 239 233 240 234 content.Append(rulesListBox) ··· 242 236 scrolled.SetChild(content) 243 237 toolbarView.SetContent(scrolled) 244 238 245 - // Connect Add Rule button handler 246 239 addButton.ConnectClicked(func() { 247 240 showAddRuleDialog(win, cfg, browsers, rebuildRulesList) 248 241 })
+7 -12
cmd/switchyard/ui_helpers.go gtk/ui_helpers.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 48 48 NarrowColumns: 2, 49 49 } 50 50 51 - // loadBrowserIcon loads a browser icon using GIcon for best quality. 52 - // Using GIcon allows GTK to select the optimal icon size from the theme, 53 - // avoiding blurry scaling that occurs with named icons. 51 + // loadBrowserIcon builds an image for the browser, preferring the live GIcon so 52 + // GTK picks the sharpest themed size instead of blurry named-icon scaling. 54 53 func loadBrowserIcon(browser *Browser, size int) *gtk.Image { 55 - // Try to use GIcon from AppInfo for best quality 56 - if browser.AppInfo != nil { 57 - if gicon := browser.AppInfo.Icon(); gicon != nil { 54 + if browser.appInfo != nil { 55 + if gicon := browser.appInfo.Icon(); gicon != nil { 58 56 image := gtk.NewImageFromGIcon(gicon) 59 57 image.SetPixelSize(size) 60 58 return image 61 59 } 62 60 } 63 61 64 - // Fallback to icon name 65 62 iconName := browser.Icon 66 63 if iconName == "" { 67 64 iconName = "web-browser-symbolic" ··· 72 69 return image 73 70 } 74 71 75 - // hold the callback functions for browser button interactions. 72 + // BrowserButtonCallbacks holds the handlers for browser button interactions. 76 73 type BrowserButtonCallbacks struct { 77 74 OnClick func(browser *Browser) 78 75 OnRightClick func(btn *gtk.Button, browser *Browser) ··· 94 91 iconBox.SetHAlign(gtk.AlignCenter) 95 92 iconBox.SetVAlign(gtk.AlignCenter) 96 93 97 - // Browser icon 98 94 icon := loadBrowserIcon(browser, launcherMetrics.IconSize) 99 95 icon.SetHAlign(gtk.AlignCenter) 100 96 icon.SetVAlign(gtk.AlignCenter) ··· 106 102 labelValue := coreglib.NewValue(browser.Name) 107 103 btn.UpdateProperty([]gtk.AccessibleProperty{gtk.AccessiblePropertyLabel}, []coreglib.Value{*labelValue}) 108 104 109 - // Show browser name based on config 110 105 if showLabel { 111 106 label := gtk.NewLabel(browser.Name) 112 107 label.SetEllipsize(pango.EllipsizeEnd) ··· 141 136 return btn 142 137 } 143 138 144 - // wrap a FlowBox with its breakpoints for the launcher 139 + // LauncherFlowBox wraps a FlowBox with its launcher breakpoints. 145 140 type LauncherFlowBox struct { 146 141 FlowBox *gtk.FlowBox 147 142 NarrowBreakpoint *adw.Breakpoint
+6 -18
cmd/switchyard/window_launcher.go gtk/window_launcher.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 16 16 win.SetApplication(&app.Application) 17 17 win.SetResizable(false) 18 18 19 - // Main layout 20 19 mainBox := gtk.NewBox(gtk.OrientationVertical, 0) 21 20 22 - // create early so button handlers can reference it 21 + // Created early so button handlers can capture it. 23 22 urlEntry := createURLEntry(url) 24 23 25 - // Content area with browser buttons 26 24 contentBox := createLauncherContentBox() 27 25 launcherFlow := createLauncherFlowBox() 28 26 flowBox := launcherFlow.FlowBox 29 27 30 - // Add responsive breakpoints to window 31 28 win.AddBreakpoint(launcherFlow.NarrowBreakpoint) 32 29 win.AddBreakpoint(launcherFlow.MediumBreakpoint) 33 30 34 - // Create browser buttons 35 31 callbacks := BrowserButtonCallbacks{ 36 32 OnClick: func(browser *Browser) { 37 33 launchBrowser(browser, urlEntry.Text()) ··· 47 43 flowBox.Insert(btn, -1) 48 44 } 49 45 50 - // Correct max column items. This helps getting rid of empty item spacing. 46 + // Cap columns at the browser count so a short row has no empty trailing cells. 51 47 flowBox.SetMaxChildrenPerLine(min(flowBox.MaxChildrenPerLine(), uint(len(filteredBrowsers)))) 52 48 53 - // Handle Enter/Space activation on selected FlowBox child 54 49 flowBox.ConnectChildActivated(func(child *gtk.FlowBoxChild) { 55 50 idx := child.Index() 56 51 if idx >= 0 && idx < len(filteredBrowsers) { ··· 59 54 } 60 55 }) 61 56 62 - // Select first browser by default for keyboard navigation 57 + // Select the first browser so keyboard navigation has a starting point. 63 58 if first := flowBox.ChildAtIndex(0); first != nil { 64 59 flowBox.SelectChild(first) 65 60 } 66 61 67 - // URL entry activation launches selected browser 68 62 urlEntry.ConnectActivate(func() { 69 63 selected := flowBox.SelectedChildren() 70 64 if len(selected) > 0 { ··· 82 76 contentBox.Append(flowBox) 83 77 mainBox.Append(contentBox) 84 78 85 - // Bottom bar 86 79 bottomBar := createLauncherBottomBar(urlEntry, func() { win.Close() }) 87 80 mainBox.Append(bottomBar) 88 81 89 82 win.SetContent(mainBox) 90 83 91 - // Keyboard shortcuts 92 84 keyController := gtk.NewEventControllerKey() 93 85 keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { 94 - // Ctrl+[1-9] for quick selection 86 + // Ctrl+[1-9] selects a browser by position. 95 87 if keyval >= gdk.KEY_1 && keyval <= gdk.KEY_9 && state&gdk.ControlMask != 0 { 96 88 idx := int(keyval - gdk.KEY_1) 97 89 if idx < len(filteredBrowsers) { ··· 100 92 return true 101 93 } 102 94 } 103 - // Escape to close 104 95 if keyval == gdk.KEY_Escape { 105 96 win.Close() 106 97 return true ··· 109 100 }) 110 101 win.AddController(keyController) 111 102 112 - // Set up action handlers 113 103 actionGroup := setupLauncherActions(win, app, filteredBrowsers, url, func() { win.Close() }) 114 104 win.InsertActionGroup("win", actionGroup) 115 105 116 106 win.Present() 117 107 } 118 108 119 - // filter hidden browsers and moves favorite to front. 109 + // filterAndSortBrowsers drops hidden browsers and moves the favorite to the front. 120 110 func filterAndSortBrowsers(browsers []*Browser, cfg *Config) []*Browser { 121 - // Filter hidden browsers 122 111 hiddenSet := make(map[string]bool) 123 112 for _, id := range cfg.HiddenBrowsers { 124 113 hiddenSet[id] = true ··· 131 120 } 132 121 } 133 122 134 - // Move favorite to front 135 123 if cfg.FavoriteBrowser != "" { 136 124 for i, browser := range filtered { 137 125 if browser.ID == cfg.FavoriteBrowser {
+2 -7
cmd/switchyard/window_settings.go gtk/window_settings.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package gtk 4 4 5 5 import ( 6 6 "context" ··· 21 21 22 22 setupAppActions(app, win) 23 23 24 - // Navigation split view 25 24 splitView := adw.NewNavigationSplitView() 26 25 splitView.SetShowContent(true) 27 26 splitView.SetMinSidebarWidth(200) 28 27 splitView.SetMaxSidebarWidth(200) 29 28 30 - // Sidebar 31 29 sidebarPage := adw.NewNavigationPage(createSidebar(win, cfg, browsers, splitView), "Switchyard") 32 30 splitView.SetSidebar(sidebarPage) 33 31 34 - // Initial content 35 32 contentPage := adw.NewNavigationPage(createRulesPage(win, browsers, cfg), "Browser Rules") 36 33 splitView.SetContent(contentPage) 37 34 ··· 80 77 func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView *adw.NavigationSplitView) gtk.Widgetter { 81 78 toolbarView := adw.NewToolbarView() 82 79 83 - // Header with menu 84 80 sidebarHeader := adw.NewHeaderBar() 85 81 sidebarHeader.SetShowEndTitleButtons(false) 86 82 ··· 106 102 107 103 toolbarView.AddTopBar(sidebarHeader) 108 104 109 - // Navigation list 110 105 scrolled := gtk.NewScrolledWindow() 111 106 scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 112 107 scrolled.SetVExpand(true) ··· 115 110 listBox.SetSelectionMode(gtk.SelectionSingle) 116 111 listBox.AddCSSClass("navigation-sidebar") 117 112 118 - // Navigation rows 119 113 appearanceRow := adw.NewActionRow() 120 114 appearanceRow.SetTitle("Appearance") 121 115 appearanceRow.AddPrefix(gtk.NewImageFromIconName("applications-graphics-symbolic")) ··· 141 135 advancedRow.AddPrefix(gtk.NewImageFromIconName("preferences-other-symbolic")) 142 136 listBox.Append(advancedRow) 143 137 138 + // Separator above Link Redirections (2) and Advanced (4) to group the rows. 144 139 listBox.SetHeaderFunc(func(row, before *gtk.ListBoxRow) { 145 140 if row.Index() == 2 || row.Index() == 4 { 146 141 row.SetHeader(gtk.NewSeparator(gtk.OrientationHorizontal))
+97
gtk/browser.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package gtk 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + "sort" 9 + 10 + appbrowser "github.com/alyraffauf/switchyard/internal/browser" 11 + "github.com/alyraffauf/switchyard/internal/host" 12 + "github.com/diamondburned/gotk4/pkg/gdk/v4" 13 + "github.com/diamondburned/gotk4/pkg/gio/v2" 14 + ) 15 + 16 + // Browser is a detected browser as the GTK layer sees it: the agnostic model 17 + // plus the live GIO handle needed for sharp icons and Wayland activation 18 + // tokens. The handle rides with each browser so those GTK concerns stay local. 19 + type Browser struct { 20 + *appbrowser.Browser 21 + appInfo *gio.AppInfo 22 + } 23 + 24 + // DesktopAction is a desktop-entry action such as "new-private-window". 25 + type DesktopAction = appbrowser.Action 26 + 27 + // detectBrowsers returns the installed browsers that handle HTTP URLs, sorted 28 + // by name. GIO transparently covers system apps, Flatpaks, and Snaps. 29 + 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 39 + } 40 + 41 + icon := "" 42 + if gicon := appInfo.Icon(); gicon != nil { 43 + icon = gicon.String() 44 + } 45 + 46 + 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, 54 + }) 55 + } 56 + 57 + sort.Slice(browsers, func(i, j int) bool { 58 + return browsers[i].Name < browsers[j].Name 59 + }) 60 + 61 + return browsers 62 + } 63 + 64 + func launchBrowser(b *Browser, url string) { 65 + if b.Exec == "" { 66 + fmt.Fprintf(os.Stderr, "Error: No command line for browser %s\n", b.Name) 67 + return 68 + } 69 + if err := appbrowser.Launch(b.Exec, url, b.activationToken(), host.InFlatpak()); err != nil { 70 + fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) 71 + } 72 + } 73 + 74 + // launchBrowserAction launches a browser using a specific desktop action's 75 + // command line (e.g. "new-private-window"). 76 + func launchBrowserAction(b *Browser, action DesktopAction, url string) { 77 + if action.Exec == "" { 78 + fmt.Fprintf(os.Stderr, "Error: No exec line for action %s\n", action.ID) 79 + return 80 + } 81 + if err := appbrowser.Launch(action.Exec, url, b.activationToken(), host.InFlatpak()); err != nil { 82 + fmt.Fprintf(os.Stderr, "Error launching browser action: %v\n", err) 83 + } 84 + } 85 + 86 + // activationToken returns the Wayland/X11 startup token used to raise b's 87 + // window, or "" when the handle or display is unavailable. 88 + func (b *Browser) activationToken() string { 89 + if b.appInfo == nil { 90 + return "" 91 + } 92 + display := gdk.DisplayGetDefault() 93 + if display == nil { 94 + return "" 95 + } 96 + return display.AppLaunchContext().StartupNotifyID(b.appInfo, nil) 97 + }
+179
gtk/run.go
··· 1 + // Switchyard - A configurable default browser for Linux 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package gtk 5 + 6 + import ( 7 + "net/url" 8 + "os" 9 + "strings" 10 + 11 + "github.com/alyraffauf/switchyard/internal/host" 12 + "github.com/alyraffauf/switchyard/internal/routing" 13 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 14 + "github.com/diamondburned/gotk4/pkg/gdk/v4" 15 + "github.com/diamondburned/gotk4/pkg/gio/v2" 16 + "github.com/diamondburned/gotk4/pkg/glib/v2" 17 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 18 + ) 19 + 20 + // Run starts the Switchyard GTK application and blocks until it exits. 21 + func Run() { 22 + app := adw.NewApplication(getAppID(), gio.ApplicationHandlesOpen) 23 + 24 + app.AddMainOption("native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 25 + "Run as native-messaging host (invoked by browsers)", "") 26 + 27 + app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int { 28 + if options.Contains("native-host") { 29 + runNativeMessagingHost() 30 + return 0 31 + } 32 + return -1 33 + }) 34 + 35 + app.ConnectActivate(func() { 36 + cfg := loadConfig() 37 + if cfg.StayAlive { 38 + app.Hold() 39 + } 40 + setupApp(cfg) 41 + browsers := detectBrowsers() 42 + showSettingsWindow(app, browsers, cfg) 43 + }) 44 + 45 + app.ConnectOpen(func(files []gio.Filer, hint string) { 46 + cfg := loadConfig() 47 + if cfg.StayAlive { 48 + app.Hold() 49 + } 50 + setupApp(cfg) 51 + browsers := detectBrowsers() 52 + 53 + if len(files) == 0 { 54 + showSettingsWindow(app, browsers, cfg) 55 + return 56 + } 57 + 58 + rawURL := files[0].URI() 59 + 60 + if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { 61 + handleSwitchyardURL(app, browsers, cfg, rawURL) 62 + return 63 + } 64 + 65 + sanitized := prepareURLForRouting(rawURL, cfg) 66 + if sanitized == "" { 67 + // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open 68 + cmd := host.HostCommand("xdg-open", rawURL) 69 + cmd.Start() 70 + return 71 + } 72 + 73 + handleURL(app, browsers, cfg, sanitized) 74 + }) 75 + 76 + if code := app.Run(os.Args); code > 0 { 77 + os.Exit(code) 78 + } 79 + } 80 + 81 + // setupApp initializes app-wide settings like dark mode and icon paths 82 + func setupApp(cfg *Config) { 83 + if cfg.ForceDarkMode { 84 + adw.StyleManagerGetDefault().SetColorScheme(adw.ColorSchemeForceDark) 85 + } 86 + 87 + // Add host system icon paths when running in Flatpak 88 + if host.InFlatpak() { 89 + iconTheme := gtk.IconThemeGetForDisplay(gdk.DisplayGetDefault()) 90 + if iconTheme != nil { 91 + iconTheme.AddSearchPath("/var/lib/flatpak/exports/share/icons") 92 + home, _ := os.UserHomeDir() 93 + if home != "" { 94 + iconTheme.AddSearchPath(home + "/.local/share/flatpak/exports/share/icons") 95 + } 96 + } 97 + } 98 + } 99 + 100 + // handleSwitchyardURL processes switchyard:// URLs with browser preferences 101 + func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) { 102 + targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) 103 + if err != nil { 104 + // Invalid switchyard URL - ignore 105 + return 106 + } 107 + 108 + sanitized := prepareURLForRouting(targetURL, cfg) 109 + if sanitized == "" { 110 + // Pass non-browser URLs to xdg-open 111 + cmd := host.HostCommand("xdg-open", targetURL) 112 + cmd.Start() 113 + return 114 + } 115 + 116 + // If browser preferences specified, try each in order 117 + if len(browserPrefs) > 0 { 118 + for _, pref := range browserPrefs { 119 + // Try with and without .desktop suffix 120 + id := pref 121 + if !strings.HasSuffix(id, ".desktop") { 122 + id = id + ".desktop" 123 + } 124 + if browser := findBrowserByID(browsers, id); browser != nil { 125 + launchBrowser(browser, sanitized) 126 + return 127 + } 128 + } 129 + // No preferred browser found - show launcher 130 + showLauncherWindow(app, sanitized, browsers, cfg) 131 + return 132 + } 133 + 134 + // No browser specified - use standard routing 135 + handleURL(app, browsers, cfg, sanitized) 136 + } 137 + 138 + func prepareURLForRouting(rawURL string, cfg *Config) string { 139 + sanitized := routing.SanitizeURL(rawURL) 140 + if sanitized == "" { 141 + return "" 142 + } 143 + 144 + if cfg.RemoveTrackingParameters { 145 + sanitized = routing.RemoveTrackingParameters(sanitized) 146 + } 147 + 148 + if len(cfg.Redirections) > 0 { 149 + sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections) 150 + } 151 + 152 + return sanitized 153 + } 154 + 155 + // handleURL routes a URL to the appropriate browser based on rules 156 + func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) { 157 + browserID, alwaysAsk, matched := cfg.MatchRule(urlStr) 158 + if matched { 159 + if alwaysAsk { 160 + showLauncherWindow(app, urlStr, browsers, cfg) 161 + return 162 + } 163 + 164 + if browser := findBrowserByID(browsers, browserID); browser != nil { 165 + launchBrowser(browser, urlStr) 166 + return 167 + } 168 + } 169 + 170 + // No rule matched: fall back to the favorite browser, else prompt. 171 + if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { 172 + if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil { 173 + launchBrowser(browser, urlStr) 174 + return 175 + } 176 + } 177 + 178 + showLauncherWindow(app, urlStr, browsers, cfg) 179 + }
+30
internal/browser/actions.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + import ( 6 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 7 + "github.com/alyraffauf/goxdgdesktop/xdg" 8 + ) 9 + 10 + // Action is a desktop-entry action, e.g. "new-private-window". 11 + type Action = desktopfile.Action 12 + 13 + // ListDesktopActions returns the actions declared in appID's desktop entry. 14 + // It returns nil when the desktop file is missing or cannot be parsed. 15 + func ListDesktopActions(appID string) []Action { 16 + if appID == "" { 17 + return nil 18 + } 19 + 20 + desktopFilePath := xdg.FindDesktopFile(appID) 21 + if desktopFilePath == "" { 22 + return nil 23 + } 24 + 25 + file, err := desktopfile.Read(desktopFilePath) 26 + if err != nil { 27 + return nil 28 + } 29 + return file.Actions() 30 + }
+14
internal/browser/browser.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + // Package browser models installed web browsers and launches them, free of any 4 + // GTK/GIO types. Discovering browsers and fetching activation tokens is the 5 + // caller's job; this package works in plain strings. 6 + package browser 7 + 8 + // Browser is an installed web browser that can open URLs. 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" 14 + }
+51
internal/browser/launch.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + import ( 6 + "os" 7 + "os/exec" 8 + "strings" 9 + 10 + "github.com/alyraffauf/goxdgdesktop/desktopexec" 11 + ) 12 + 13 + const activationTokenEnv = "XDG_ACTIVATION_TOKEN" 14 + 15 + // Launch runs cmdline for url in the background. activationToken is the 16 + // Wayland/X11 startup token for raising the window and may be empty; when 17 + // inFlatpak is set the command runs on the host via flatpak-spawn. An empty 18 + // command line is a no-op. 19 + func Launch(cmdline, url, activationToken string, inFlatpak bool) error { 20 + cmd := buildCommand(cmdline, url, activationToken, inFlatpak) 21 + if cmd == nil { 22 + return nil 23 + } 24 + if err := cmd.Start(); err != nil { 25 + return err 26 + } 27 + go cmd.Wait() 28 + return nil 29 + } 30 + 31 + // buildCommand assembles the OS command that launches cmdline for url, or nil 32 + // when the resolved command line is empty. 33 + func buildCommand(cmdline, url, activationToken string, inFlatpak bool) *exec.Cmd { 34 + cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url) 35 + if strings.TrimSpace(cmdline) == "" { 36 + return nil 37 + } 38 + 39 + // The sandbox drops our environment, so pass the token to the host via --env. 40 + if inFlatpak && !strings.HasPrefix(cmdline, "flatpak-spawn") { 41 + return exec.Command("flatpak-spawn", "--host", 42 + "--env="+activationTokenEnv+"="+activationToken, "sh", "-c", cmdline) 43 + } 44 + 45 + // Let the shell parse quotes; see switchyard issue #27. 46 + cmd := exec.Command("sh", "-c", cmdline) 47 + if activationToken != "" { 48 + cmd.Env = append(os.Environ(), activationTokenEnv+"="+activationToken) 49 + } 50 + return cmd 51 + }
+65
internal/browser/launch_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + import ( 6 + "slices" 7 + "testing" 8 + 9 + "github.com/alyraffauf/goxdgdesktop/desktopexec" 10 + ) 11 + 12 + func TestBuildCommandEmptyCmdlineIsNoOp(t *testing.T) { 13 + if cmd := buildCommand("", "", "", false); cmd != nil { 14 + t.Fatalf("empty command line: got %v, want nil", cmd.Args) 15 + } 16 + } 17 + 18 + func TestBuildCommandHostSubstitutesURL(t *testing.T) { 19 + const url = "https://example.com" 20 + want := desktopexec.SubstituteOrAppendURL("firefox %u", url) 21 + 22 + cmd := buildCommand("firefox %u", url, "", false) 23 + 24 + wantArgs := []string{"sh", "-c", want} 25 + if !slices.Equal(cmd.Args, wantArgs) { 26 + t.Fatalf("args: got %v, want %v", cmd.Args, wantArgs) 27 + } 28 + if cmd.Env != nil { 29 + t.Fatalf("env: got %v, want nil when no activation token", cmd.Env) 30 + } 31 + } 32 + 33 + func TestBuildCommandHostPassesActivationTokenViaEnv(t *testing.T) { 34 + cmd := buildCommand("firefox %u", "https://example.com", "tok-123", false) 35 + 36 + lastEnv := cmd.Env[len(cmd.Env)-1] 37 + if want := activationTokenEnv + "=tok-123"; lastEnv != want { 38 + t.Fatalf("activation token env: got %q, want %q", lastEnv, want) 39 + } 40 + } 41 + 42 + func TestBuildCommandFlatpakWrapsWithHostSpawn(t *testing.T) { 43 + const url = "https://example.com" 44 + want := desktopexec.SubstituteOrAppendURL("firefox %u", url) 45 + 46 + cmd := buildCommand("firefox %u", url, "tok-123", true) 47 + 48 + wantArgs := []string{ 49 + "flatpak-spawn", "--host", 50 + "--env=" + activationTokenEnv + "=tok-123", 51 + "sh", "-c", want, 52 + } 53 + if !slices.Equal(cmd.Args, wantArgs) { 54 + t.Fatalf("args: got %v, want %v", cmd.Args, wantArgs) 55 + } 56 + } 57 + 58 + // A command line that already escapes the sandbox must not be wrapped twice. 59 + func TestBuildCommandFlatpakDoesNotDoubleWrap(t *testing.T) { 60 + cmd := buildCommand("flatpak-spawn --host firefox %u", "https://example.com", "tok", true) 61 + 62 + if cmd.Args[0] != "sh" { 63 + t.Fatalf("expected already-spawned command to run under sh, got %v", cmd.Args) 64 + } 65 + }
+26
internal/host/host.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + // Package host runs commands on the host system and detects the Flatpak 4 + // sandbox. It is toolkit-agnostic so backend packages can reason about the 5 + // runtime environment without depending on the GTK layer. 6 + package host 7 + 8 + import ( 9 + "os" 10 + "os/exec" 11 + ) 12 + 13 + // InFlatpak reports whether the process is running inside a Flatpak sandbox. 14 + func InFlatpak() bool { 15 + return os.Getenv("FLATPAK_ID") != "" 16 + } 17 + 18 + // HostCommand builds a command that runs on the host, hopping out of the 19 + // Flatpak sandbox via flatpak-spawn --host when one is active. 20 + func HostCommand(name string, args ...string) *exec.Cmd { 21 + if !InFlatpak() { 22 + return exec.Command(name, args...) 23 + } 24 + hostArgs := append([]string{"--host", name}, args...) 25 + return exec.Command("flatpak-spawn", hostArgs...) 26 + }
+11 -8
justfile
··· 61 61 test-config: 62 62 go test -v ./internal/config 63 63 64 - test: test-routing test-config 64 + test-browser: 65 + go test -v ./internal/browser 66 + 67 + test: test-routing test-config test-browser 65 68 66 69 # Run tests with coverage report 67 70 test-routing-coverage: ··· 71 74 @echo "" 72 75 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" 73 76 74 - test-coverage: test-config test-routing-coverage 77 + test-coverage: test-config test-browser test-routing-coverage 75 78 76 79 # Build and install Flatpak (development version) 77 80 flatpak: ··· 142 145 exit 1 143 146 fi 144 147 145 - dirty="$(git status --porcelain -- ':!cmd/switchyard/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")" 148 + dirty="$(git status --porcelain -- ':!gtk/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")" 146 149 if [ -n "$dirty" ]; then 147 - echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2 150 + echo "error: working tree has changes outside gtk/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2 148 151 echo "$dirty" >&2 149 152 exit 1 150 153 fi ··· 162 165 163 166 just test 164 167 165 - sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" cmd/switchyard/app.go 166 - if ! grep -qE "Version\s*=\s*\"${version}\"" cmd/switchyard/app.go; then 167 - echo "error: failed to update Version in cmd/switchyard/app.go" >&2 168 + sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" gtk/app.go 169 + if ! grep -qE "Version\s*=\s*\"${version}\"" gtk/app.go; then 170 + echo "error: failed to update Version in gtk/app.go" >&2 168 171 exit 1 169 172 fi 170 173 ··· 186 189 exit 1 187 190 fi 188 191 189 - git add cmd/switchyard/app.go "${metainfo}" "${manifest}" "${firefox_manifest}" "${pkgjson}" 192 + git add gtk/app.go "${metainfo}" "${manifest}" "${firefox_manifest}" "${pkgjson}" 190 193 git commit -m "update for ${tag}" 191 194 git tag -a "${tag}" -m "${tag}" 192 195 git push origin master