From 3e39a6f90c68a977fd4b9f1267b995ed137e187c Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 6 Jul 2026 08:48:09 -0400 Subject: [PATCH] darwin: add browser scanning and launch support --- cmd/sw/main.go | 14 +- go.mod | 1 + go.sum | 5 + gtk/run.go | 15 +- internal/browser/actions.go | 10 +- internal/browser/launch.go | 25 +- internal/browser/launch_common.go | 17 + internal/browser/launch_darwin.go | 21 ++ internal/browser/launch_darwin_test.go | 34 ++ internal/browser/launch_test.go | 2 + internal/browserscan/actions.go | 2 + internal/browserscan/browserscan.go | 14 +- internal/browserscan/browserscan_darwin.go | 207 ++++++++++++ .../browserscan/browserscan_darwin_test.go | 307 ++++++++++++++++++ internal/browserscan/browserscan_test.go | 2 + internal/browserscan/common.go | 27 ++ internal/browserscan/locale.go | 15 +- internal/browserscan/locale_test.go | 2 + internal/browserscan/nsworkspace_darwin.m | 26 ++ 19 files changed, 699 insertions(+), 47 deletions(-) create mode 100644 internal/browser/launch_common.go create mode 100644 internal/browser/launch_darwin.go create mode 100644 internal/browser/launch_darwin_test.go create mode 100644 internal/browserscan/browserscan_darwin.go create mode 100644 internal/browserscan/browserscan_darwin_test.go create mode 100644 internal/browserscan/common.go create mode 100644 internal/browserscan/nsworkspace_darwin.m diff --git a/cmd/sw/main.go b/cmd/sw/main.go index 2192a00..026f8d3 100644 --- a/cmd/sw/main.go +++ b/cmd/sw/main.go @@ -132,12 +132,16 @@ func main() { os.Exit(1) } allBrowsers := browserscan.Installed() - for _, pref := range browserPrefs { - id := pref - if !strings.HasSuffix(id, ".desktop") { - id += ".desktop" + for _, browserPreference := range browserPrefs { + if launchOrLog(allBrowsers, browserPreference, targetURL) { + return + } + + if strings.HasSuffix(browserPreference, ".desktop") { + continue } - if launchOrLog(allBrowsers, id, targetURL) { + + if launchOrLog(allBrowsers, browserPreference+".desktop", targetURL) { return } } diff --git a/go.mod b/go.mod index c67285d..fa4ed31 100644 --- a/go.mod +++ b/go.mod @@ -33,4 +33,5 @@ require ( go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect + howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 22a3daf..954c167 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,7 @@ github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a h1:dN2jY github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -74,3 +75,7 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/gtk/run.go b/gtk/run.go index 6b0d286..91e902b 100644 --- a/gtk/run.go +++ b/gtk/run.go @@ -115,13 +115,16 @@ func handleSwitchyardURL(app *adw.Application, cfg *Config, rawURL string) { // If browser preferences specified, try each in order if len(browserPrefs) > 0 { - for _, pref := range browserPrefs { - // Try with and without .desktop suffix - id := pref - if !strings.HasSuffix(id, ".desktop") { - id = id + ".desktop" + for _, browserPreference := range browserPrefs { + if launchBrowserByID(browserPreference, sanitized) { + return + } + + if strings.HasSuffix(browserPreference, ".desktop") { + continue } - if launchBrowserByID(id, sanitized) { + + if launchBrowserByID(browserPreference+".desktop", sanitized) { return } } diff --git a/internal/browser/actions.go b/internal/browser/actions.go index 11be26f..c6cb9d1 100644 --- a/internal/browser/actions.go +++ b/internal/browser/actions.go @@ -2,7 +2,9 @@ package browser -import "github.com/alyraffauf/goxdgdesktop/desktopfile" - -// Action is a desktop-entry action, e.g. "new-private-window". -type Action = desktopfile.Action +// Action is an app-specific launch action, e.g. "new-private-window". +type Action struct { + ID string + Name string + Exec string +} diff --git a/internal/browser/launch.go b/internal/browser/launch.go index 851b6f0..fe5da3b 100644 --- a/internal/browser/launch.go +++ b/internal/browser/launch.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browser import ( @@ -12,24 +14,11 @@ import ( const activationTokenEnv = "XDG_ACTIVATION_TOKEN" -// Launch runs cmdline for url in the background. activationToken is the -// Wayland/X11 startup token for raising the window and may be empty; when -// inFlatpak is set the command runs on the host via flatpak-spawn. An empty -// command line is a no-op. -func Launch(cmdline, url, activationToken string, inFlatpak bool) error { - cmd := buildCommand(cmdline, url, activationToken, inFlatpak) - if cmd == nil { - return nil - } - if err := cmd.Start(); err != nil { - return err - } - go cmd.Wait() - return nil -} - -// buildCommand assembles the OS command that launches cmdline for url, or nil -// when the resolved command line is empty. +// buildCommand assembles the OS command that launches cmdline for url via a +// desktop Exec line, honoring activationToken (the Wayland/X11 startup token +// for raising the window, may be empty) and running on the host via +// flatpak-spawn when inFlatpak is set. Returns nil when the resolved command +// line is empty. func buildCommand(cmdline, url, activationToken string, inFlatpak bool) *exec.Cmd { if url != "" { cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url) diff --git a/internal/browser/launch_common.go b/internal/browser/launch_common.go new file mode 100644 index 0000000..48bff07 --- /dev/null +++ b/internal/browser/launch_common.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package browser + +// Launch starts the platform command for cmdline/url in the background. +// Empty command lines are no-ops. +func Launch(cmdline, url, activationToken string, inFlatpak bool) error { + cmd := buildCommand(cmdline, url, activationToken, inFlatpak) + if cmd == nil { + return nil + } + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() + return nil +} diff --git a/internal/browser/launch_darwin.go b/internal/browser/launch_darwin.go new file mode 100644 index 0000000..f3e6f23 --- /dev/null +++ b/internal/browser/launch_darwin.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build darwin + +package browser + +import "os/exec" + +// buildCommand assembles the macOS open invocation for bundlePath and url. +// Linux-only launch options are ignored. Returns nil when bundlePath is empty. +func buildCommand(bundlePath, url, _ string, _ bool) *exec.Cmd { + if bundlePath == "" { + return nil + } + + args := []string{"-a", bundlePath} + if url != "" { + args = append(args, url) + } + return exec.Command("open", args...) +} diff --git a/internal/browser/launch_darwin_test.go b/internal/browser/launch_darwin_test.go new file mode 100644 index 0000000..3d591b4 --- /dev/null +++ b/internal/browser/launch_darwin_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build darwin + +package browser + +import ( + "slices" + "testing" +) + +func TestBuildCommandEmptyBundlePathIsNoOp(t *testing.T) { + if cmd := buildCommand("", "https://example.com", "", false); cmd != nil { + t.Fatalf("empty bundle path: got %v, want nil", cmd.Args) + } +} + +func TestBuildCommandOpensBundleWithURL(t *testing.T) { + cmd := buildCommand("/Applications/Firefox.app", "https://example.com", "", false) + + want := []string{"open", "-a", "/Applications/Firefox.app", "https://example.com"} + if !slices.Equal(cmd.Args, want) { + t.Fatalf("args: got %v, want %v", cmd.Args, want) + } +} + +func TestBuildCommandOmitsURLWhenEmpty(t *testing.T) { + cmd := buildCommand("/Applications/Firefox.app", "", "", false) + + want := []string{"open", "-a", "/Applications/Firefox.app"} + if !slices.Equal(cmd.Args, want) { + t.Fatalf("args: got %v, want %v", cmd.Args, want) + } +} diff --git a/internal/browser/launch_test.go b/internal/browser/launch_test.go index 6c8e775..d81d765 100644 --- a/internal/browser/launch_test.go +++ b/internal/browser/launch_test.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browser import ( diff --git a/internal/browserscan/actions.go b/internal/browserscan/actions.go index cb90a09..915ff58 100644 --- a/internal/browserscan/actions.go +++ b/internal/browserscan/actions.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browserscan import ( diff --git a/internal/browserscan/browserscan.go b/internal/browserscan/browserscan.go index 22e3953..097f5c7 100644 --- a/internal/browserscan/browserscan.go +++ b/internal/browserscan/browserscan.go @@ -1,11 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + // Package browserscan discovers installed HTTP(S) browsers by parsing the // .desktop files on the XDG data search path. It has no GTK/GIO dependency. package browserscan import ( - "cmp" "io/fs" "os" "path/filepath" @@ -17,9 +18,6 @@ import ( "github.com/alyraffauf/switchyard/internal/browser" ) -// A browser switcher must never list itself, across all packaged variants. -const selfIDPrefix = "io.github.alyraffauf.Switchyard" - var httpSchemeHandlers = []string{ "x-scheme-handler/http", "x-scheme-handler/https", @@ -55,7 +53,7 @@ func Installed() []browser.Browser { } seen[id] = true - if strings.HasPrefix(id, selfIDPrefix) { + if isSelf(id) { return nil } @@ -66,16 +64,14 @@ func Installed() []browser.Browser { }) } - slices.SortFunc(browsers, func(first, second browser.Browser) int { - return cmp.Compare(first.Name, second.Name) - }) + sortByName(browsers) return browsers } // Find returns a displayable HTTP(S) browser by desktop file ID. func Find(id string) (browser.Browser, bool) { - if id == "" || strings.HasPrefix(id, selfIDPrefix) { + if isSelf(id) { return browser.Browser{}, false } diff --git a/internal/browserscan/browserscan_darwin.go b/internal/browserscan/browserscan_darwin.go new file mode 100644 index 0000000..cdf011c --- /dev/null +++ b/internal/browserscan/browserscan_darwin.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build darwin + +// Package browserscan discovers installed HTTP(S) browsers via NSWorkspace +// (Launch Services), then reads each candidate's Info.plist for display +// metadata. +package browserscan + +/* +#cgo LDFLAGS: -framework Cocoa +#include + +char *browserscan_nsworkspace_app_paths(void); +*/ +import "C" + +import ( + "os" + "path/filepath" + "slices" + "strings" + "sync" + "unsafe" + + "github.com/alyraffauf/switchyard/internal/browser" + "howett.net/plist" +) + +// appPaths is a variable so tests can stub app discovery without touching +// the real filesystem or Launch Services. +var appPaths = nsWorkspaceAppPaths + +// nsWorkspaceAppPaths lists every app registered to open https:// URLs, via +// NSWorkspace/Launch Services (see nsworkspace_darwin.m). +func nsWorkspaceAppPaths() []string { + rawPaths := C.browserscan_nsworkspace_app_paths() + if rawPaths == nil { + return nil + } + defer C.free(unsafe.Pointer(rawPaths)) + + return strings.Split(C.GoString(rawPaths), "\n") +} + +type bundleInfo struct { + BundleIdentifier string `plist:"CFBundleIdentifier"` + BundleName string `plist:"CFBundleName"` + BundleDisplayName string `plist:"CFBundleDisplayName"` + BundleIconFile string `plist:"CFBundleIconFile"` + BundleIconName string `plist:"CFBundleIconName"` + URLTypes []bundleURLType `plist:"CFBundleURLTypes"` + UIElement bool `plist:"LSUIElement"` + BackgroundOnly bool `plist:"LSBackgroundOnly"` +} + +type bundleURLType struct { + Schemes []string `plist:"CFBundleURLSchemes"` + Role string `plist:"CFBundleTypeRole"` + Rank string `plist:"LSHandlerRank"` +} + +type parsedBrowserResult struct { + browser browser.Browser + ok bool +} + +// lsHandlerRankNone marks a URL type as opted out of being a handler candidate. +const lsHandlerRankNone = "None" + +// Installed returns the installed HTTP(S) browsers, sorted by Name. Switchyard's +// own entries are excluded; filtering config-hidden browsers is the caller's job. +func Installed() []browser.Browser { + browsers := scanBrowsers() + sortByName(browsers) + + return browsers +} + +// Find returns a displayable HTTP(S) browser by bundle identifier. +func Find(id string) (browser.Browser, bool) { + if isSelf(id) { + return browser.Browser{}, false + } + + for _, installedBrowser := range scanBrowsers() { + if installedBrowser.ID == id { + return installedBrowser, true + } + } + + return browser.Browser{}, false +} + +func scanBrowsers() []browser.Browser { + discoveredAppPaths := appPaths() + results := make([]parsedBrowserResult, len(discoveredAppPaths)) + + var waitGroup sync.WaitGroup + for index, appPath := range discoveredAppPaths { + waitGroup.Add(1) + + go func(index int, appPath string) { + defer waitGroup.Done() + + parsedBrowser, ok := parseBrowser(appPath) + results[index] = parsedBrowserResult{ + browser: parsedBrowser, + ok: ok, + } + }(index, appPath) + } + waitGroup.Wait() + + browsers := make([]browser.Browser, 0, len(results)) + seen := map[string]bool{} + + for _, result := range results { + if !result.ok || seen[result.browser.ID] { + continue + } + seen[result.browser.ID] = true + browsers = append(browsers, result.browser) + } + + return browsers +} + +// ListDesktopActions returns app-specific launch actions for appID. macOS app +// bundles have no generic mechanism analogous to XDG desktop actions, so this +// always returns nil. +func ListDesktopActions(appID string) []browser.Action { + return nil +} + +// parseBrowser returns the Browser at appPath, or ok=false if it isn't a +// displayable HTTP(S) browser. +func parseBrowser(appPath string) (browser.Browser, bool) { + data, err := os.ReadFile(filepath.Join(appPath, "Contents", "Info.plist")) + if err != nil { + return browser.Browser{}, false + } + + var info bundleInfo + if _, err := plist.Unmarshal(data, &info); err != nil { + return browser.Browser{}, false + } + + if isSelf(info.BundleIdentifier) { + return browser.Browser{}, false + } + if info.UIElement || info.BackgroundOnly { + return browser.Browser{}, false + } + if !handlesHTTP(info.URLTypes) { + return browser.Browser{}, false + } + + return browser.Browser{ + ID: info.BundleIdentifier, + Name: displayName(appPath, info), + Icon: iconPath(appPath, info), + Exec: appPath, // a bundle path here, not a shell command line + }, true +} + +// handlesHTTP requires both http and https, with neither opted out via lsHandlerRankNone +func handlesHTTP(types []bundleURLType) bool { + var hasHTTP, hasHTTPS bool + for _, urlType := range types { + if strings.EqualFold(urlType.Role, lsHandlerRankNone) || strings.EqualFold(urlType.Rank, lsHandlerRankNone) { + continue + } + if slices.Contains(urlType.Schemes, "http") { + hasHTTP = true + } + if slices.Contains(urlType.Schemes, "https") { + hasHTTPS = true + } + } + return hasHTTP && hasHTTPS +} + +func displayName(appPath string, info bundleInfo) string { + if info.BundleDisplayName != "" { + return info.BundleDisplayName + } + if info.BundleName != "" { + return info.BundleName + } + return strings.TrimSuffix(filepath.Base(appPath), ".app") +} + +func iconPath(appPath string, info bundleInfo) string { + icon := info.BundleIconFile + if icon == "" { + icon = info.BundleIconName + } + if icon == "" { + return "" + } + if filepath.Ext(icon) == "" { + // CFBundleIconFile conventionally omits the extension. + icon += ".icns" + } + return filepath.Join(appPath, "Contents", "Resources", icon) +} diff --git a/internal/browserscan/browserscan_darwin_test.go b/internal/browserscan/browserscan_darwin_test.go new file mode 100644 index 0000000..eb1bfea --- /dev/null +++ b/internal/browserscan/browserscan_darwin_test.go @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build darwin + +package browserscan + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/alyraffauf/switchyard/internal/browser" +) + +// browserPlist is a minimal Info.plist for an app that handles HTTP(S). +const browserPlist = ` + + + + CFBundleIdentifier + com.example.testbrowser + CFBundleName + Test Browser + CFBundleIconFile + test-browser + CFBundleURLTypes + + + CFBundleURLSchemes + + http + https + + + + + +` + +// writeAppBundle writes an Info.plist at dir/.app/Contents/Info.plist. +func writeAppBundle(t *testing.T, dir, name, contents string) string { + t.Helper() + appPath := filepath.Join(dir, name+".app") + contentsDir := filepath.Join(appPath, "Contents") + if err := os.MkdirAll(contentsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(contentsDir, "Info.plist"), []byte(contents), 0o644); err != nil { + t.Fatalf("write Info.plist: %v", err) + } + return appPath +} + +// isolatedPaths overrides app discovery to return exactly paths, in order, +// fully isolating the scan from the host's real installed apps. +func isolatedPaths(t *testing.T, paths ...string) { + t.Helper() + originalAppPaths := appPaths + appPaths = func() []string { return paths } + t.Cleanup(func() { appPaths = originalAppPaths }) +} + +func browserIDs(browsers []browser.Browser) []string { + browserIdentifiers := make([]string, len(browsers)) + for i, installedBrowser := range browsers { + browserIdentifiers[i] = installedBrowser.ID + } + return browserIdentifiers +} + +func TestInstalledIncludesBrowserAndFields(t *testing.T) { + dir := t.TempDir() + appPath := writeAppBundle(t, dir, "Test Browser", browserPlist) + isolatedPaths(t, appPath) + + got := Installed() + if len(got) != 1 { + t.Fatalf("got %d browsers, want 1: %v", len(got), browserIDs(got)) + } + installed := got[0] + if installed.ID != "com.example.testbrowser" || installed.Name != "Test Browser" { + t.Errorf("unexpected fields: %+v", installed) + } + wantIcon := filepath.Join(dir, "Test Browser.app", "Contents", "Resources", "test-browser.icns") + if installed.Icon != wantIcon { + t.Errorf("icon: got %q, want %q", installed.Icon, wantIcon) + } + wantExec := filepath.Join(dir, "Test Browser.app") + if installed.Exec != wantExec { + t.Errorf("exec: got %q, want %q", installed.Exec, wantExec) + } + if len(installed.Actions) != 0 { + t.Errorf("expected no actions, got %v", installed.Actions) + } +} + +func TestInstalledFiltering(t *testing.T) { + tests := []struct { + name string + contents string + }{ + { + name: "http only, no https", + contents: plistWithURLTypes(` + + CFBundleURLSchemes + http + `), + }, + { + name: "no url types at all", + contents: plistWithURLTypes(""), + }, + { + name: "handler rank none", + contents: plistWithURLTypes(` + + CFBundleURLSchemes + httphttps + LSHandlerRank + None + `), + }, + { + name: "type role none", + contents: plistWithURLTypes(` + + CFBundleURLSchemes + httphttps + CFBundleTypeRole + None + `), + }, + { + name: "ui element agent", + contents: ` + + + + CFBundleIdentifier + com.example.testbrowser + LSUIElement + + CFBundleURLTypes + + + CFBundleURLSchemes + httphttps + + + + +`, + }, + { + name: "missing bundle identifier", + contents: ` + + + + CFBundleURLTypes + + + CFBundleURLSchemes + httphttps + + + + +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + appPath := writeAppBundle(t, dir, "Candidate", tt.contents) + isolatedPaths(t, appPath) + + if got := Installed(); len(got) != 0 { + t.Errorf("expected entry excluded, got %v", browserIDs(got)) + } + }) + } +} + +func TestInstalledExcludesSwitchyardFamily(t *testing.T) { + dir := t.TempDir() + switchyardPath := writeAppBundle(t, dir, "Switchyard", plistForID("io.github.alyraffauf.Switchyard")) + switchyardDevelPath := writeAppBundle(t, dir, "Switchyard Devel", plistForID("io.github.alyraffauf.Switchyard.Devel")) + realBrowserPath := writeAppBundle(t, dir, "Real Browser", plistForID("com.example.realbrowser")) + isolatedPaths(t, switchyardPath, switchyardDevelPath, realBrowserPath) + + got := browserIDs(Installed()) + want := []string{"com.example.realbrowser"} + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestInstalledDedupesByBundleID(t *testing.T) { + dir := t.TempDir() + // Same bundle ID reported twice; the first one found wins. + first := writeAppBundle(t, dir, "First Browser", plistForID("com.example.browser")) + second := writeAppBundle(t, dir, "Second Browser", plistForID("com.example.browser")) + isolatedPaths(t, first, second) + + got := Installed() + if len(got) != 1 { + t.Fatalf("got %d browsers, want 1: %v", len(got), browserIDs(got)) + } + if got[0].Name != "First Browser" { + t.Errorf("got %q, want %q", got[0].Name, "First Browser") + } +} + +func TestInstalledSortedByName(t *testing.T) { + dir := t.TempDir() + charliePath := writeAppBundle(t, dir, "Charlie", namedPlistForID("com.example.charlie", "Charlie")) + alicePath := writeAppBundle(t, dir, "Alice", namedPlistForID("com.example.alice", "Alice")) + bobPath := writeAppBundle(t, dir, "Bob", namedPlistForID("com.example.bob", "Bob")) + isolatedPaths(t, charliePath, alicePath, bobPath) + + got := []string{} + for _, installedBrowser := range Installed() { + got = append(got, installedBrowser.Name) + } + want := []string{"Alice", "Bob", "Charlie"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindBrowser(t *testing.T) { + dir := t.TempDir() + appPath := writeAppBundle(t, dir, "Test Browser", browserPlist) + isolatedPaths(t, appPath) + + got, ok := Find("com.example.testbrowser") + if !ok { + t.Fatal("Find returned false") + } + if got.Name != "Test Browser" { + t.Errorf("unexpected browser: %+v", got) + } +} + +func TestFindMissingOrRejected(t *testing.T) { + dir := t.TempDir() + appPath := writeAppBundle(t, dir, "Not A Browser", plistWithURLTypes("")) + isolatedPaths(t, appPath) + + if got, ok := Find("com.example.testbrowser"); ok { + t.Errorf("Find returned %+v, want false", got) + } + if got, ok := Find("missing.id"); ok { + t.Errorf("Find returned %+v, want false", got) + } +} + +func TestListDesktopActionsIsAlwaysEmpty(t *testing.T) { + if got := ListDesktopActions("com.example.testbrowser"); got != nil { + t.Errorf("got %v, want nil", got) + } +} + +func plistWithURLTypes(urlTypesXML string) string { + return ` + + + + CFBundleIdentifier + com.example.testbrowser + CFBundleURLTypes + ` + urlTypesXML + ` + + +` +} + +func plistForID(id string) string { + return namedPlistForID(id, "") +} + +func namedPlistForID(id, name string) string { + nameXML := "" + if name != "" { + nameXML = "\tCFBundleName\n\t" + name + "\n" + } + return ` + + + + CFBundleIdentifier + ` + id + ` +` + nameXML + ` CFBundleURLTypes + + + CFBundleURLSchemes + httphttps + + + + +` +} diff --git a/internal/browserscan/browserscan_test.go b/internal/browserscan/browserscan_test.go index 9e5a5b9..66a82c9 100644 --- a/internal/browserscan/browserscan_test.go +++ b/internal/browserscan/browserscan_test.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browserscan import ( diff --git a/internal/browserscan/common.go b/internal/browserscan/common.go new file mode 100644 index 0000000..690fcf8 --- /dev/null +++ b/internal/browserscan/common.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package browserscan + +import ( + "cmp" + "slices" + "strings" + + "github.com/alyraffauf/switchyard/internal/browser" +) + +// A browser switcher must never list itself, across all packaged variants. +const selfIDPrefix = "io.github.alyraffauf.Switchyard" + +// isSelf reports whether id belongs to Switchyard itself. +// The id is a desktop file ID on Linux and a bundle identifier on macOS. +func isSelf(id string) bool { + return id == "" || strings.HasPrefix(id, selfIDPrefix) +} + +// sortByName sorts browsers in place. +func sortByName(browsers []browser.Browser) { + slices.SortFunc(browsers, func(first, second browser.Browser) int { + return cmp.Compare(first.Name, second.Name) + }) +} diff --git a/internal/browserscan/locale.go b/internal/browserscan/locale.go index 6236b55..f5fa6cd 100644 --- a/internal/browserscan/locale.go +++ b/internal/browserscan/locale.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browserscan import ( @@ -20,12 +22,15 @@ func localizedString(file *desktopfile.File, section, key string) string { } func localizedActions(file *desktopfile.File) []browser.Action { - actions := file.Actions() - for i := range actions { - section := desktopfile.ActionSectionStart + actions[i].ID - if name := localizedString(file, section, "Name"); name != "" { - actions[i].Name = name + desktopActions := file.Actions() + actions := make([]browser.Action, len(desktopActions)) + for i, action := range desktopActions { + name := action.Name + section := desktopfile.ActionSectionStart + action.ID + if localized := localizedString(file, section, "Name"); localized != "" { + name = localized } + actions[i] = browser.Action{ID: action.ID, Name: name, Exec: action.Exec} } return actions } diff --git a/internal/browserscan/locale_test.go b/internal/browserscan/locale_test.go index 8311ea4..0c5c115 100644 --- a/internal/browserscan/locale_test.go +++ b/internal/browserscan/locale_test.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//go:build !darwin + package browserscan import ( diff --git a/internal/browserscan/nsworkspace_darwin.m b/internal/browserscan/nsworkspace_darwin.m new file mode 100644 index 0000000..b095545 --- /dev/null +++ b/internal/browserscan/nsworkspace_darwin.m @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#import +#import + +// Returns every app path registered to open https:// URLs, newline-joined, +// or NULL if there are none. Caller must free() the result. +char *browserscan_nsworkspace_app_paths(void) { + @autoreleasepool { + NSURL *probe = [NSURL URLWithString:@"https://example.com"]; + NSArray *appURLs = + [[NSWorkspace sharedWorkspace] URLsForApplicationsToOpenURL:probe]; + + if (appURLs.count == 0) { + return NULL; + } + + NSMutableArray *paths = [NSMutableArray array]; + for (NSURL *url in appURLs) { + [paths addObject:url.path]; + } + NSString *joined = [paths componentsJoinedByString:@"\n"]; + // Copy out of the autorelease pool so the caller owns the returned memory. + return strdup(joined.UTF8String); + } +} -- 2.51.2