diff --git a/internal/browserscan/browserscan.go b/internal/browserscan/browserscan.go new file mode 100644 index 0000000..73f0700 --- /dev/null +++ b/internal/browserscan/browserscan.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// 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" + "path/filepath" + "slices" + "strings" + + "github.com/alyraffauf/goxdgdesktop/desktopfile" + "github.com/alyraffauf/goxdgdesktop/xdg" + "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", +} + +// applicationsDirs is a variable so tests can override the search path. +var applicationsDirs = xdg.ApplicationsDirs + +// 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 { + var browsers []browser.Browser + seen := map[string]bool{} + + for _, dir := range applicationsDirs() { + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // Skip missing/unreadable dirs rather than aborting the walk. + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".desktop") { + return nil + } + + id := desktopID(dir, path) + // First dir to define an ID wins. Mark seen even on rejection, so a + // shadowed copy in a lower-priority dir can't resurface. + if seen[id] { + return nil + } + seen[id] = true + + if strings.HasPrefix(id, selfIDPrefix) { + return nil + } + + if parsed, ok := parseBrowser(id, path); ok { + browsers = append(browsers, parsed) + } + return nil + }) + } + + slices.SortFunc(browsers, func(first, second browser.Browser) int { + return cmp.Compare(first.Name, second.Name) + }) + + return browsers +} + +// desktopID is path's desktop-file ID relative to dir, with separators replaced +// by "-" per the spec (sub/foo.desktop -> sub-foo.desktop). +func desktopID(dir, path string) string { + rel, err := filepath.Rel(dir, path) + if err != nil { + rel = filepath.Base(path) + } + return strings.ReplaceAll(filepath.ToSlash(rel), "/", "-") +} + +// parseBrowser returns the Browser at path, or ok=false if it isn't a +// displayable HTTP(S) browser. +func parseBrowser(id, path string) (browser.Browser, bool) { + file, err := desktopfile.Read(path) + if err != nil { + return browser.Browser{}, false + } + + if typ, _ := file.Get(desktopfile.EntrySection, "Type"); typ != "Application" { + return browser.Browser{}, false + } + if isTrue(file, "Hidden") || isTrue(file, "NoDisplay") { + return browser.Browser{}, false + } + if !handlesHTTP(file) { + return browser.Browser{}, false + } + + name, _ := file.Get(desktopfile.EntrySection, "Name") + icon, _ := file.Get(desktopfile.EntrySection, "Icon") + exec, _ := file.Get(desktopfile.EntrySection, "Exec") + + return browser.Browser{ + ID: id, + Name: name, + Icon: icon, + Exec: exec, + }, true +} + +func isTrue(file *desktopfile.File, key string) bool { + value, _ := file.Get(desktopfile.EntrySection, key) + return strings.EqualFold(value, "true") +} + +func handlesHTTP(file *desktopfile.File) bool { + mimeType, ok := file.Get(desktopfile.EntrySection, "MimeType") + if !ok { + return false + } + for mime := range strings.SplitSeq(mimeType, ";") { + if slices.Contains(httpSchemeHandlers, strings.TrimSpace(mime)) { + return true + } + } + return false +} diff --git a/internal/browserscan/browserscan_test.go b/internal/browserscan/browserscan_test.go new file mode 100644 index 0000000..e8edbfb --- /dev/null +++ b/internal/browserscan/browserscan_test.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package browserscan + +import ( + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/switchyard/internal/browser" +) + +// browserEntry is a minimal desktop file that handles HTTP(S). +const browserEntry = `[Desktop Entry] +Type=Application +Name=Test Browser +Icon=test-browser +Exec=test-browser %u +MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https; +` + +// writeDesktop writes a .desktop file at dir/ (rel may contain slashes for +// nested subdirs), creating parent dirs as needed. +func writeDesktop(t *testing.T, dir, rel, contents string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + +// isolatedDirs overrides the applications search path with two temp dirs (a +// higher-priority "home" dir and a lower-priority "system" dir), fully isolating +// the scan from the host's real desktop files. It returns those dirs. +func isolatedDirs(t *testing.T) (home, system string) { + t.Helper() + home = filepath.Join(t.TempDir(), "applications") + system = filepath.Join(t.TempDir(), "applications") + orig := applicationsDirs + applicationsDirs = func() []string { return []string{home, system} } + t.Cleanup(func() { applicationsDirs = orig }) + return home, system +} + +func ids(browsers []browser.Browser) []string { + out := make([]string, len(browsers)) + for i, b := range browsers { + out[i] = b.ID + } + return out +} + +func TestInstalledIncludesBrowserAndFields(t *testing.T) { + home, _ := isolatedDirs(t) + writeDesktop(t, home, "test-browser.desktop", browserEntry) + + got := Installed() + if len(got) != 1 { + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) + } + want := browser.Browser{ + ID: "test-browser.desktop", + Name: "Test Browser", + Icon: "test-browser", + Exec: "test-browser %u", + } + if got[0] != want { + t.Errorf("got %+v, want %+v", got[0], want) + } +} + +func TestInstalledFiltering(t *testing.T) { + tests := []struct { + name string + contents string + }{ + { + name: "no http mimetype", + contents: `[Desktop Entry] +Type=Application +Name=Not A Browser +MimeType=text/html; +`, + }, + { + name: "no mimetype at all", + contents: `[Desktop Entry] +Type=Application +Name=Plain App +`, + }, + { + name: "nodisplay", + contents: `[Desktop Entry] +Type=Application +Name=Hidden Browser +NoDisplay=true +MimeType=x-scheme-handler/http; +`, + }, + { + name: "hidden", + contents: `[Desktop Entry] +Type=Application +Name=Hidden Browser +Hidden=TRUE +MimeType=x-scheme-handler/http; +`, + }, + { + name: "wrong type", + contents: `[Desktop Entry] +Type=Link +Name=Link Entry +URL=https://example.com +MimeType=x-scheme-handler/http; +`, + }, + { + name: "missing type", + contents: `[Desktop Entry] +Name=No Type +MimeType=x-scheme-handler/http; +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home, _ := isolatedDirs(t) + writeDesktop(t, home, "candidate.desktop", tt.contents) + + if got := Installed(); len(got) != 0 { + t.Errorf("expected entry excluded, got %v", ids(got)) + } + }) + } +} + +func TestInstalledExcludesSwitchyardFamily(t *testing.T) { + home, _ := isolatedDirs(t) + // Both the stable and Devel variants are installed; neither may appear. + writeDesktop(t, home, "io.github.alyraffauf.Switchyard.desktop", browserEntry) + writeDesktop(t, home, "io.github.alyraffauf.Switchyard.Devel.desktop", browserEntry) + writeDesktop(t, home, "real-browser.desktop", browserEntry) + + got := ids(Installed()) + want := []string{"real-browser.desktop"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestInstalledShadowingHomeWins(t *testing.T) { + home, system := isolatedDirs(t) + // Same ID in both dirs with different names; the data-home copy must win. + writeDesktop(t, home, "browser.desktop", `[Desktop Entry] +Type=Application +Name=Home Browser +MimeType=x-scheme-handler/http; +`) + writeDesktop(t, system, "browser.desktop", `[Desktop Entry] +Type=Application +Name=System Browser +MimeType=x-scheme-handler/http; +`) + + got := Installed() + if len(got) != 1 { + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) + } + if got[0].Name != "Home Browser" { + t.Errorf("got %q, want %q", got[0].Name, "Home Browser") + } +} + +func TestInstalledShadowingHiddenHomeSuppressesSystem(t *testing.T) { + home, system := isolatedDirs(t) + // A hidden copy in data-home shadows a valid copy in the system dir. + writeDesktop(t, home, "browser.desktop", `[Desktop Entry] +Type=Application +Name=Home Browser +NoDisplay=true +MimeType=x-scheme-handler/http; +`) + writeDesktop(t, system, "browser.desktop", browserEntry) + + if got := Installed(); len(got) != 0 { + t.Errorf("expected shadowed system copy suppressed, got %v", ids(got)) + } +} + +func TestInstalledNestedSubdirID(t *testing.T) { + home, _ := isolatedDirs(t) + writeDesktop(t, home, "sub/foo.desktop", browserEntry) + + got := Installed() + if len(got) != 1 { + t.Fatalf("got %d browsers, want 1: %v", len(got), ids(got)) + } + if got[0].ID != "sub-foo.desktop" { + t.Errorf("got ID %q, want %q", got[0].ID, "sub-foo.desktop") + } +} + +func TestInstalledSortedByName(t *testing.T) { + home, _ := isolatedDirs(t) + writeDesktop(t, home, "charlie.desktop", `[Desktop Entry] +Type=Application +Name=Charlie +MimeType=x-scheme-handler/http; +`) + writeDesktop(t, home, "alice.desktop", `[Desktop Entry] +Type=Application +Name=Alice +MimeType=x-scheme-handler/http; +`) + writeDesktop(t, home, "bob.desktop", `[Desktop Entry] +Type=Application +Name=Bob +MimeType=x-scheme-handler/https; +`) + + got := ids(Installed()) + want := []string{"alice.desktop", "bob.desktop", "charlie.desktop"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +}