···3333 run: just test-routing-coverage
3434 - name: Test config package
3535 run: just test-config
3636+ - name: Test browser package
3737+ run: just test-browser
3638 - name: Display coverage summary
3739 run: |
3840 echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
+1-1
cmd/switchyard/app.go
gtk/app.go
···11// Switchyard - A configurable default browser for Linux
22// SPDX-License-Identifier: GPL-3.0-or-later
3344-package main
44+package gtk
5566import "os"
77
-80
cmd/switchyard/browser.go
···11-// SPDX-License-Identifier: GPL-3.0-or-later
22-33-package main
44-55-import (
66- "fmt"
77- "os"
88- "sort"
99-1010- "github.com/diamondburned/gotk4/pkg/gio/v2"
1111-)
1212-1313-type Browser struct {
1414- ID string // desktop file ID (e.g., "firefox.desktop")
1515- Name string
1616- Icon string
1717- AppInfo *gio.AppInfo // Store the GIO AppInfo for launching
1818-}
1919-2020-func detectBrowsers() []*Browser {
2121- var browsers []*Browser
2222-2323- // Use GIO to get all applications that handle HTTP URLs
2424- // This automatically handles system apps, Flatpaks, Snaps, etc.
2525- appInfos := gio.AppInfoGetRecommendedForType("x-scheme-handler/http")
2626-2727- for _, appInfo := range appInfos {
2828- id := appInfo.ID()
2929- if id == "" {
3030- continue
3131- }
3232-3333- // Skip ourselves
3434- if id == getAppID()+".desktop" {
3535- continue
3636- }
3737-3838- name := appInfo.Name()
3939- icon := ""
4040- if gicon := appInfo.Icon(); gicon != nil {
4141- icon = gicon.String()
4242- }
4343-4444- browsers = append(browsers, &Browser{
4545- ID: id,
4646- Name: name,
4747- Icon: icon,
4848- AppInfo: appInfo,
4949- })
5050- }
5151-5252- // Sort browsers alphabetically by name
5353- sort.Slice(browsers, func(i, j int) bool {
5454- return browsers[i].Name < browsers[j].Name
5555- })
5656-5757- return browsers
5858-}
5959-6060-func launchBrowser(b *Browser, url string) {
6161- cmdline := b.AppInfo.Commandline()
6262- if cmdline == "" {
6363- fmt.Fprintf(os.Stderr, "Error: No command line for browser %s\n", b.Name)
6464- return
6565- }
6666- if err := launchCommand(cmdline, url, b.AppInfo); err != nil {
6767- fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err)
6868- }
6969-}
7070-7171-// launchBrowserAction launches a browser with a specific desktop file action (e.g., "new-private-window")
7272-func launchBrowserAction(b *Browser, action DesktopAction, url string) {
7373- if action.Exec == "" {
7474- fmt.Fprintf(os.Stderr, "Error: No exec line for action %s\n", action.ID)
7575- return
7676- }
7777- if err := launchCommand(action.Exec, url, b.AppInfo); err != nil {
7878- fmt.Fprintf(os.Stderr, "Error launching browser action: %v\n", err)
7979- }
8080-}
···11+// SPDX-License-Identifier: GPL-3.0-or-later
22+33+package gtk
44+55+import (
66+ "fmt"
77+ "os"
88+ "sort"
99+1010+ appbrowser "github.com/alyraffauf/switchyard/internal/browser"
1111+ "github.com/alyraffauf/switchyard/internal/host"
1212+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
1313+ "github.com/diamondburned/gotk4/pkg/gio/v2"
1414+)
1515+1616+// Browser is a detected browser as the GTK layer sees it: the agnostic model
1717+// plus the live GIO handle needed for sharp icons and Wayland activation
1818+// tokens. The handle rides with each browser so those GTK concerns stay local.
1919+type Browser struct {
2020+ *appbrowser.Browser
2121+ appInfo *gio.AppInfo
2222+}
2323+2424+// DesktopAction is a desktop-entry action such as "new-private-window".
2525+type DesktopAction = appbrowser.Action
2626+2727+// detectBrowsers returns the installed browsers that handle HTTP URLs, sorted
2828+// by name. GIO transparently covers system apps, Flatpaks, and Snaps.
2929+func detectBrowsers() []*Browser {
3030+ appInfos := gio.AppInfoGetRecommendedForType("x-scheme-handler/http")
3131+3232+ browsers := make([]*Browser, 0, len(appInfos))
3333+ selfDesktopID := getAppID() + ".desktop"
3434+3535+ for _, appInfo := range appInfos {
3636+ id := appInfo.ID()
3737+ if id == "" || id == selfDesktopID {
3838+ continue
3939+ }
4040+4141+ icon := ""
4242+ if gicon := appInfo.Icon(); gicon != nil {
4343+ icon = gicon.String()
4444+ }
4545+4646+ browsers = append(browsers, &Browser{
4747+ Browser: &appbrowser.Browser{
4848+ ID: id,
4949+ Name: appInfo.Name(),
5050+ Icon: icon,
5151+ Exec: appInfo.Commandline(),
5252+ },
5353+ appInfo: appInfo,
5454+ })
5555+ }
5656+5757+ sort.Slice(browsers, func(i, j int) bool {
5858+ return browsers[i].Name < browsers[j].Name
5959+ })
6060+6161+ return browsers
6262+}
6363+6464+func launchBrowser(b *Browser, url string) {
6565+ if b.Exec == "" {
6666+ fmt.Fprintf(os.Stderr, "Error: No command line for browser %s\n", b.Name)
6767+ return
6868+ }
6969+ if err := appbrowser.Launch(b.Exec, url, b.activationToken(), host.InFlatpak()); err != nil {
7070+ fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err)
7171+ }
7272+}
7373+7474+// launchBrowserAction launches a browser using a specific desktop action's
7575+// command line (e.g. "new-private-window").
7676+func launchBrowserAction(b *Browser, action DesktopAction, url string) {
7777+ if action.Exec == "" {
7878+ fmt.Fprintf(os.Stderr, "Error: No exec line for action %s\n", action.ID)
7979+ return
8080+ }
8181+ if err := appbrowser.Launch(action.Exec, url, b.activationToken(), host.InFlatpak()); err != nil {
8282+ fmt.Fprintf(os.Stderr, "Error launching browser action: %v\n", err)
8383+ }
8484+}
8585+8686+// activationToken returns the Wayland/X11 startup token used to raise b's
8787+// window, or "" when the handle or display is unavailable.
8888+func (b *Browser) activationToken() string {
8989+ if b.appInfo == nil {
9090+ return ""
9191+ }
9292+ display := gdk.DisplayGetDefault()
9393+ if display == nil {
9494+ return ""
9595+ }
9696+ return display.AppLaunchContext().StartupNotifyID(b.appInfo, nil)
9797+}
+179
gtk/run.go
···11+// Switchyard - A configurable default browser for Linux
22+// SPDX-License-Identifier: GPL-3.0-or-later
33+44+package gtk
55+66+import (
77+ "net/url"
88+ "os"
99+ "strings"
1010+1111+ "github.com/alyraffauf/switchyard/internal/host"
1212+ "github.com/alyraffauf/switchyard/internal/routing"
1313+ "github.com/diamondburned/gotk4-adwaita/pkg/adw"
1414+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
1515+ "github.com/diamondburned/gotk4/pkg/gio/v2"
1616+ "github.com/diamondburned/gotk4/pkg/glib/v2"
1717+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1818+)
1919+2020+// Run starts the Switchyard GTK application and blocks until it exits.
2121+func Run() {
2222+ app := adw.NewApplication(getAppID(), gio.ApplicationHandlesOpen)
2323+2424+ app.AddMainOption("native-host", 0, glib.OptionFlagNone, glib.OptionArgNone,
2525+ "Run as native-messaging host (invoked by browsers)", "")
2626+2727+ app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int {
2828+ if options.Contains("native-host") {
2929+ runNativeMessagingHost()
3030+ return 0
3131+ }
3232+ return -1
3333+ })
3434+3535+ app.ConnectActivate(func() {
3636+ cfg := loadConfig()
3737+ if cfg.StayAlive {
3838+ app.Hold()
3939+ }
4040+ setupApp(cfg)
4141+ browsers := detectBrowsers()
4242+ showSettingsWindow(app, browsers, cfg)
4343+ })
4444+4545+ app.ConnectOpen(func(files []gio.Filer, hint string) {
4646+ cfg := loadConfig()
4747+ if cfg.StayAlive {
4848+ app.Hold()
4949+ }
5050+ setupApp(cfg)
5151+ browsers := detectBrowsers()
5252+5353+ if len(files) == 0 {
5454+ showSettingsWindow(app, browsers, cfg)
5555+ return
5656+ }
5757+5858+ rawURL := files[0].URI()
5959+6060+ if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" {
6161+ handleSwitchyardURL(app, browsers, cfg, rawURL)
6262+ return
6363+ }
6464+6565+ sanitized := prepareURLForRouting(rawURL, cfg)
6666+ if sanitized == "" {
6767+ // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open
6868+ cmd := host.HostCommand("xdg-open", rawURL)
6969+ cmd.Start()
7070+ return
7171+ }
7272+7373+ handleURL(app, browsers, cfg, sanitized)
7474+ })
7575+7676+ if code := app.Run(os.Args); code > 0 {
7777+ os.Exit(code)
7878+ }
7979+}
8080+8181+// setupApp initializes app-wide settings like dark mode and icon paths
8282+func setupApp(cfg *Config) {
8383+ if cfg.ForceDarkMode {
8484+ adw.StyleManagerGetDefault().SetColorScheme(adw.ColorSchemeForceDark)
8585+ }
8686+8787+ // Add host system icon paths when running in Flatpak
8888+ if host.InFlatpak() {
8989+ iconTheme := gtk.IconThemeGetForDisplay(gdk.DisplayGetDefault())
9090+ if iconTheme != nil {
9191+ iconTheme.AddSearchPath("/var/lib/flatpak/exports/share/icons")
9292+ home, _ := os.UserHomeDir()
9393+ if home != "" {
9494+ iconTheme.AddSearchPath(home + "/.local/share/flatpak/exports/share/icons")
9595+ }
9696+ }
9797+ }
9898+}
9999+100100+// handleSwitchyardURL processes switchyard:// URLs with browser preferences
101101+func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) {
102102+ targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL)
103103+ if err != nil {
104104+ // Invalid switchyard URL - ignore
105105+ return
106106+ }
107107+108108+ sanitized := prepareURLForRouting(targetURL, cfg)
109109+ if sanitized == "" {
110110+ // Pass non-browser URLs to xdg-open
111111+ cmd := host.HostCommand("xdg-open", targetURL)
112112+ cmd.Start()
113113+ return
114114+ }
115115+116116+ // If browser preferences specified, try each in order
117117+ if len(browserPrefs) > 0 {
118118+ for _, pref := range browserPrefs {
119119+ // Try with and without .desktop suffix
120120+ id := pref
121121+ if !strings.HasSuffix(id, ".desktop") {
122122+ id = id + ".desktop"
123123+ }
124124+ if browser := findBrowserByID(browsers, id); browser != nil {
125125+ launchBrowser(browser, sanitized)
126126+ return
127127+ }
128128+ }
129129+ // No preferred browser found - show launcher
130130+ showLauncherWindow(app, sanitized, browsers, cfg)
131131+ return
132132+ }
133133+134134+ // No browser specified - use standard routing
135135+ handleURL(app, browsers, cfg, sanitized)
136136+}
137137+138138+func prepareURLForRouting(rawURL string, cfg *Config) string {
139139+ sanitized := routing.SanitizeURL(rawURL)
140140+ if sanitized == "" {
141141+ return ""
142142+ }
143143+144144+ if cfg.RemoveTrackingParameters {
145145+ sanitized = routing.RemoveTrackingParameters(sanitized)
146146+ }
147147+148148+ if len(cfg.Redirections) > 0 {
149149+ sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections)
150150+ }
151151+152152+ return sanitized
153153+}
154154+155155+// handleURL routes a URL to the appropriate browser based on rules
156156+func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) {
157157+ browserID, alwaysAsk, matched := cfg.MatchRule(urlStr)
158158+ if matched {
159159+ if alwaysAsk {
160160+ showLauncherWindow(app, urlStr, browsers, cfg)
161161+ return
162162+ }
163163+164164+ if browser := findBrowserByID(browsers, browserID); browser != nil {
165165+ launchBrowser(browser, urlStr)
166166+ return
167167+ }
168168+ }
169169+170170+ // No rule matched: fall back to the favorite browser, else prompt.
171171+ if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" {
172172+ if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil {
173173+ launchBrowser(browser, urlStr)
174174+ return
175175+ }
176176+ }
177177+178178+ showLauncherWindow(app, urlStr, browsers, cfg)
179179+}
+30
internal/browser/actions.go
···11+// SPDX-License-Identifier: GPL-3.0-or-later
22+33+package browser
44+55+import (
66+ "github.com/alyraffauf/goxdgdesktop/desktopfile"
77+ "github.com/alyraffauf/goxdgdesktop/xdg"
88+)
99+1010+// Action is a desktop-entry action, e.g. "new-private-window".
1111+type Action = desktopfile.Action
1212+1313+// ListDesktopActions returns the actions declared in appID's desktop entry.
1414+// It returns nil when the desktop file is missing or cannot be parsed.
1515+func ListDesktopActions(appID string) []Action {
1616+ if appID == "" {
1717+ return nil
1818+ }
1919+2020+ desktopFilePath := xdg.FindDesktopFile(appID)
2121+ if desktopFilePath == "" {
2222+ return nil
2323+ }
2424+2525+ file, err := desktopfile.Read(desktopFilePath)
2626+ if err != nil {
2727+ return nil
2828+ }
2929+ return file.Actions()
3030+}
+14
internal/browser/browser.go
···11+// SPDX-License-Identifier: GPL-3.0-or-later
22+33+// Package browser models installed web browsers and launches them, free of any
44+// GTK/GIO types. Discovering browsers and fetching activation tokens is the
55+// caller's job; this package works in plain strings.
66+package browser
77+88+// Browser is an installed web browser that can open URLs.
99+type Browser struct {
1010+ ID string // desktop file ID, e.g. "firefox.desktop"
1111+ Name string
1212+ Icon string // themed icon name; may be empty
1313+ Exec string // desktop-entry command line with field codes, e.g. "firefox %u"
1414+}
+51
internal/browser/launch.go
···11+// SPDX-License-Identifier: GPL-3.0-or-later
22+33+package browser
44+55+import (
66+ "os"
77+ "os/exec"
88+ "strings"
99+1010+ "github.com/alyraffauf/goxdgdesktop/desktopexec"
1111+)
1212+1313+const activationTokenEnv = "XDG_ACTIVATION_TOKEN"
1414+1515+// Launch runs cmdline for url in the background. activationToken is the
1616+// Wayland/X11 startup token for raising the window and may be empty; when
1717+// inFlatpak is set the command runs on the host via flatpak-spawn. An empty
1818+// command line is a no-op.
1919+func Launch(cmdline, url, activationToken string, inFlatpak bool) error {
2020+ cmd := buildCommand(cmdline, url, activationToken, inFlatpak)
2121+ if cmd == nil {
2222+ return nil
2323+ }
2424+ if err := cmd.Start(); err != nil {
2525+ return err
2626+ }
2727+ go cmd.Wait()
2828+ return nil
2929+}
3030+3131+// buildCommand assembles the OS command that launches cmdline for url, or nil
3232+// when the resolved command line is empty.
3333+func buildCommand(cmdline, url, activationToken string, inFlatpak bool) *exec.Cmd {
3434+ cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url)
3535+ if strings.TrimSpace(cmdline) == "" {
3636+ return nil
3737+ }
3838+3939+ // The sandbox drops our environment, so pass the token to the host via --env.
4040+ if inFlatpak && !strings.HasPrefix(cmdline, "flatpak-spawn") {
4141+ return exec.Command("flatpak-spawn", "--host",
4242+ "--env="+activationTokenEnv+"="+activationToken, "sh", "-c", cmdline)
4343+ }
4444+4545+ // Let the shell parse quotes; see switchyard issue #27.
4646+ cmd := exec.Command("sh", "-c", cmdline)
4747+ if activationToken != "" {
4848+ cmd.Env = append(os.Environ(), activationTokenEnv+"="+activationToken)
4949+ }
5050+ return cmd
5151+}
···11+// SPDX-License-Identifier: GPL-3.0-or-later
22+33+// Package host runs commands on the host system and detects the Flatpak
44+// sandbox. It is toolkit-agnostic so backend packages can reason about the
55+// runtime environment without depending on the GTK layer.
66+package host
77+88+import (
99+ "os"
1010+ "os/exec"
1111+)
1212+1313+// InFlatpak reports whether the process is running inside a Flatpak sandbox.
1414+func InFlatpak() bool {
1515+ return os.Getenv("FLATPAK_ID") != ""
1616+}
1717+1818+// HostCommand builds a command that runs on the host, hopping out of the
1919+// Flatpak sandbox via flatpak-spawn --host when one is active.
2020+func HostCommand(name string, args ...string) *exec.Cmd {
2121+ if !InFlatpak() {
2222+ return exec.Command(name, args...)
2323+ }
2424+ hostArgs := append([]string{"--host", name}, args...)
2525+ return exec.Command("flatpak-spawn", hostArgs...)
2626+}
+11-8
justfile
···6161test-config:
6262 go test -v ./internal/config
63636464-test: test-routing test-config
6464+test-browser:
6565+ go test -v ./internal/browser
6666+6767+test: test-routing test-config test-browser
65686669# Run tests with coverage report
6770test-routing-coverage:
···7174 @echo ""
7275 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out"
73767474-test-coverage: test-config test-routing-coverage
7777+test-coverage: test-config test-browser test-routing-coverage
75787679# Build and install Flatpak (development version)
7780flatpak:
···142145 exit 1
143146 fi
144147145145- dirty="$(git status --porcelain -- ':!cmd/switchyard/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")"
148148+ dirty="$(git status --porcelain -- ':!gtk/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")"
146149 if [ -n "$dirty" ]; then
147147- echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2
150150+ echo "error: working tree has changes outside gtk/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2
148151 echo "$dirty" >&2
149152 exit 1
150153 fi
···162165163166 just test
164167165165- sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" cmd/switchyard/app.go
166166- if ! grep -qE "Version\s*=\s*\"${version}\"" cmd/switchyard/app.go; then
167167- echo "error: failed to update Version in cmd/switchyard/app.go" >&2
168168+ sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" gtk/app.go
169169+ if ! grep -qE "Version\s*=\s*\"${version}\"" gtk/app.go; then
170170+ echo "error: failed to update Version in gtk/app.go" >&2
168171 exit 1
169172 fi
170173···186189 exit 1
187190 fi
188191189189- git add cmd/switchyard/app.go "${metainfo}" "${manifest}" "${firefox_manifest}" "${pkgjson}"
192192+ git add gtk/app.go "${metainfo}" "${manifest}" "${firefox_manifest}" "${pkgjson}"
190193 git commit -m "update for ${tag}"
191194 git tag -a "${tag}" -m "${tag}"
192195 git push origin master