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.

darwin: add browser scanning and launch support

Aly Raffauf (Jul 6, 2026, 8:48 AM EDT) 3e39a6f9 715dc6cd

+699 -47
+9 -5
cmd/sw/main.go
··· 132 132 os.Exit(1) 133 133 } 134 134 allBrowsers := browserscan.Installed() 135 - for _, pref := range browserPrefs { 136 - id := pref 137 - if !strings.HasSuffix(id, ".desktop") { 138 - id += ".desktop" 135 + for _, browserPreference := range browserPrefs { 136 + if launchOrLog(allBrowsers, browserPreference, targetURL) { 137 + return 139 138 } 140 - if launchOrLog(allBrowsers, id, targetURL) { 139 + 140 + if strings.HasSuffix(browserPreference, ".desktop") { 141 + continue 142 + } 143 + 144 + if launchOrLog(allBrowsers, browserPreference+".desktop", targetURL) { 141 145 return 142 146 } 143 147 }
+1
go.mod
··· 33 33 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect 34 34 golang.org/x/sync v0.21.0 // indirect 35 35 golang.org/x/sys v0.46.0 // indirect 36 + howett.net/plist v1.0.1 // indirect 36 37 )
+5
go.sum
··· 44 44 github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q= 45 45 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 46 46 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 47 + github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 47 48 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 48 49 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 49 50 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= ··· 74 75 golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 75 76 golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 76 77 golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 78 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 + gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 80 + howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= 81 + howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+9 -6
gtk/run.go
··· 115 115 116 116 // If browser preferences specified, try each in order 117 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" 118 + for _, browserPreference := range browserPrefs { 119 + if launchBrowserByID(browserPreference, sanitized) { 120 + return 121 + } 122 + 123 + if strings.HasSuffix(browserPreference, ".desktop") { 124 + continue 123 125 } 124 - if launchBrowserByID(id, sanitized) { 126 + 127 + if launchBrowserByID(browserPreference+".desktop", sanitized) { 125 128 return 126 129 } 127 130 }
+6 -4
internal/browser/actions.go
··· 2 2 3 3 package browser 4 4 5 - import "github.com/alyraffauf/goxdgdesktop/desktopfile" 6 - 7 - // Action is a desktop-entry action, e.g. "new-private-window". 8 - type Action = desktopfile.Action 5 + // Action is an app-specific launch action, e.g. "new-private-window". 6 + type Action struct { 7 + ID string 8 + Name string 9 + Exec string 10 + }
+7 -18
internal/browser/launch.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browser 4 6 5 7 import ( ··· 12 14 13 15 const activationTokenEnv = "XDG_ACTIVATION_TOKEN" 14 16 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. 17 + // buildCommand assembles the OS command that launches cmdline for url via a 18 + // desktop Exec line, honoring activationToken (the Wayland/X11 startup token 19 + // for raising the window, may be empty) and running on the host via 20 + // flatpak-spawn when inFlatpak is set. Returns nil when the resolved command 21 + // line is empty. 33 22 func buildCommand(cmdline, url, activationToken string, inFlatpak bool) *exec.Cmd { 34 23 if url != "" { 35 24 cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url)
+17
internal/browser/launch_common.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browser 4 + 5 + // Launch starts the platform command for cmdline/url in the background. 6 + // Empty command lines are no-ops. 7 + func Launch(cmdline, url, activationToken string, inFlatpak bool) error { 8 + cmd := buildCommand(cmdline, url, activationToken, inFlatpak) 9 + if cmd == nil { 10 + return nil 11 + } 12 + if err := cmd.Start(); err != nil { 13 + return err 14 + } 15 + go cmd.Wait() 16 + return nil 17 + }
+21
internal/browser/launch_darwin.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + //go:build darwin 4 + 5 + package browser 6 + 7 + import "os/exec" 8 + 9 + // buildCommand assembles the macOS open invocation for bundlePath and url. 10 + // Linux-only launch options are ignored. Returns nil when bundlePath is empty. 11 + func buildCommand(bundlePath, url, _ string, _ bool) *exec.Cmd { 12 + if bundlePath == "" { 13 + return nil 14 + } 15 + 16 + args := []string{"-a", bundlePath} 17 + if url != "" { 18 + args = append(args, url) 19 + } 20 + return exec.Command("open", args...) 21 + }
+34
internal/browser/launch_darwin_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + //go:build darwin 4 + 5 + package browser 6 + 7 + import ( 8 + "slices" 9 + "testing" 10 + ) 11 + 12 + func TestBuildCommandEmptyBundlePathIsNoOp(t *testing.T) { 13 + if cmd := buildCommand("", "https://example.com", "", false); cmd != nil { 14 + t.Fatalf("empty bundle path: got %v, want nil", cmd.Args) 15 + } 16 + } 17 + 18 + func TestBuildCommandOpensBundleWithURL(t *testing.T) { 19 + cmd := buildCommand("/Applications/Firefox.app", "https://example.com", "", false) 20 + 21 + want := []string{"open", "-a", "/Applications/Firefox.app", "https://example.com"} 22 + if !slices.Equal(cmd.Args, want) { 23 + t.Fatalf("args: got %v, want %v", cmd.Args, want) 24 + } 25 + } 26 + 27 + func TestBuildCommandOmitsURLWhenEmpty(t *testing.T) { 28 + cmd := buildCommand("/Applications/Firefox.app", "", "", false) 29 + 30 + want := []string{"open", "-a", "/Applications/Firefox.app"} 31 + if !slices.Equal(cmd.Args, want) { 32 + t.Fatalf("args: got %v, want %v", cmd.Args, want) 33 + } 34 + }
+2
internal/browser/launch_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browser 4 6 5 7 import (
+2
internal/browserscan/actions.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browserscan 4 6 5 7 import (
+5 -9
internal/browserscan/browserscan.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 // Package browserscan discovers installed HTTP(S) browsers by parsing the 4 6 // .desktop files on the XDG data search path. It has no GTK/GIO dependency. 5 7 package browserscan 6 8 7 9 import ( 8 - "cmp" 9 10 "io/fs" 10 11 "os" 11 12 "path/filepath" ··· 16 17 "github.com/alyraffauf/goxdgdesktop/xdg" 17 18 "github.com/alyraffauf/switchyard/internal/browser" 18 19 ) 19 - 20 - // A browser switcher must never list itself, across all packaged variants. 21 - const selfIDPrefix = "io.github.alyraffauf.Switchyard" 22 20 23 21 var httpSchemeHandlers = []string{ 24 22 "x-scheme-handler/http", ··· 55 53 } 56 54 seen[id] = true 57 55 58 - if strings.HasPrefix(id, selfIDPrefix) { 56 + if isSelf(id) { 59 57 return nil 60 58 } 61 59 ··· 66 64 }) 67 65 } 68 66 69 - slices.SortFunc(browsers, func(first, second browser.Browser) int { 70 - return cmp.Compare(first.Name, second.Name) 71 - }) 67 + sortByName(browsers) 72 68 73 69 return browsers 74 70 } 75 71 76 72 // Find returns a displayable HTTP(S) browser by desktop file ID. 77 73 func Find(id string) (browser.Browser, bool) { 78 - if id == "" || strings.HasPrefix(id, selfIDPrefix) { 74 + if isSelf(id) { 79 75 return browser.Browser{}, false 80 76 } 81 77
+207
internal/browserscan/browserscan_darwin.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + //go:build darwin 4 + 5 + // Package browserscan discovers installed HTTP(S) browsers via NSWorkspace 6 + // (Launch Services), then reads each candidate's Info.plist for display 7 + // metadata. 8 + package browserscan 9 + 10 + /* 11 + #cgo LDFLAGS: -framework Cocoa 12 + #include <stdlib.h> 13 + 14 + char *browserscan_nsworkspace_app_paths(void); 15 + */ 16 + import "C" 17 + 18 + import ( 19 + "os" 20 + "path/filepath" 21 + "slices" 22 + "strings" 23 + "sync" 24 + "unsafe" 25 + 26 + "github.com/alyraffauf/switchyard/internal/browser" 27 + "howett.net/plist" 28 + ) 29 + 30 + // appPaths is a variable so tests can stub app discovery without touching 31 + // the real filesystem or Launch Services. 32 + var appPaths = nsWorkspaceAppPaths 33 + 34 + // nsWorkspaceAppPaths lists every app registered to open https:// URLs, via 35 + // NSWorkspace/Launch Services (see nsworkspace_darwin.m). 36 + func nsWorkspaceAppPaths() []string { 37 + rawPaths := C.browserscan_nsworkspace_app_paths() 38 + if rawPaths == nil { 39 + return nil 40 + } 41 + defer C.free(unsafe.Pointer(rawPaths)) 42 + 43 + return strings.Split(C.GoString(rawPaths), "\n") 44 + } 45 + 46 + type bundleInfo struct { 47 + BundleIdentifier string `plist:"CFBundleIdentifier"` 48 + BundleName string `plist:"CFBundleName"` 49 + BundleDisplayName string `plist:"CFBundleDisplayName"` 50 + BundleIconFile string `plist:"CFBundleIconFile"` 51 + BundleIconName string `plist:"CFBundleIconName"` 52 + URLTypes []bundleURLType `plist:"CFBundleURLTypes"` 53 + UIElement bool `plist:"LSUIElement"` 54 + BackgroundOnly bool `plist:"LSBackgroundOnly"` 55 + } 56 + 57 + type bundleURLType struct { 58 + Schemes []string `plist:"CFBundleURLSchemes"` 59 + Role string `plist:"CFBundleTypeRole"` 60 + Rank string `plist:"LSHandlerRank"` 61 + } 62 + 63 + type parsedBrowserResult struct { 64 + browser browser.Browser 65 + ok bool 66 + } 67 + 68 + // lsHandlerRankNone marks a URL type as opted out of being a handler candidate. 69 + const lsHandlerRankNone = "None" 70 + 71 + // Installed returns the installed HTTP(S) browsers, sorted by Name. Switchyard's 72 + // own entries are excluded; filtering config-hidden browsers is the caller's job. 73 + func Installed() []browser.Browser { 74 + browsers := scanBrowsers() 75 + sortByName(browsers) 76 + 77 + return browsers 78 + } 79 + 80 + // Find returns a displayable HTTP(S) browser by bundle identifier. 81 + func Find(id string) (browser.Browser, bool) { 82 + if isSelf(id) { 83 + return browser.Browser{}, false 84 + } 85 + 86 + for _, installedBrowser := range scanBrowsers() { 87 + if installedBrowser.ID == id { 88 + return installedBrowser, true 89 + } 90 + } 91 + 92 + return browser.Browser{}, false 93 + } 94 + 95 + func scanBrowsers() []browser.Browser { 96 + discoveredAppPaths := appPaths() 97 + results := make([]parsedBrowserResult, len(discoveredAppPaths)) 98 + 99 + var waitGroup sync.WaitGroup 100 + for index, appPath := range discoveredAppPaths { 101 + waitGroup.Add(1) 102 + 103 + go func(index int, appPath string) { 104 + defer waitGroup.Done() 105 + 106 + parsedBrowser, ok := parseBrowser(appPath) 107 + results[index] = parsedBrowserResult{ 108 + browser: parsedBrowser, 109 + ok: ok, 110 + } 111 + }(index, appPath) 112 + } 113 + waitGroup.Wait() 114 + 115 + browsers := make([]browser.Browser, 0, len(results)) 116 + seen := map[string]bool{} 117 + 118 + for _, result := range results { 119 + if !result.ok || seen[result.browser.ID] { 120 + continue 121 + } 122 + seen[result.browser.ID] = true 123 + browsers = append(browsers, result.browser) 124 + } 125 + 126 + return browsers 127 + } 128 + 129 + // ListDesktopActions returns app-specific launch actions for appID. macOS app 130 + // bundles have no generic mechanism analogous to XDG desktop actions, so this 131 + // always returns nil. 132 + func ListDesktopActions(appID string) []browser.Action { 133 + return nil 134 + } 135 + 136 + // parseBrowser returns the Browser at appPath, or ok=false if it isn't a 137 + // displayable HTTP(S) browser. 138 + func parseBrowser(appPath string) (browser.Browser, bool) { 139 + data, err := os.ReadFile(filepath.Join(appPath, "Contents", "Info.plist")) 140 + if err != nil { 141 + return browser.Browser{}, false 142 + } 143 + 144 + var info bundleInfo 145 + if _, err := plist.Unmarshal(data, &info); err != nil { 146 + return browser.Browser{}, false 147 + } 148 + 149 + if isSelf(info.BundleIdentifier) { 150 + return browser.Browser{}, false 151 + } 152 + if info.UIElement || info.BackgroundOnly { 153 + return browser.Browser{}, false 154 + } 155 + if !handlesHTTP(info.URLTypes) { 156 + return browser.Browser{}, false 157 + } 158 + 159 + return browser.Browser{ 160 + ID: info.BundleIdentifier, 161 + Name: displayName(appPath, info), 162 + Icon: iconPath(appPath, info), 163 + Exec: appPath, // a bundle path here, not a shell command line 164 + }, true 165 + } 166 + 167 + // handlesHTTP requires both http and https, with neither opted out via lsHandlerRankNone 168 + func handlesHTTP(types []bundleURLType) bool { 169 + var hasHTTP, hasHTTPS bool 170 + for _, urlType := range types { 171 + if strings.EqualFold(urlType.Role, lsHandlerRankNone) || strings.EqualFold(urlType.Rank, lsHandlerRankNone) { 172 + continue 173 + } 174 + if slices.Contains(urlType.Schemes, "http") { 175 + hasHTTP = true 176 + } 177 + if slices.Contains(urlType.Schemes, "https") { 178 + hasHTTPS = true 179 + } 180 + } 181 + return hasHTTP && hasHTTPS 182 + } 183 + 184 + func displayName(appPath string, info bundleInfo) string { 185 + if info.BundleDisplayName != "" { 186 + return info.BundleDisplayName 187 + } 188 + if info.BundleName != "" { 189 + return info.BundleName 190 + } 191 + return strings.TrimSuffix(filepath.Base(appPath), ".app") 192 + } 193 + 194 + func iconPath(appPath string, info bundleInfo) string { 195 + icon := info.BundleIconFile 196 + if icon == "" { 197 + icon = info.BundleIconName 198 + } 199 + if icon == "" { 200 + return "" 201 + } 202 + if filepath.Ext(icon) == "" { 203 + // CFBundleIconFile conventionally omits the extension. 204 + icon += ".icns" 205 + } 206 + return filepath.Join(appPath, "Contents", "Resources", icon) 207 + }
+307
internal/browserscan/browserscan_darwin_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + //go:build darwin 4 + 5 + package browserscan 6 + 7 + import ( 8 + "os" 9 + "path/filepath" 10 + "slices" 11 + "testing" 12 + 13 + "github.com/alyraffauf/switchyard/internal/browser" 14 + ) 15 + 16 + // browserPlist is a minimal Info.plist for an app that handles HTTP(S). 17 + const browserPlist = `<?xml version="1.0" encoding="UTF-8"?> 18 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 19 + <plist version="1.0"> 20 + <dict> 21 + <key>CFBundleIdentifier</key> 22 + <string>com.example.testbrowser</string> 23 + <key>CFBundleName</key> 24 + <string>Test Browser</string> 25 + <key>CFBundleIconFile</key> 26 + <string>test-browser</string> 27 + <key>CFBundleURLTypes</key> 28 + <array> 29 + <dict> 30 + <key>CFBundleURLSchemes</key> 31 + <array> 32 + <string>http</string> 33 + <string>https</string> 34 + </array> 35 + </dict> 36 + </array> 37 + </dict> 38 + </plist> 39 + ` 40 + 41 + // writeAppBundle writes an Info.plist at dir/<name>.app/Contents/Info.plist. 42 + func writeAppBundle(t *testing.T, dir, name, contents string) string { 43 + t.Helper() 44 + appPath := filepath.Join(dir, name+".app") 45 + contentsDir := filepath.Join(appPath, "Contents") 46 + if err := os.MkdirAll(contentsDir, 0o755); err != nil { 47 + t.Fatalf("mkdir: %v", err) 48 + } 49 + if err := os.WriteFile(filepath.Join(contentsDir, "Info.plist"), []byte(contents), 0o644); err != nil { 50 + t.Fatalf("write Info.plist: %v", err) 51 + } 52 + return appPath 53 + } 54 + 55 + // isolatedPaths overrides app discovery to return exactly paths, in order, 56 + // fully isolating the scan from the host's real installed apps. 57 + func isolatedPaths(t *testing.T, paths ...string) { 58 + t.Helper() 59 + originalAppPaths := appPaths 60 + appPaths = func() []string { return paths } 61 + t.Cleanup(func() { appPaths = originalAppPaths }) 62 + } 63 + 64 + func browserIDs(browsers []browser.Browser) []string { 65 + browserIdentifiers := make([]string, len(browsers)) 66 + for i, installedBrowser := range browsers { 67 + browserIdentifiers[i] = installedBrowser.ID 68 + } 69 + return browserIdentifiers 70 + } 71 + 72 + func TestInstalledIncludesBrowserAndFields(t *testing.T) { 73 + dir := t.TempDir() 74 + appPath := writeAppBundle(t, dir, "Test Browser", browserPlist) 75 + isolatedPaths(t, appPath) 76 + 77 + got := Installed() 78 + if len(got) != 1 { 79 + t.Fatalf("got %d browsers, want 1: %v", len(got), browserIDs(got)) 80 + } 81 + installed := got[0] 82 + if installed.ID != "com.example.testbrowser" || installed.Name != "Test Browser" { 83 + t.Errorf("unexpected fields: %+v", installed) 84 + } 85 + wantIcon := filepath.Join(dir, "Test Browser.app", "Contents", "Resources", "test-browser.icns") 86 + if installed.Icon != wantIcon { 87 + t.Errorf("icon: got %q, want %q", installed.Icon, wantIcon) 88 + } 89 + wantExec := filepath.Join(dir, "Test Browser.app") 90 + if installed.Exec != wantExec { 91 + t.Errorf("exec: got %q, want %q", installed.Exec, wantExec) 92 + } 93 + if len(installed.Actions) != 0 { 94 + t.Errorf("expected no actions, got %v", installed.Actions) 95 + } 96 + } 97 + 98 + func TestInstalledFiltering(t *testing.T) { 99 + tests := []struct { 100 + name string 101 + contents string 102 + }{ 103 + { 104 + name: "http only, no https", 105 + contents: plistWithURLTypes(` 106 + <dict> 107 + <key>CFBundleURLSchemes</key> 108 + <array><string>http</string></array> 109 + </dict>`), 110 + }, 111 + { 112 + name: "no url types at all", 113 + contents: plistWithURLTypes(""), 114 + }, 115 + { 116 + name: "handler rank none", 117 + contents: plistWithURLTypes(` 118 + <dict> 119 + <key>CFBundleURLSchemes</key> 120 + <array><string>http</string><string>https</string></array> 121 + <key>LSHandlerRank</key> 122 + <string>None</string> 123 + </dict>`), 124 + }, 125 + { 126 + name: "type role none", 127 + contents: plistWithURLTypes(` 128 + <dict> 129 + <key>CFBundleURLSchemes</key> 130 + <array><string>http</string><string>https</string></array> 131 + <key>CFBundleTypeRole</key> 132 + <string>None</string> 133 + </dict>`), 134 + }, 135 + { 136 + name: "ui element agent", 137 + contents: `<?xml version="1.0" encoding="UTF-8"?> 138 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 139 + <plist version="1.0"> 140 + <dict> 141 + <key>CFBundleIdentifier</key> 142 + <string>com.example.testbrowser</string> 143 + <key>LSUIElement</key> 144 + <true/> 145 + <key>CFBundleURLTypes</key> 146 + <array> 147 + <dict> 148 + <key>CFBundleURLSchemes</key> 149 + <array><string>http</string><string>https</string></array> 150 + </dict> 151 + </array> 152 + </dict> 153 + </plist> 154 + `, 155 + }, 156 + { 157 + name: "missing bundle identifier", 158 + contents: `<?xml version="1.0" encoding="UTF-8"?> 159 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 160 + <plist version="1.0"> 161 + <dict> 162 + <key>CFBundleURLTypes</key> 163 + <array> 164 + <dict> 165 + <key>CFBundleURLSchemes</key> 166 + <array><string>http</string><string>https</string></array> 167 + </dict> 168 + </array> 169 + </dict> 170 + </plist> 171 + `, 172 + }, 173 + } 174 + 175 + for _, tt := range tests { 176 + t.Run(tt.name, func(t *testing.T) { 177 + dir := t.TempDir() 178 + appPath := writeAppBundle(t, dir, "Candidate", tt.contents) 179 + isolatedPaths(t, appPath) 180 + 181 + if got := Installed(); len(got) != 0 { 182 + t.Errorf("expected entry excluded, got %v", browserIDs(got)) 183 + } 184 + }) 185 + } 186 + } 187 + 188 + func TestInstalledExcludesSwitchyardFamily(t *testing.T) { 189 + dir := t.TempDir() 190 + switchyardPath := writeAppBundle(t, dir, "Switchyard", plistForID("io.github.alyraffauf.Switchyard")) 191 + switchyardDevelPath := writeAppBundle(t, dir, "Switchyard Devel", plistForID("io.github.alyraffauf.Switchyard.Devel")) 192 + realBrowserPath := writeAppBundle(t, dir, "Real Browser", plistForID("com.example.realbrowser")) 193 + isolatedPaths(t, switchyardPath, switchyardDevelPath, realBrowserPath) 194 + 195 + got := browserIDs(Installed()) 196 + want := []string{"com.example.realbrowser"} 197 + if !slices.Equal(got, want) { 198 + t.Errorf("got %v, want %v", got, want) 199 + } 200 + } 201 + 202 + func TestInstalledDedupesByBundleID(t *testing.T) { 203 + dir := t.TempDir() 204 + // Same bundle ID reported twice; the first one found wins. 205 + first := writeAppBundle(t, dir, "First Browser", plistForID("com.example.browser")) 206 + second := writeAppBundle(t, dir, "Second Browser", plistForID("com.example.browser")) 207 + isolatedPaths(t, first, second) 208 + 209 + got := Installed() 210 + if len(got) != 1 { 211 + t.Fatalf("got %d browsers, want 1: %v", len(got), browserIDs(got)) 212 + } 213 + if got[0].Name != "First Browser" { 214 + t.Errorf("got %q, want %q", got[0].Name, "First Browser") 215 + } 216 + } 217 + 218 + func TestInstalledSortedByName(t *testing.T) { 219 + dir := t.TempDir() 220 + charliePath := writeAppBundle(t, dir, "Charlie", namedPlistForID("com.example.charlie", "Charlie")) 221 + alicePath := writeAppBundle(t, dir, "Alice", namedPlistForID("com.example.alice", "Alice")) 222 + bobPath := writeAppBundle(t, dir, "Bob", namedPlistForID("com.example.bob", "Bob")) 223 + isolatedPaths(t, charliePath, alicePath, bobPath) 224 + 225 + got := []string{} 226 + for _, installedBrowser := range Installed() { 227 + got = append(got, installedBrowser.Name) 228 + } 229 + want := []string{"Alice", "Bob", "Charlie"} 230 + if !slices.Equal(got, want) { 231 + t.Fatalf("got %v, want %v", got, want) 232 + } 233 + } 234 + 235 + func TestFindBrowser(t *testing.T) { 236 + dir := t.TempDir() 237 + appPath := writeAppBundle(t, dir, "Test Browser", browserPlist) 238 + isolatedPaths(t, appPath) 239 + 240 + got, ok := Find("com.example.testbrowser") 241 + if !ok { 242 + t.Fatal("Find returned false") 243 + } 244 + if got.Name != "Test Browser" { 245 + t.Errorf("unexpected browser: %+v", got) 246 + } 247 + } 248 + 249 + func TestFindMissingOrRejected(t *testing.T) { 250 + dir := t.TempDir() 251 + appPath := writeAppBundle(t, dir, "Not A Browser", plistWithURLTypes("")) 252 + isolatedPaths(t, appPath) 253 + 254 + if got, ok := Find("com.example.testbrowser"); ok { 255 + t.Errorf("Find returned %+v, want false", got) 256 + } 257 + if got, ok := Find("missing.id"); ok { 258 + t.Errorf("Find returned %+v, want false", got) 259 + } 260 + } 261 + 262 + func TestListDesktopActionsIsAlwaysEmpty(t *testing.T) { 263 + if got := ListDesktopActions("com.example.testbrowser"); got != nil { 264 + t.Errorf("got %v, want nil", got) 265 + } 266 + } 267 + 268 + func plistWithURLTypes(urlTypesXML string) string { 269 + return `<?xml version="1.0" encoding="UTF-8"?> 270 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 271 + <plist version="1.0"> 272 + <dict> 273 + <key>CFBundleIdentifier</key> 274 + <string>com.example.testbrowser</string> 275 + <key>CFBundleURLTypes</key> 276 + <array>` + urlTypesXML + `</array> 277 + </dict> 278 + </plist> 279 + ` 280 + } 281 + 282 + func plistForID(id string) string { 283 + return namedPlistForID(id, "") 284 + } 285 + 286 + func namedPlistForID(id, name string) string { 287 + nameXML := "" 288 + if name != "" { 289 + nameXML = "\t<key>CFBundleName</key>\n\t<string>" + name + "</string>\n" 290 + } 291 + return `<?xml version="1.0" encoding="UTF-8"?> 292 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 293 + <plist version="1.0"> 294 + <dict> 295 + <key>CFBundleIdentifier</key> 296 + <string>` + id + `</string> 297 + ` + nameXML + ` <key>CFBundleURLTypes</key> 298 + <array> 299 + <dict> 300 + <key>CFBundleURLSchemes</key> 301 + <array><string>http</string><string>https</string></array> 302 + </dict> 303 + </array> 304 + </dict> 305 + </plist> 306 + ` 307 + }
+2
internal/browserscan/browserscan_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browserscan 4 6 5 7 import (
+27
internal/browserscan/common.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package browserscan 4 + 5 + import ( 6 + "cmp" 7 + "slices" 8 + "strings" 9 + 10 + "github.com/alyraffauf/switchyard/internal/browser" 11 + ) 12 + 13 + // A browser switcher must never list itself, across all packaged variants. 14 + const selfIDPrefix = "io.github.alyraffauf.Switchyard" 15 + 16 + // isSelf reports whether id belongs to Switchyard itself. 17 + // The id is a desktop file ID on Linux and a bundle identifier on macOS. 18 + func isSelf(id string) bool { 19 + return id == "" || strings.HasPrefix(id, selfIDPrefix) 20 + } 21 + 22 + // sortByName sorts browsers in place. 23 + func sortByName(browsers []browser.Browser) { 24 + slices.SortFunc(browsers, func(first, second browser.Browser) int { 25 + return cmp.Compare(first.Name, second.Name) 26 + }) 27 + }
+10 -5
internal/browserscan/locale.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browserscan 4 6 5 7 import ( ··· 20 22 } 21 23 22 24 func localizedActions(file *desktopfile.File) []browser.Action { 23 - actions := file.Actions() 24 - for i := range actions { 25 - section := desktopfile.ActionSectionStart + actions[i].ID 26 - if name := localizedString(file, section, "Name"); name != "" { 27 - actions[i].Name = name 25 + desktopActions := file.Actions() 26 + actions := make([]browser.Action, len(desktopActions)) 27 + for i, action := range desktopActions { 28 + name := action.Name 29 + section := desktopfile.ActionSectionStart + action.ID 30 + if localized := localizedString(file, section, "Name"); localized != "" { 31 + name = localized 28 32 } 33 + actions[i] = browser.Action{ID: action.ID, Name: name, Exec: action.Exec} 29 34 } 30 35 return actions 31 36 }
+2
internal/browserscan/locale_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 + //go:build !darwin 4 + 3 5 package browserscan 4 6 5 7 import (
+26
internal/browserscan/nsworkspace_darwin.m
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + #import <Cocoa/Cocoa.h> 4 + #import <string.h> 5 + 6 + // Returns every app path registered to open https:// URLs, newline-joined, 7 + // or NULL if there are none. Caller must free() the result. 8 + char *browserscan_nsworkspace_app_paths(void) { 9 + @autoreleasepool { 10 + NSURL *probe = [NSURL URLWithString:@"https://example.com"]; 11 + NSArray<NSURL *> *appURLs = 12 + [[NSWorkspace sharedWorkspace] URLsForApplicationsToOpenURL:probe]; 13 + 14 + if (appURLs.count == 0) { 15 + return NULL; 16 + } 17 + 18 + NSMutableArray<NSString *> *paths = [NSMutableArray array]; 19 + for (NSURL *url in appURLs) { 20 + [paths addObject:url.path]; 21 + } 22 + NSString *joined = [paths componentsJoinedByString:@"\n"]; 23 + // Copy out of the autorelease pool so the caller owns the returned memory. 24 + return strdup(joined.UTF8String); 25 + } 26 + }