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.

replace manual desktop file parsing with goxdgdesktop

Aly Raffauf (Jul 5, 2026, 10:52 AM EDT) ecb3e2b9 e995f8d5

+59 -181
+44
flatpak/go-modules.json
··· 22 22 }, 23 23 { 24 24 "type": "file", 25 + "url": "https://proxy.golang.org/github.com/alyraffauf/goxdgdesktop/@v/v0.1.0.info", 26 + "sha256": "a187d754282645dfc1ec9dd33c05443367c9cd69d3c26d3a10092672cc653f2a", 27 + "dest": "go/pkg/mod/cache/download/github.com/alyraffauf/goxdgdesktop/@v", 28 + "dest-filename": "v0.1.0.info" 29 + }, 30 + { 31 + "type": "file", 32 + "url": "https://proxy.golang.org/github.com/alyraffauf/goxdgdesktop/@v/v0.1.0.mod", 33 + "sha256": "1892feb94f647ff5966e99233b5e87753690359f7802664f35b9d59bb614064e", 34 + "dest": "go/pkg/mod/cache/download/github.com/alyraffauf/goxdgdesktop/@v", 35 + "dest-filename": "v0.1.0.mod" 36 + }, 37 + { 38 + "type": "file", 39 + "url": "https://proxy.golang.org/github.com/alyraffauf/goxdgdesktop/@v/v0.1.0.zip", 40 + "sha256": "7a832fed24c398bc6628fe45a65bfd9f0020cfebbb30fc9a335df38f4ba018c8", 41 + "dest": "go/pkg/mod/cache/download/github.com/alyraffauf/goxdgdesktop/@v", 42 + "dest-filename": "v0.1.0.zip" 43 + }, 44 + { 45 + "type": "file", 25 46 "url": "https://proxy.golang.org/github.com/diamondburned/gotk4-adwaita/pkg/@v/v0.0.0-20250703085337-e94555b846b6.info", 26 47 "sha256": "77a90d23b9ccd4355f39b6fa852ea3b26b8a6791b3ee2799c87f92855c64f8b6", 27 48 "dest": "go/pkg/mod/cache/download/github.com/diamondburned/gotk4-adwaita/pkg/@v", ··· 64 85 }, 65 86 { 66 87 "type": "file", 88 + "url": "https://proxy.golang.org/github.com/kballard/go-shellquote/@v/v0.0.0-20180428030007-95032a82bc51.info", 89 + "sha256": "d6f6034c911ee4f7583394452662b95695e9c3807a48e9292e48b52d252d79d5", 90 + "dest": "go/pkg/mod/cache/download/github.com/kballard/go-shellquote/@v", 91 + "dest-filename": "v0.0.0-20180428030007-95032a82bc51.info" 92 + }, 93 + { 94 + "type": "file", 95 + "url": "https://proxy.golang.org/github.com/kballard/go-shellquote/@v/v0.0.0-20180428030007-95032a82bc51.mod", 96 + "sha256": "49a2d6c73f667dc0f46322a0d6acfcba14209bfb76ef7e622d61e1091962ff34", 97 + "dest": "go/pkg/mod/cache/download/github.com/kballard/go-shellquote/@v", 98 + "dest-filename": "v0.0.0-20180428030007-95032a82bc51.mod" 99 + }, 100 + { 101 + "type": "file", 102 + "url": "https://proxy.golang.org/github.com/kballard/go-shellquote/@v/v0.0.0-20180428030007-95032a82bc51.zip", 103 + "sha256": "ae4cb7b097dc4eb0c248dff00ed3bbf0f36984c4162ad1d615266084e58bd6cc", 104 + "dest": "go/pkg/mod/cache/download/github.com/kballard/go-shellquote/@v", 105 + "dest-filename": "v0.0.0-20180428030007-95032a82bc51.zip" 106 + }, 107 + { 108 + "type": "file", 67 109 "url": "https://proxy.golang.org/github.com/pelletier/go-toml/v2/@v/v2.2.4.info", 68 110 "sha256": "5a5d616e85f301cb0123bd18dbbedafca624c402a91771db1079dd3e4a9a6d14", 69 111 "dest": "go/pkg/mod/cache/download/github.com/pelletier/go-toml/v2/@v", ··· 129 171 "type": "shell", 130 172 "commands": [ 131 173 "mkdir -p 'go/pkg/mod/cache/download/github.com/!karpeles!lab/weak/@v' && printf '%s\\n' h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g= > 'go/pkg/mod/cache/download/github.com/!karpeles!lab/weak/@v/v0.1.1.ziphash'", 174 + "mkdir -p go/pkg/mod/cache/download/github.com/alyraffauf/goxdgdesktop/@v && printf '%s\\n' h1:n5+3AjF2ntvON6W0nN4O5bHDmcjYuFtKVCwBr6ekqj0= > go/pkg/mod/cache/download/github.com/alyraffauf/goxdgdesktop/@v/v0.1.0.ziphash", 132 175 "mkdir -p go/pkg/mod/cache/download/github.com/diamondburned/gotk4-adwaita/pkg/@v && printf '%s\\n' h1:WzOC3KtvrC1hJMz3fbJBg0Ye50nt4Tafor+a/bBHNEA= > go/pkg/mod/cache/download/github.com/diamondburned/gotk4-adwaita/pkg/@v/v0.0.0-20250703085337-e94555b846b6.ziphash", 133 176 "mkdir -p go/pkg/mod/cache/download/github.com/diamondburned/gotk4/pkg/@v && printf '%s\\n' h1:dN2jYYZ71hFhoKFSn24pQdKWLZb/XDydBt8pEIkFjJo= > go/pkg/mod/cache/download/github.com/diamondburned/gotk4/pkg/@v/v0.3.2-0.20250703063411-16654385f59a.ziphash", 177 + "mkdir -p go/pkg/mod/cache/download/github.com/kballard/go-shellquote/@v && printf '%s\\n' h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= > go/pkg/mod/cache/download/github.com/kballard/go-shellquote/@v/v0.0.0-20180428030007-95032a82bc51.ziphash", 134 178 "mkdir -p go/pkg/mod/cache/download/github.com/pelletier/go-toml/v2/@v && printf '%s\\n' h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= > go/pkg/mod/cache/download/github.com/pelletier/go-toml/v2/@v/v2.2.4.ziphash", 135 179 "mkdir -p go/pkg/mod/cache/download/go4.org/unsafe/assume-no-moving-gc/@v && printf '%s\\n' h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4= > go/pkg/mod/cache/download/go4.org/unsafe/assume-no-moving-gc/@v/v0.0.0-20231121144256-b99613f794b6.ziphash", 136 180 "mkdir -p go/pkg/mod/cache/download/golang.org/x/sync/@v && printf '%s\\n' h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= > go/pkg/mod/cache/download/golang.org/x/sync/@v/v0.0.0-20210220032951-036812b2e83c.ziphash"
+2
go.mod
··· 5 5 toolchain go1.24.11 6 6 7 7 require ( 8 + github.com/alyraffauf/goxdgdesktop v0.1.0 8 9 github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 9 10 github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a 10 11 github.com/pelletier/go-toml/v2 v2.2.4 ··· 12 13 13 14 require ( 14 15 github.com/KarpelesLab/weak v0.1.1 // indirect 16 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 15 17 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect 16 18 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect 17 19 )
+4
go.sum
··· 1 1 github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g= 2 2 github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw= 3 + github.com/alyraffauf/goxdgdesktop v0.1.0 h1:n5+3AjF2ntvON6W0nN4O5bHDmcjYuFtKVCwBr6ekqj0= 4 + github.com/alyraffauf/goxdgdesktop v0.1.0/go.mod h1:K91gqx5usBl0hjhNUxHcBSRaOaU8gaggPxfKpCnTXzQ= 3 5 github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 h1:WzOC3KtvrC1hJMz3fbJBg0Ye50nt4Tafor+a/bBHNEA= 4 6 github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6/go.mod h1:ZzYiyPe0TqsukfPHi0sK/WwKzm0wIJdSRylLnuvAZNw= 5 7 github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a h1:dN2jYYZ71hFhoKFSn24pQdKWLZb/XDydBt8pEIkFjJo= 6 8 github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q= 9 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 10 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 7 11 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 8 12 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 9 13 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4=
+1 -4
src/config.go
··· 152 152 } 153 153 } 154 154 155 - // hostCommand creates a command that runs on the host system when in flatpak, 156 - // or directly otherwise 155 + // hostCommand runs commands on the host when Switchyard is sandboxed by Flatpak. 157 156 func hostCommand(name string, args ...string) *exec.Cmd { 158 157 if os.Getenv("FLATPAK_ID") != "" { 159 158 hostArgs := append([]string{"--host", name}, args...) ··· 163 162 } 164 163 165 164 func isDefaultBrowser() bool { 166 - // Check if Switchyard is the default browser using xdg-settings 167 165 cmd := hostCommand("xdg-settings", "get", "default-web-browser") 168 166 169 167 output, err := cmd.Output() ··· 177 175 } 178 176 179 177 func setAsDefaultBrowser() error { 180 - // Set Switchyard as the default browser using xdg-settings 181 178 desktopFile := getAppID() + ".desktop" 182 179 cmd := hostCommand("xdg-settings", "set", "default-web-browser", desktopFile) 183 180 return cmd.Run()
+6 -149
src/desktop_actions.go
··· 3 3 package main 4 4 5 5 import ( 6 - "bufio" 7 - "os" 8 - "strings" 9 - 6 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 7 + "github.com/alyraffauf/goxdgdesktop/xdg" 10 8 "github.com/diamondburned/gotk4/pkg/gio/v2" 11 9 ) 12 10 13 - type DesktopAction struct { 14 - ID string // action ID (e.g., "new-private-window") 15 - Name string // display name (e.g., "New Private Window") 16 - Exec string // Exec command line for this action 17 - } 18 - 19 - // Manually find desktop files according to XDG directories 20 - func findDesktopFile(appID string) string { 21 - var dataDirs []string 22 - 23 - // XDG_DATA_HOME (default: ~/.local/share) 24 - dataHome := os.Getenv("XDG_DATA_HOME") 25 - if dataHome == "" { 26 - dataHome = os.Getenv("HOME") + "/.local/share" 27 - } 28 - dataDirs = append(dataDirs, dataHome) 29 - 30 - // XDG_DATA_DIRS (default: /usr/local/share:/usr/share) 31 - dataDirsEnv := os.Getenv("XDG_DATA_DIRS") 32 - if dataDirsEnv == "" { 33 - dataDirsEnv = "/usr/local/share:/usr/share" 34 - } 35 - for _, dir := range strings.Split(dataDirsEnv, ":") { 36 - if dir != "" { 37 - dataDirs = append(dataDirs, dir) 38 - } 39 - } 40 - 41 - // Flatpaks 42 - dataDirs = append(dataDirs, "/var/lib/flatpak/exports/share") 43 - if home := os.Getenv("HOME"); home != "" { 44 - dataDirs = append(dataDirs, home+"/.local/share/flatpak/exports/share") 45 - } 46 - 47 - // Search for appID in each data directory's applications subdirectory 48 - for _, dataDir := range dataDirs { 49 - path := dataDir + "/applications/" + appID 50 - if _, err := os.Stat(path); err == nil { 51 - return path 52 - } 53 - } 54 - 55 - return "" 56 - } 11 + type DesktopAction = desktopfile.Action 57 12 58 - /** ListDesktopActions returns available actions for an AppInfo by parsing its desktop file directly. For some reason, AppInfo does not expose actions. So we either call g_desktop_app_info_list_actions, which is not bound to Go, or parse it ourselves. I'd prefer to not use CGo. **/ 59 13 func ListDesktopActions(appInfo *gio.AppInfo) []DesktopAction { 60 14 if appInfo == nil { 61 15 return nil 62 16 } 63 17 64 - // Get the desktop file path 65 - // For GIO AppInfo, we can use the ID to find the desktop file 66 18 appID := appInfo.ID() 67 19 if appID == "" { 68 20 return nil 69 21 } 70 22 71 - // Find the desktop file using XDG Base Directory specification 72 - desktopFilePath := findDesktopFile(appID) 23 + desktopFilePath := xdg.FindDesktopFile(appID) 73 24 if desktopFilePath == "" { 74 25 return nil 75 26 } 76 27 77 - // Parse the desktop file 78 - return parseDesktopFileActions(desktopFilePath) 79 - } 80 - 81 - // parseDesktopFileActions parses a desktop file and extracts all actions. 82 - // Sketchier than what Glib gives us, but Glib doesn't give us this anyway. 83 - // Desktop files are INI-format with sections like: 84 - // 85 - // [Desktop Entry] 86 - // Actions=new-window;new-private-window; 87 - // 88 - // [Desktop Action new-window] 89 - // Name=New Window 90 - // Exec=firefox --new-window %u 91 - // 92 - // [Desktop Action new-private-window] 93 - // Name=New Private Window 94 - // Exec=firefox --private-window %u 95 - func parseDesktopFileActions(path string) []DesktopAction { 96 - file, err := os.Open(path) 28 + file, err := desktopfile.Read(desktopFilePath) 97 29 if err != nil { 98 30 return nil 99 31 } 100 - defer file.Close() 101 - 102 - var actions []DesktopAction 103 - var currentSection string 104 - var currentActionID string 105 - var currentActionName string 106 - var currentActionExec string 107 - 108 - scanner := bufio.NewScanner(file) 109 - for scanner.Scan() { 110 - line := strings.TrimSpace(scanner.Text()) 111 - 112 - // Skip comments and empty lines 113 - if line == "" || strings.HasPrefix(line, "#") { 114 - continue 115 - } 116 - 117 - // Section headers 118 - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { 119 - // Save previous action if we were in one 120 - if currentSection != "" && strings.HasPrefix(currentSection, "Desktop Action ") { 121 - if currentActionID != "" && currentActionName != "" && currentActionExec != "" { 122 - /** Only add if exec contains %u or %U (accepts URLs) 123 - This is commented because it breaks many Chromium-based browers completely, and even some Firefox-based brwosers allow passing URLs to actions unequiped to deal with them (e.g. LibreWolf). Looks less polished for the user, but works in more cases. **/ 124 - 125 - // if strings.Contains(currentActionExec, "%u") || strings.Contains(currentActionExec, "%U") { 126 - actions = append(actions, DesktopAction{ 127 - ID: currentActionID, 128 - Name: currentActionName, 129 - Exec: currentActionExec, 130 - }) 131 - // } 132 - } 133 - currentActionID = "" 134 - currentActionName = "" 135 - currentActionExec = "" 136 - } 137 - 138 - currentSection = strings.TrimSpace(line[1 : len(line)-1]) 139 - 140 - // Extract action ID from section name 141 - if strings.HasPrefix(currentSection, "Desktop Action ") { 142 - currentActionID = strings.TrimPrefix(currentSection, "Desktop Action ") 143 - } 144 - continue 145 - } 146 - 147 - // Key-value pairs 148 - if strings.Contains(line, "=") { 149 - parts := strings.SplitN(line, "=", 2) 150 - key := strings.TrimSpace(parts[0]) 151 - value := strings.TrimSpace(parts[1]) 152 - 153 - // In [Desktop Action ...], look for Name and Exec 154 - if strings.HasPrefix(currentSection, "Desktop Action ") { 155 - if key == "Name" { 156 - currentActionName = value 157 - } else if key == "Exec" { 158 - currentActionExec = value 159 - } 160 - } 161 - } 162 - } 163 - 164 - // Don't forget to save the last action if file ends while in an action section 165 - if currentSection != "" && strings.HasPrefix(currentSection, "Desktop Action ") { 166 - if currentActionID != "" && currentActionName != "" && currentActionExec != "" { 167 - actions = append(actions, DesktopAction{ 168 - ID: currentActionID, 169 - Name: currentActionName, 170 - Exec: currentActionExec, 171 - }) 172 - } 173 - } 174 - 175 - return actions 32 + return file.Actions() 176 33 }
+2 -22
src/launch.go
··· 7 7 "os/exec" 8 8 "strings" 9 9 10 + "github.com/alyraffauf/goxdgdesktop/desktopexec" 10 11 "github.com/diamondburned/gotk4/pkg/gdk/v4" 11 12 "github.com/diamondburned/gotk4/pkg/gio/v2" 12 13 ) ··· 14 15 // launchCommand executes a desktop file command line with URL substitution 15 16 // and proper activation token handling for window raising on Wayland. 16 17 func launchCommand(cmdline, url string, appInfo *gio.AppInfo) error { 17 - hasFieldCode := strings.Contains(cmdline, "%u") || 18 - strings.Contains(cmdline, "%U") || 19 - strings.Contains(cmdline, "%f") || 20 - strings.Contains(cmdline, "%F") 21 - 22 - quotedURL := shellQuoteURL(url) 23 - 24 - // Replace %u, %U, %f, %F with quoted URL 25 - cmdline = strings.ReplaceAll(cmdline, "%u", quotedURL) 26 - cmdline = strings.ReplaceAll(cmdline, "%U", quotedURL) 27 - cmdline = strings.ReplaceAll(cmdline, "%f", quotedURL) 28 - cmdline = strings.ReplaceAll(cmdline, "%F", quotedURL) 29 - 30 - // Remove other field codes 31 - for _, code := range []string{"%i", "%c", "%k"} { 32 - cmdline = strings.ReplaceAll(cmdline, code, "") 33 - } 34 - 35 - // Chromium-based desktop files are essentially broken, they include no logic for a URL to pass using typical means. In this case, we work around by appending our URL. 36 - if !hasFieldCode && url != "" { 37 - cmdline = cmdline + " " + quotedURL 38 - } 18 + cmdline = desktopexec.SubstituteOrAppendURL(cmdline, url) 39 19 40 20 if strings.TrimSpace(cmdline) == "" { 41 21 return nil
-6
src/url.go
··· 54 54 } 55 55 } 56 56 57 - // shellQuoteURL returns a shell-safe quoted version of the URL. 58 - // This prevents shell metacharacters like & from being interpreted. 59 - func shellQuoteURL(s string) string { 60 - return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" 61 - } 62 - 63 57 func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { 64 58 u, err := url.Parse(rawURL) 65 59 if err != nil {