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.

add switchyard:// URI scheme (#21)

* docs: add URI scheme

* fixup fmt

* use net/url for better url parsing, handle special schemas, improve tests

* main: handle xdg-open for rejected URLs

* url: switch to allowlist for URI schemes

* fixup tests

* data: provide switchyard:// scheme

* add handleSwitchyardURL

* add tests for switchyard:// urls

* data/metainfo: improve summary

* README.md: document URI scheme

authored by

Aly Raffauf and committed by
GitHub
(Jan 21, 2026, 11:58 PM EST) aa932863 5b72a012

+481 -846
+1
README.md
··· 32 32 - **Rule-based routing**: Automatically open URLs in specific browsers based on powerful patterns. 33 33 - **Multi-condition rules**: Combine multiple conditions with AND/OR logic for precise control. 34 34 - **Multiple pattern types**: Exact Domain, URL Contains, Wildcard, and Regex matching. 35 + - **Custom URI scheme**: Create links that specify browser preferences directly with `switchyard://` URLs. 35 36 - **Quick browser picker**: When no rule matches, choose from your installed browsers with keyboard or mouse. 36 37 - **Keyboard shortcuts**: Press Ctrl+1-9 to instantly select a browser. 37 38 - **Lightweight**: Runs only when needed, no background processes.
+1 -1
data/io.github.alyraffauf.Switchyard.desktop
··· 5 5 Name=Switchyard 6 6 Comment=Rules-based URL router and browser launcher for Linux 7 7 Categories=Network;WebBrowser;GNOME; 8 - MimeType=x-scheme-handler/http;x-scheme-handler/https; 8 + MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/switchyard; 9 9 10 10 Icon=io.github.alyraffauf.Switchyard 11 11 Exec=switchyard %U
+2 -1
data/io.github.alyraffauf.Switchyard.metainfo.xml
··· 3 3 <id>io.github.alyraffauf.Switchyard</id> 4 4 5 5 <name>Switchyard</name> 6 - <summary>Rules-based link router and launcher</summary> 6 + <summary>Rules-based browser launcher</summary> 7 7 <metadata_license>CC0-1.0</metadata_license> 8 8 <project_license>GPL-3.0-or-later</project_license> 9 9 <content_rating type="oars-1.0" /> ··· 22 22 <binary>switchyard</binary> 23 23 <mediatype>x-scheme-handler/http</mediatype> 24 24 <mediatype>x-scheme-handler/https</mediatype> 25 + <mediatype>x-scheme-handler/switchyard</mediatype> 25 26 </provides> 26 27 27 28 <url type="homepage">https://switchyard.aly.codes/</url>
+42
docs/URI Scheme.md
··· 1 + # Switchyard URI Scheme 2 + 3 + Switchyard registers a custom URI scheme that allows links to specify browser preferences directly. This is useful for situations where you want to create links that always open in a specific browser, but don't want a permanent rule. Example use cases include note-taking, to-do apps, etc. 4 + 5 + ## Format 6 + 7 + ``` 8 + switchyard://open?url=<encoded-url>&browser=<desktop-file-id>[,<desktop-file-id>,...] 9 + ``` 10 + 11 + ## Parameters 12 + 13 + - **url** (required): The URL to open, percent-encoded. 14 + - **browser** (optional): A comma-separated list of [desktop file IDs](https://specifications.freedesktop.org/desktop-entry/latest/) in order of preference. Switchyard opens the URL in the first listed browser that is installed. If omitted, standard routing rules apply. 15 + 16 + Desktop file IDs match the `.desktop` filename without the extension (e.g. `org.mozilla.firefox`, `com.google.Chrome`). 17 + 18 + ## Behavior 19 + 20 + 1. Switchyard looks for each browser in the preference list in order. 21 + 2. The URL opens in the first browser found on the system. 22 + 3. If no listed browser is installed, Switchyard displays the browser launcher, allowing the user to select from installed browsers. 23 + 24 + ## Examples 25 + 26 + Open in Firefox: 27 + 28 + ``` 29 + switchyard://open?url=https://example.com&browser=org.mozilla.firefox 30 + ``` 31 + 32 + Open in Firefox if installed, otherwise Chrome: 33 + 34 + ``` 35 + switchyard://open?url=https://example.com&browser=org.mozilla.firefox,com.google.Chrome 36 + ``` 37 + 38 + Percent-encoded special characters: 39 + 40 + ``` 41 + switchyard://open?url=https%3A%2F%2Fexample.com%3Ffoo%3Dbar&browser=org.mozilla.firefox 42 + ```
+2 -2
justfile
··· 52 52 # Run unit tests 53 53 test: 54 54 @echo "Running unit tests..." 55 - go test -v ./src/config_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/validation.go 55 + go test -v ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/validation.go 56 56 57 57 # Run tests with coverage report 58 58 test-coverage: 59 59 @echo "Running tests with coverage..." 60 - go test -coverprofile=coverage.out ./src/config_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/validation.go 60 + go test -coverprofile=coverage.out ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/validation.go 61 61 go tool cover -func=coverage.out 62 62 @echo "" 63 63 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out"
-123
src/config.go
··· 7 7 "os" 8 8 "os/exec" 9 9 "path/filepath" 10 - "regexp" 11 10 "strings" 12 11 13 12 "github.com/pelletier/go-toml/v2" ··· 124 123 } 125 124 return false 126 125 } 127 - } 128 - 129 - func matchesPattern(url, pattern, patternType string) bool { 130 - domain := extractDomain(url) 131 - 132 - switch patternType { 133 - case "domain": 134 - // Exact domain match 135 - return strings.EqualFold(domain, pattern) 136 - case "keyword": 137 - // URL contains text 138 - return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 139 - case "regex": 140 - re, err := regexp.Compile(pattern) 141 - if err != nil { 142 - return false 143 - } 144 - return re.MatchString(url) 145 - case "glob": 146 - return matchGlob(url, pattern) 147 - default: 148 - return false 149 - } 150 - } 151 - 152 - func matchGlob(url, pattern string) bool { 153 - // Extract domain from URL for matching 154 - domain := extractDomain(url) 155 - 156 - // Simple glob matching: * matches any characters 157 - pattern = strings.ReplaceAll(pattern, ".", "\\.") 158 - pattern = strings.ReplaceAll(pattern, "*", ".*") 159 - pattern = "^" + pattern + "$" 160 - 161 - re, err := regexp.Compile(pattern) 162 - if err != nil { 163 - return false 164 - } 165 - 166 - // Match against domain or full URL 167 - return re.MatchString(domain) || re.MatchString(url) 168 - } 169 - 170 - func extractDomain(url string) string { 171 - // Remove protocol 172 - u := url 173 - if idx := strings.Index(u, "://"); idx != -1 { 174 - u = u[idx+3:] 175 - } 176 - // Remove path 177 - if idx := strings.Index(u, "/"); idx != -1 { 178 - u = u[:idx] 179 - } 180 - // Remove port 181 - if idx := strings.Index(u, ":"); idx != -1 { 182 - u = u[:idx] 183 - } 184 - return u 185 - } 186 - 187 - func sanitizeURL(url string) string { 188 - // Trim whitespace 189 - url = strings.TrimSpace(url) 190 - 191 - if url == "" { 192 - return "" 193 - } 194 - 195 - // Handle file:// URIs that GIO sometimes creates from bare domains 196 - // If it's a file:// URI but the path doesn't exist and looks like a domain, convert it 197 - if strings.HasPrefix(url, "file://") { 198 - filePath := strings.TrimPrefix(url, "file://") 199 - 200 - // Check if file actually exists 201 - if _, err := os.Stat(filePath); os.IsNotExist(err) { 202 - // File doesn't exist - might be a bare domain that GIO converted 203 - // Extract just the filename (last component) 204 - lastSlash := strings.LastIndex(filePath, "/") 205 - if lastSlash != -1 { 206 - possibleDomain := filePath[lastSlash+1:] 207 - // Check if it looks like a domain (not a file extension) 208 - // Domain should have multiple parts separated by dots, not just "file.txt" 209 - if strings.Contains(possibleDomain, ".") && !strings.Contains(possibleDomain, " ") { 210 - // Split by dot to check if it looks like a domain vs a filename 211 - parts := strings.Split(possibleDomain, ".") 212 - // A domain typically has at least 2 parts and the TLD is not a file extension 213 - // Common file extensions to reject: .txt, .pdf, .doc, .jpg, etc. 214 - if len(parts) >= 2 { 215 - lastPart := strings.ToLower(parts[len(parts)-1]) 216 - // List of common file extensions to reject 217 - fileExtensions := []string{"txt", "pdf", "doc", "docx", "jpg", "jpeg", "png", "gif", "zip", "tar", "gz"} 218 - isFileExt := false 219 - for _, ext := range fileExtensions { 220 - if lastPart == ext { 221 - isFileExt = true 222 - break 223 - } 224 - } 225 - // If it doesn't look like a file extension, treat as domain 226 - if !isFileExt && len(lastPart) > 1 { 227 - return "https://" + possibleDomain 228 - } 229 - } 230 - } 231 - } 232 - } 233 - // Real file path - reject (browsers handle file:// directly) 234 - return "" 235 - } 236 - 237 - // Reject local file paths (browsers don't need routing for these) 238 - if strings.HasPrefix(url, "/") || strings.HasPrefix(url, ".") { 239 - return "" 240 - } 241 - 242 - // If it already has a scheme, return as-is 243 - if strings.Contains(url, "://") { 244 - return url 245 - } 246 - 247 - // Add https:// prefix for bare domains/URLs 248 - return "https://" + url 249 126 } 250 127 251 128 // hostCommand creates a command that runs on the host system when in flatpak,
-710
src/config_test.go
··· 6 6 "testing" 7 7 ) 8 8 9 - // TestExtractDomain tests URL domain extraction 10 - func TestExtractDomain(t *testing.T) { 11 - tests := []struct { 12 - name string 13 - url string 14 - expected string 15 - }{ 16 - { 17 - name: "simple https url", 18 - url: "https://example.com", 19 - expected: "example.com", 20 - }, 21 - { 22 - name: "https url with path", 23 - url: "https://example.com/path/to/page", 24 - expected: "example.com", 25 - }, 26 - { 27 - name: "https url with port", 28 - url: "https://example.com:8080", 29 - expected: "example.com", 30 - }, 31 - { 32 - name: "https url with port and path", 33 - url: "https://example.com:8080/path", 34 - expected: "example.com", 35 - }, 36 - { 37 - name: "subdomain", 38 - url: "https://sub.example.com", 39 - expected: "sub.example.com", 40 - }, 41 - { 42 - name: "multiple subdomains", 43 - url: "https://deep.sub.example.com/path", 44 - expected: "deep.sub.example.com", 45 - }, 46 - { 47 - name: "http protocol", 48 - url: "http://example.com", 49 - expected: "example.com", 50 - }, 51 - { 52 - name: "no protocol", 53 - url: "example.com", 54 - expected: "example.com", 55 - }, 56 - { 57 - name: "no protocol with path", 58 - url: "example.com/path", 59 - expected: "example.com", 60 - }, 61 - { 62 - name: "url with query params", 63 - url: "https://example.com/path?key=value", 64 - expected: "example.com", 65 - }, 66 - { 67 - name: "url with fragment", 68 - url: "https://example.com/path#section", 69 - expected: "example.com", 70 - }, 71 - } 72 - 73 - for _, tt := range tests { 74 - t.Run(tt.name, func(t *testing.T) { 75 - result := extractDomain(tt.url) 76 - if result != tt.expected { 77 - t.Errorf("extractDomain(%q) = %q, want %q", tt.url, result, tt.expected) 78 - } 79 - }) 80 - } 81 - } 82 - 83 - // TestSanitizeURL tests URL sanitization logic 84 - func TestSanitizeURL(t *testing.T) { 85 - tests := []struct { 86 - name string 87 - url string 88 - expected string 89 - }{ 90 - { 91 - name: "already has https", 92 - url: "https://example.com", 93 - expected: "https://example.com", 94 - }, 95 - { 96 - name: "already has http", 97 - url: "http://example.com", 98 - expected: "http://example.com", 99 - }, 100 - { 101 - name: "bare domain", 102 - url: "example.com", 103 - expected: "https://example.com", 104 - }, 105 - { 106 - name: "bare domain with path", 107 - url: "example.com/path", 108 - expected: "https://example.com/path", 109 - }, 110 - { 111 - name: "domain with whitespace", 112 - url: " example.com ", 113 - expected: "https://example.com", 114 - }, 115 - { 116 - name: "empty string", 117 - url: "", 118 - expected: "", 119 - }, 120 - { 121 - name: "whitespace only", 122 - url: " ", 123 - expected: "", 124 - }, 125 - { 126 - name: "file path with leading slash rejected", 127 - url: "/home/user/file.txt", 128 - expected: "", 129 - }, 130 - { 131 - name: "relative file path rejected", 132 - url: "./file.txt", 133 - expected: "", 134 - }, 135 - { 136 - name: "file:// uri with existing file rejected", 137 - url: "file:///etc/hosts", 138 - expected: "", 139 - }, 140 - { 141 - name: "file:// uri that looks like bare domain converted", 142 - url: "file:///nonexistent/path/example.com", 143 - expected: "https://example.com", 144 - }, 145 - { 146 - name: "file:// uri with nonexistent file rejected", 147 - url: "file:///nonexistent/file.txt", 148 - expected: "", 149 - }, 150 - { 151 - name: "custom protocol", 152 - url: "ftp://example.com", 153 - expected: "ftp://example.com", 154 - }, 155 - } 156 - 157 - for _, tt := range tests { 158 - t.Run(tt.name, func(t *testing.T) { 159 - result := sanitizeURL(tt.url) 160 - if result != tt.expected { 161 - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.url, result, tt.expected) 162 - } 163 - }) 164 - } 165 - } 166 - 167 - // TestMatchGlob tests glob pattern matching 168 - func TestMatchGlob(t *testing.T) { 169 - tests := []struct { 170 - name string 171 - url string 172 - pattern string 173 - want bool 174 - }{ 175 - { 176 - name: "wildcard subdomain", 177 - url: "https://sub.example.com", 178 - pattern: "*.example.com", 179 - want: true, 180 - }, 181 - { 182 - name: "wildcard subdomain no match", 183 - url: "https://different.com", 184 - pattern: "*.example.com", 185 - want: false, 186 - }, 187 - { 188 - name: "exact match", 189 - url: "https://example.com", 190 - pattern: "example.com", 191 - want: true, 192 - }, 193 - { 194 - name: "wildcard at end", 195 - url: "https://example.com/path", 196 - pattern: "example.com*", 197 - want: true, 198 - }, 199 - { 200 - name: "multiple subdomains", 201 - url: "https://deep.sub.example.com", 202 - pattern: "*.example.com", 203 - want: true, 204 - }, 205 - { 206 - name: "wildcard in middle", 207 - url: "https://test.example.com", 208 - pattern: "*.example.*", 209 - want: true, 210 - }, 211 - { 212 - name: "invalid pattern causes regex error", 213 - url: "https://example.com", 214 - pattern: "[invalid", 215 - want: false, 216 - }, 217 - } 218 - 219 - for _, tt := range tests { 220 - t.Run(tt.name, func(t *testing.T) { 221 - result := matchGlob(tt.url, tt.pattern) 222 - if result != tt.want { 223 - t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.url, tt.pattern, result, tt.want) 224 - } 225 - }) 226 - } 227 - } 228 - 229 - // TestMatchesPattern tests pattern matching for different types 230 - func TestMatchesPattern(t *testing.T) { 231 - tests := []struct { 232 - name string 233 - url string 234 - pattern string 235 - patternType string 236 - want bool 237 - }{ 238 - // Domain type tests 239 - { 240 - name: "domain exact match", 241 - url: "https://github.com", 242 - pattern: "github.com", 243 - patternType: "domain", 244 - want: true, 245 - }, 246 - { 247 - name: "domain case insensitive", 248 - url: "https://GitHub.COM", 249 - pattern: "github.com", 250 - patternType: "domain", 251 - want: true, 252 - }, 253 - { 254 - name: "domain with path still matches", 255 - url: "https://github.com/user/repo", 256 - pattern: "github.com", 257 - patternType: "domain", 258 - want: true, 259 - }, 260 - { 261 - name: "domain no match different domain", 262 - url: "https://gitlab.com", 263 - pattern: "github.com", 264 - patternType: "domain", 265 - want: false, 266 - }, 267 - { 268 - name: "domain subdomain no match", 269 - url: "https://api.github.com", 270 - pattern: "github.com", 271 - patternType: "domain", 272 - want: false, 273 - }, 274 - // Keyword type tests 275 - { 276 - name: "keyword in domain", 277 - url: "https://github.com", 278 - pattern: "github", 279 - patternType: "keyword", 280 - want: true, 281 - }, 282 - { 283 - name: "keyword in path", 284 - url: "https://example.com/github/repo", 285 - pattern: "github", 286 - patternType: "keyword", 287 - want: true, 288 - }, 289 - { 290 - name: "keyword case insensitive", 291 - url: "https://GITHUB.com", 292 - pattern: "github", 293 - patternType: "keyword", 294 - want: true, 295 - }, 296 - { 297 - name: "keyword no match", 298 - url: "https://gitlab.com", 299 - pattern: "github", 300 - patternType: "keyword", 301 - want: false, 302 - }, 303 - // Regex type tests 304 - { 305 - name: "regex simple match", 306 - url: "https://github.com/user/repo", 307 - pattern: "github\\.com", 308 - patternType: "regex", 309 - want: true, 310 - }, 311 - { 312 - name: "regex with groups", 313 - url: "https://github.com/user123/repo", 314 - pattern: "github\\.com/user\\d+", 315 - patternType: "regex", 316 - want: true, 317 - }, 318 - { 319 - name: "regex no match", 320 - url: "https://github.com", 321 - pattern: "gitlab\\.com", 322 - patternType: "regex", 323 - want: false, 324 - }, 325 - { 326 - name: "regex invalid pattern", 327 - url: "https://github.com", 328 - pattern: "[invalid(regex", 329 - patternType: "regex", 330 - want: false, 331 - }, 332 - // Glob type tests 333 - { 334 - name: "glob wildcard subdomain", 335 - url: "https://api.github.com", 336 - pattern: "*.github.com", 337 - patternType: "glob", 338 - want: true, 339 - }, 340 - { 341 - name: "glob exact", 342 - url: "https://github.com", 343 - pattern: "github.com", 344 - patternType: "glob", 345 - want: true, 346 - }, 347 - // Unknown type 348 - { 349 - name: "unknown type returns false", 350 - url: "https://github.com", 351 - pattern: "github.com", 352 - patternType: "unknown", 353 - want: false, 354 - }, 355 - } 356 - 357 - for _, tt := range tests { 358 - t.Run(tt.name, func(t *testing.T) { 359 - result := matchesPattern(tt.url, tt.pattern, tt.patternType) 360 - if result != tt.want { 361 - t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", 362 - tt.url, tt.pattern, tt.patternType, result, tt.want) 363 - } 364 - }) 365 - } 366 - } 367 - 368 9 // TestRuleMatchesConditions_AND tests AND logic (all conditions must match) 369 10 func TestRuleMatchesConditions_AND(t *testing.T) { 370 11 tests := []struct { ··· 759 400 } 760 401 } 761 402 762 - // TestExtractDomain_EdgeCases tests URL domain extraction with edge cases 763 - func TestExtractDomain_EdgeCases(t *testing.T) { 764 - tests := []struct { 765 - name string 766 - url string 767 - expected string 768 - }{ 769 - { 770 - name: "URL with authentication credentials strips at colon", 771 - url: "https://user:password@example.com/path", 772 - expected: "user", // Note: extractDomain strips at colon (port removal logic) 773 - }, 774 - { 775 - name: "URL with username only", 776 - url: "https://user@example.com/path", 777 - expected: "user@example.com", 778 - }, 779 - { 780 - name: "internationalized domain (IDN) ASCII form", 781 - url: "https://xn--n3h.com/path", 782 - expected: "xn--n3h.com", 783 - }, 784 - { 785 - name: "internationalized domain Unicode", 786 - url: "https://münchen.example/path", 787 - expected: "münchen.example", 788 - }, 789 - { 790 - name: "very long subdomain", 791 - url: "https://this.is.a.very.long.subdomain.chain.example.com/path", 792 - expected: "this.is.a.very.long.subdomain.chain.example.com", 793 - }, 794 - { 795 - name: "IP address v4", 796 - url: "https://192.168.1.1/path", 797 - expected: "192.168.1.1", 798 - }, 799 - { 800 - name: "IP address v4 with port", 801 - url: "https://192.168.1.1:8080/path", 802 - expected: "192.168.1.1", 803 - }, 804 - { 805 - name: "localhost", 806 - url: "http://localhost/path", 807 - expected: "localhost", 808 - }, 809 - { 810 - name: "localhost with port", 811 - url: "http://localhost:3000/path", 812 - expected: "localhost", 813 - }, 814 - { 815 - name: "single word TLD", 816 - url: "https://localhost", 817 - expected: "localhost", 818 - }, 819 - { 820 - name: "double slash in path doesn't affect domain", 821 - url: "https://example.com//double//slashes", 822 - expected: "example.com", 823 - }, 824 - { 825 - name: "ftp protocol", 826 - url: "ftp://files.example.com/file.zip", 827 - expected: "files.example.com", 828 - }, 829 - { 830 - name: "custom protocol", 831 - url: "myapp://example.com/action", 832 - expected: "example.com", 833 - }, 834 - { 835 - name: "mailto protocol extracts scheme name", 836 - url: "mailto:user@example.com", 837 - expected: "mailto", // Note: mailto: has no //, so extractDomain returns text before colon 838 - }, 839 - { 840 - name: "URL with empty path", 841 - url: "https://example.com/", 842 - expected: "example.com", 843 - }, 844 - { 845 - name: "URL with only question mark", 846 - url: "https://example.com?", 847 - expected: "example.com?", 848 - }, 849 - } 850 - 851 - for _, tt := range tests { 852 - t.Run(tt.name, func(t *testing.T) { 853 - result := extractDomain(tt.url) 854 - if result != tt.expected { 855 - t.Errorf("extractDomain(%q) = %q, want %q", tt.url, result, tt.expected) 856 - } 857 - }) 858 - } 859 - } 860 - 861 - // TestMatchesPattern_EdgeCases tests pattern matching with edge case URLs 862 - func TestMatchesPattern_EdgeCases(t *testing.T) { 863 - tests := []struct { 864 - name string 865 - url string 866 - pattern string 867 - patternType string 868 - expected bool 869 - }{ 870 - // Long URL tests 871 - { 872 - name: "very long URL with keyword match", 873 - url: "https://example.com/" + string(make([]byte, 1000)) + "keyword" + string(make([]byte, 1000)), 874 - pattern: "keyword", 875 - patternType: "keyword", 876 - expected: true, 877 - }, 878 - { 879 - name: "URL with many query parameters", 880 - url: "https://example.com/path?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10", 881 - pattern: "example.com", 882 - patternType: "domain", 883 - expected: true, 884 - }, 885 - // Special character tests 886 - { 887 - name: "URL with encoded spaces", 888 - url: "https://example.com/path%20with%20spaces", 889 - pattern: "%20", 890 - patternType: "keyword", 891 - expected: true, 892 - }, 893 - { 894 - name: "URL with plus signs", 895 - url: "https://search.example.com/q=hello+world", 896 - pattern: "hello+world", 897 - patternType: "keyword", 898 - expected: true, 899 - }, 900 - { 901 - name: "URL with hash fragment", 902 - url: "https://example.com/page#section-id", 903 - pattern: "section-id", 904 - patternType: "keyword", 905 - expected: true, 906 - }, 907 - { 908 - name: "URL with unicode characters", 909 - url: "https://example.com/日本語", 910 - pattern: "日本語", 911 - patternType: "keyword", 912 - expected: true, 913 - }, 914 - // Data URI tests 915 - { 916 - name: "data URI matches domain 'data' since it extracts scheme", 917 - url: "data:text/html,<h1>Hello</h1>", 918 - pattern: "data", 919 - patternType: "domain", 920 - expected: true, // extractDomain returns "data" for data: URIs 921 - }, 922 - { 923 - name: "data URI matches keyword", 924 - url: "data:text/html,<h1>Hello</h1>", 925 - pattern: "text/html", 926 - patternType: "keyword", 927 - expected: true, 928 - }, 929 - // JavaScript URI tests 930 - { 931 - name: "javascript URI keyword match", 932 - url: "javascript:alert('test')", 933 - pattern: "javascript", 934 - patternType: "keyword", 935 - expected: true, 936 - }, 937 - // Blob URI tests 938 - { 939 - name: "blob URI keyword match", 940 - url: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000", 941 - pattern: "blob:", 942 - patternType: "keyword", 943 - expected: true, 944 - }, 945 - // Regex edge cases 946 - { 947 - name: "regex with special characters in URL", 948 - url: "https://example.com/path?key=value&other=123", 949 - pattern: `key=value.*other=\d+`, 950 - patternType: "regex", 951 - expected: true, 952 - }, 953 - { 954 - name: "regex matching entire URL", 955 - url: "https://subdomain.example.com/path", 956 - pattern: `^https://[a-z]+\.example\.com/.*$`, 957 - patternType: "regex", 958 - expected: true, 959 - }, 960 - // Glob edge cases 961 - { 962 - name: "glob with multiple wildcards", 963 - url: "https://api.v2.example.com/endpoint", 964 - pattern: "*.*.example.com", 965 - patternType: "glob", 966 - expected: true, 967 - }, 968 - { 969 - name: "glob matching only TLD", 970 - url: "https://anything.io/path", 971 - pattern: "*.io", 972 - patternType: "glob", 973 - expected: true, 974 - }, 975 - // Empty and whitespace tests 976 - { 977 - name: "URL with only whitespace in query", 978 - url: "https://example.com/search?q= ", 979 - pattern: " ", 980 - patternType: "keyword", 981 - expected: true, 982 - }, 983 - // Case sensitivity verification 984 - { 985 - name: "domain match is case insensitive", 986 - url: "https://EXAMPLE.COM/path", 987 - pattern: "example.com", 988 - patternType: "domain", 989 - expected: true, 990 - }, 991 - { 992 - name: "keyword match is case insensitive", 993 - url: "https://example.com/PATH", 994 - pattern: "path", 995 - patternType: "keyword", 996 - expected: true, 997 - }, 998 - } 999 - 1000 - for _, tt := range tests { 1001 - t.Run(tt.name, func(t *testing.T) { 1002 - result := matchesPattern(tt.url, tt.pattern, tt.patternType) 1003 - if result != tt.expected { 1004 - t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", 1005 - tt.url, tt.pattern, tt.patternType, result, tt.expected) 1006 - } 1007 - }) 1008 - } 1009 - } 1010 - 1011 403 // TestConfigMatchRule_RuleOrdering tests that rules are matched in order (first match wins) 1012 404 func TestConfigMatchRule_RuleOrdering(t *testing.T) { 1013 405 tests := []struct { ··· 1223 615 }) 1224 616 } 1225 617 } 1226 - 1227 - // TestSanitizeURL_EdgeCases tests URL sanitization with edge cases 1228 - func TestSanitizeURL_EdgeCases(t *testing.T) { 1229 - tests := []struct { 1230 - name string 1231 - url string 1232 - expected string 1233 - }{ 1234 - // Note: sanitizeURL checks for "://" to identify schemes. 1235 - // URIs with single colon (data:, mailto:, tel:, javascript:) get https:// prepended. 1236 - // This is acceptable for a browser URL router since these aren't routable web URLs. 1237 - { 1238 - name: "data URI gets https prefix (no :// in original)", 1239 - url: "data:text/html,<h1>Test</h1>", 1240 - expected: "https://data:text/html,<h1>Test</h1>", 1241 - }, 1242 - { 1243 - name: "javascript URI gets https prefix (no :// in original)", 1244 - url: "javascript:void(0)", 1245 - expected: "https://javascript:void(0)", 1246 - }, 1247 - { 1248 - name: "blob URI is preserved (has ://)", 1249 - url: "blob:https://example.com/guid", 1250 - expected: "blob:https://example.com/guid", 1251 - }, 1252 - { 1253 - name: "mailto URI gets https prefix (no :// in original)", 1254 - url: "mailto:user@example.com", 1255 - expected: "https://mailto:user@example.com", 1256 - }, 1257 - { 1258 - name: "tel URI gets https prefix (no :// in original)", 1259 - url: "tel:+1234567890", 1260 - expected: "https://tel:+1234567890", 1261 - }, 1262 - { 1263 - name: "URL with leading whitespace", 1264 - url: " https://example.com", 1265 - expected: "https://example.com", 1266 - }, 1267 - { 1268 - name: "URL with trailing whitespace", 1269 - url: "https://example.com ", 1270 - expected: "https://example.com", 1271 - }, 1272 - { 1273 - name: "URL with both leading and trailing whitespace", 1274 - url: " https://example.com ", 1275 - expected: "https://example.com", 1276 - }, 1277 - { 1278 - name: "bare domain gets https prefix", 1279 - url: "example.com", 1280 - expected: "https://example.com", 1281 - }, 1282 - { 1283 - name: "bare domain with path gets https prefix", 1284 - url: "example.com/path/to/page", 1285 - expected: "https://example.com/path/to/page", 1286 - }, 1287 - { 1288 - name: "relative path is rejected", 1289 - url: "./relative/path", 1290 - expected: "", 1291 - }, 1292 - { 1293 - name: "absolute path is rejected", 1294 - url: "/absolute/path", 1295 - expected: "", 1296 - }, 1297 - { 1298 - name: "empty string", 1299 - url: "", 1300 - expected: "", 1301 - }, 1302 - { 1303 - name: "only whitespace", 1304 - url: " ", 1305 - expected: "", 1306 - }, 1307 - { 1308 - name: "ftp URL is preserved", 1309 - url: "ftp://files.example.com/file.zip", 1310 - expected: "ftp://files.example.com/file.zip", 1311 - }, 1312 - { 1313 - name: "custom app scheme is preserved", 1314 - url: "myapp://action/param", 1315 - expected: "myapp://action/param", 1316 - }, 1317 - } 1318 - 1319 - for _, tt := range tests { 1320 - t.Run(tt.name, func(t *testing.T) { 1321 - result := sanitizeURL(tt.url) 1322 - if result != tt.expected { 1323 - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.url, result, tt.expected) 1324 - } 1325 - }) 1326 - } 1327 - }
+68 -9
src/main.go
··· 4 4 package main 5 5 6 6 import ( 7 + "net/url" 7 8 "os" 9 + "strings" 8 10 "sync" 9 11 10 12 "github.com/diamondburned/gotk4-adwaita/pkg/adw" ··· 37 39 return 38 40 } 39 41 40 - url := files[0].URI() 41 - url = sanitizeURL(url) 42 - handleURL(app, cfg, url) 42 + rawURL := files[0].URI() 43 + 44 + // Check if this is a switchyard:// URL 45 + if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { 46 + handleSwitchyardURL(app, rawURL) 47 + return 48 + } 49 + 50 + sanitized := sanitizeURL(rawURL) 51 + if sanitized == "" { 52 + // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open 53 + cmd := hostCommand("xdg-open", rawURL) 54 + cmd.Start() 55 + return 56 + } 57 + handleURL(app, cfg, sanitized) 43 58 }) 44 59 45 60 if code := app.Run(os.Args); code > 0 { ··· 67 82 } 68 83 } 69 84 85 + // handleSwitchyardURL processes switchyard:// URLs with browser preferences 86 + func handleSwitchyardURL(app *adw.Application, rawURL string) { 87 + cfg := loadConfig() 88 + 89 + targetURL, browserPrefs, err := parseSwitchyardURL(rawURL) 90 + if err != nil { 91 + // Invalid switchyard URL - ignore 92 + return 93 + } 94 + 95 + // Sanitize the target URL 96 + sanitized := sanitizeURL(targetURL) 97 + if sanitized == "" { 98 + // Pass non-browser URLs to xdg-open 99 + cmd := hostCommand("xdg-open", targetURL) 100 + cmd.Start() 101 + return 102 + } 103 + 104 + browsers := detectBrowsers() 105 + 106 + // If browser preferences specified, try each in order 107 + if len(browserPrefs) > 0 { 108 + for _, pref := range browserPrefs { 109 + // Try with and without .desktop suffix 110 + id := pref 111 + if !strings.HasSuffix(id, ".desktop") { 112 + id = id + ".desktop" 113 + } 114 + if browser := findBrowserByID(browsers, id); browser != nil { 115 + launchBrowser(browser, sanitized) 116 + app.Quit() 117 + return 118 + } 119 + } 120 + // No preferred browser found - show picker 121 + showPickerWindow(app, sanitized, browsers, cfg) 122 + return 123 + } 124 + 125 + // No browser specified - use standard routing 126 + handleURL(app, cfg, sanitized) 127 + } 128 + 70 129 // handleURL routes a URL to the appropriate browser based on rules 71 - func handleURL(app *adw.Application, cfg *Config, url string) { 130 + func handleURL(app *adw.Application, cfg *Config, urlStr string) { 72 131 browsers := detectBrowsers() 73 132 74 133 // Try to match a rule 75 - browserID, alwaysAsk, matched := cfg.matchRule(url) 134 + browserID, alwaysAsk, matched := cfg.matchRule(urlStr) 76 135 if matched { 77 136 // Check if rule has AlwaysAsk enabled 78 137 if alwaysAsk { 79 - showPickerWindow(app, url, browsers, cfg) 138 + showPickerWindow(app, urlStr, browsers, cfg) 80 139 return 81 140 } 82 141 83 142 // Find the browser and launch it 84 143 if browser := findBrowserByID(browsers, browserID); browser != nil { 85 - launchBrowser(browser, url) 144 + launchBrowser(browser, urlStr) 86 145 app.Quit() 87 146 return 88 147 } ··· 91 150 // No rule matched 92 151 if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { 93 152 if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil { 94 - launchBrowser(browser, url) 153 + launchBrowser(browser, urlStr) 95 154 app.Quit() 96 155 return 97 156 } 98 157 } 99 158 100 159 // Show picker 101 - showPickerWindow(app, url, browsers, cfg) 160 + showPickerWindow(app, urlStr, browsers, cfg) 102 161 }
+79
src/pattern_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "testing" 7 + ) 8 + 9 + func TestMatchesPattern(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + url string 13 + pattern string 14 + patternType string 15 + want bool 16 + }{ 17 + // Domain matching - exact match only 18 + {"domain match", "https://github.com/user/repo", "github.com", "domain", true}, 19 + {"domain case insensitive", "https://GitHub.COM/user", "github.com", "domain", true}, 20 + {"domain no match", "https://gitlab.com", "github.com", "domain", false}, 21 + {"domain subdomain no match", "https://api.github.com", "github.com", "domain", false}, 22 + 23 + // Keyword matching - anywhere in URL 24 + {"keyword in domain", "https://github.com", "github", "keyword", true}, 25 + {"keyword in path", "https://example.com/github/repo", "github", "keyword", true}, 26 + {"keyword in query", "https://example.com?repo=github", "github", "keyword", true}, 27 + {"keyword case insensitive", "https://GITHUB.com", "github", "keyword", true}, 28 + {"keyword no match", "https://gitlab.com", "github", "keyword", false}, 29 + 30 + // Glob matching - wildcards for subdomains 31 + {"glob wildcard subdomain", "https://api.github.com", "*.github.com", "glob", true}, 32 + {"glob exact", "https://github.com", "github.com", "glob", true}, 33 + {"glob no match", "https://github.com", "*.gitlab.com", "glob", false}, 34 + {"glob multiple wildcards", "https://api.v2.example.com", "*.*.example.com", "glob", true}, 35 + 36 + // Regex matching - full control 37 + {"regex simple", "https://github.com/user/repo", `github\.com`, "regex", true}, 38 + {"regex path pattern", "https://github.com/user123/repo", `github\.com/user\d+`, "regex", true}, 39 + {"regex no match", "https://github.com", `gitlab\.com`, "regex", false}, 40 + {"regex invalid pattern", "https://example.com", "[invalid", "regex", false}, 41 + 42 + // Unknown pattern type 43 + {"unknown type", "https://example.com", "example.com", "invalid", false}, 44 + } 45 + 46 + for _, tt := range tests { 47 + t.Run(tt.name, func(t *testing.T) { 48 + result := matchesPattern(tt.url, tt.pattern, tt.patternType) 49 + if result != tt.want { 50 + t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", 51 + tt.url, tt.pattern, tt.patternType, result, tt.want) 52 + } 53 + }) 54 + } 55 + } 56 + 57 + func TestMatchGlob(t *testing.T) { 58 + tests := []struct { 59 + name string 60 + url string 61 + pattern string 62 + want bool 63 + }{ 64 + {"wildcard subdomain", "https://api.example.com", "*.example.com", true}, 65 + {"wildcard any subdomain depth", "https://deep.sub.example.com", "*.example.com", true}, 66 + {"exact match", "https://example.com", "example.com", true}, 67 + {"no match different domain", "https://other.com", "*.example.com", false}, 68 + {"wildcard TLD", "https://example.io", "*.io", true}, 69 + } 70 + 71 + for _, tt := range tests { 72 + t.Run(tt.name, func(t *testing.T) { 73 + result := matchGlob(tt.url, tt.pattern) 74 + if result != tt.want { 75 + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.url, tt.pattern, result, tt.want) 76 + } 77 + }) 78 + } 79 + }
+117
src/url.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "fmt" 7 + "net/url" 8 + "regexp" 9 + "strings" 10 + ) 11 + 12 + func extractDomain(rawURL string) string { 13 + // Add scheme if missing so url.Parse works correctly 14 + if !strings.Contains(rawURL, "://") { 15 + rawURL = "https://" + rawURL 16 + } 17 + u, err := url.Parse(rawURL) 18 + if err != nil { 19 + return "" 20 + } 21 + return u.Hostname() 22 + } 23 + 24 + func sanitizeURL(rawURL string) string { 25 + rawURL = strings.TrimSpace(rawURL) 26 + if rawURL == "" { 27 + return "" 28 + } 29 + 30 + // Reject local file paths 31 + if strings.HasPrefix(rawURL, "/") || strings.HasPrefix(rawURL, ".") { 32 + return "" 33 + } 34 + 35 + u, err := url.Parse(rawURL) 36 + if err != nil { 37 + return "" 38 + } 39 + 40 + // Add https scheme if missing 41 + if u.Scheme == "" { 42 + rawURL = "https://" + rawURL 43 + u, _ = url.Parse(rawURL) 44 + } 45 + 46 + // Only allow browser-routable schemes 47 + switch u.Scheme { 48 + case "http", "https", "file", "ftp": 49 + return u.String() 50 + default: 51 + return "" 52 + } 53 + } 54 + 55 + func matchesPattern(url, pattern, patternType string) bool { 56 + domain := extractDomain(url) 57 + 58 + switch patternType { 59 + case "domain": 60 + // Exact domain match 61 + return strings.EqualFold(domain, pattern) 62 + case "keyword": 63 + // URL contains text 64 + return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 65 + case "regex": 66 + re, err := regexp.Compile(pattern) 67 + if err != nil { 68 + return false 69 + } 70 + return re.MatchString(url) 71 + case "glob": 72 + return matchGlob(url, pattern) 73 + default: 74 + return false 75 + } 76 + } 77 + 78 + func matchGlob(url, pattern string) bool { 79 + // Extract domain from URL for matching 80 + domain := extractDomain(url) 81 + 82 + // Simple glob matching: * matches any characters 83 + pattern = strings.ReplaceAll(pattern, ".", "\\.") 84 + pattern = strings.ReplaceAll(pattern, "*", ".*") 85 + pattern = "^" + pattern + "$" 86 + 87 + re, err := regexp.Compile(pattern) 88 + if err != nil { 89 + return false 90 + } 91 + 92 + // Match against domain or full URL 93 + return re.MatchString(domain) || re.MatchString(url) 94 + } 95 + 96 + func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { 97 + u, err := url.Parse(rawURL) 98 + if err != nil { 99 + return "", nil, err 100 + } 101 + 102 + if u.Scheme != "switchyard" || u.Host != "open" { 103 + return "", nil, fmt.Errorf("invalid switchyard URL") 104 + } 105 + 106 + query := u.Query() 107 + targetURL = query.Get("url") 108 + if targetURL == "" { 109 + return "", nil, fmt.Errorf("missing url parameter") 110 + } 111 + 112 + if browser := query.Get("browser"); browser != "" { 113 + browserPrefs = strings.Split(browser, ",") 114 + } 115 + 116 + return targetURL, browserPrefs, nil 117 + }
+169
src/url_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "testing" 7 + ) 8 + 9 + func TestSanitizeURL(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + input string 13 + expected string 14 + }{ 15 + // Common inputs from users and applications 16 + {"bare domain", "example.com", "https://example.com"}, 17 + {"bare domain with path", "example.com/page", "https://example.com/page"}, 18 + {"bare domain with whitespace", " example.com ", "https://example.com"}, 19 + {"https URL", "https://example.com", "https://example.com"}, 20 + {"http URL", "http://example.com", "http://example.com"}, 21 + {"https with path and query", "https://example.com/path?q=1", "https://example.com/path?q=1"}, 22 + 23 + // File URLs - pass through for local HTML files 24 + {"file URL", "file:///home/user/doc.html", "file:///home/user/doc.html"}, 25 + 26 + // FTP - still used for some downloads 27 + {"ftp URL", "ftp://ftp.example.com/file.zip", "ftp://ftp.example.com/file.zip"}, 28 + 29 + // Schemes that should go to xdg-open, not browsers 30 + {"mailto rejected", "mailto:user@example.com", ""}, 31 + {"tel rejected", "tel:+1234567890", ""}, 32 + {"javascript rejected", "javascript:void(0)", ""}, 33 + {"data rejected", "data:text/html,<h1>Hi</h1>", ""}, 34 + 35 + // Invalid/unsupported inputs 36 + {"empty string", "", ""}, 37 + {"whitespace only", " ", ""}, 38 + {"absolute path rejected", "/home/user/file.html", ""}, 39 + {"relative path rejected", "./file.html", ""}, 40 + {"unknown scheme rejected", "myapp://action", ""}, 41 + } 42 + 43 + for _, tt := range tests { 44 + t.Run(tt.name, func(t *testing.T) { 45 + result := sanitizeURL(tt.input) 46 + if result != tt.expected { 47 + t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) 48 + } 49 + }) 50 + } 51 + } 52 + 53 + func TestExtractDomain(t *testing.T) { 54 + tests := []struct { 55 + name string 56 + input string 57 + expected string 58 + }{ 59 + // Standard URLs 60 + {"https URL", "https://example.com", "example.com"}, 61 + {"https with path", "https://example.com/path/page", "example.com"}, 62 + {"https with port", "https://example.com:8080/path", "example.com"}, 63 + {"https with query", "https://example.com?q=test", "example.com"}, 64 + {"http URL", "http://example.com", "example.com"}, 65 + 66 + // Subdomains 67 + {"subdomain", "https://www.example.com", "www.example.com"}, 68 + {"deep subdomain", "https://api.v2.example.com", "api.v2.example.com"}, 69 + 70 + // Bare domains (no scheme) - common user input 71 + {"bare domain", "example.com", "example.com"}, 72 + {"bare domain with path", "example.com/path", "example.com"}, 73 + 74 + // Auth in URL (legacy but still seen) 75 + {"URL with credentials", "https://user:pass@example.com", "example.com"}, 76 + 77 + // IP addresses 78 + {"IPv4", "https://192.168.1.1", "192.168.1.1"}, 79 + {"IPv4 with port", "https://192.168.1.1:8080", "192.168.1.1"}, 80 + {"localhost", "http://localhost:3000", "localhost"}, 81 + } 82 + 83 + for _, tt := range tests { 84 + t.Run(tt.name, func(t *testing.T) { 85 + result := extractDomain(tt.input) 86 + if result != tt.expected { 87 + t.Errorf("extractDomain(%q) = %q, want %q", tt.input, result, tt.expected) 88 + } 89 + }) 90 + } 91 + } 92 + 93 + func TestParseSwitchyardURL(t *testing.T) { 94 + tests := []struct { 95 + name string 96 + input string 97 + wantURL string 98 + wantBrowsers []string 99 + wantErr bool 100 + }{ 101 + // Valid URLs 102 + { 103 + name: "basic with browser", 104 + input: "switchyard://open?url=https://example.com&browser=org.mozilla.firefox", 105 + wantURL: "https://example.com", 106 + wantBrowsers: []string{"org.mozilla.firefox"}, 107 + }, 108 + { 109 + name: "multiple browsers", 110 + input: "switchyard://open?url=https://example.com&browser=org.mozilla.firefox,com.google.Chrome", 111 + wantURL: "https://example.com", 112 + wantBrowsers: []string{"org.mozilla.firefox", "com.google.Chrome"}, 113 + }, 114 + { 115 + name: "no browser specified", 116 + input: "switchyard://open?url=https://example.com", 117 + wantURL: "https://example.com", 118 + wantBrowsers: nil, 119 + }, 120 + { 121 + name: "encoded URL", 122 + input: "switchyard://open?url=https%3A%2F%2Fexample.com%3Ffoo%3Dbar&browser=org.mozilla.firefox", 123 + wantURL: "https://example.com?foo=bar", 124 + wantBrowsers: []string{"org.mozilla.firefox"}, 125 + }, 126 + 127 + // Invalid URLs 128 + { 129 + name: "wrong scheme", 130 + input: "http://open?url=https://example.com", 131 + wantErr: true, 132 + }, 133 + { 134 + name: "wrong host", 135 + input: "switchyard://launch?url=https://example.com", 136 + wantErr: true, 137 + }, 138 + { 139 + name: "missing url parameter", 140 + input: "switchyard://open?browser=org.mozilla.firefox", 141 + wantErr: true, 142 + }, 143 + } 144 + 145 + for _, tt := range tests { 146 + t.Run(tt.name, func(t *testing.T) { 147 + gotURL, gotBrowsers, err := parseSwitchyardURL(tt.input) 148 + if (err != nil) != tt.wantErr { 149 + t.Errorf("parseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) 150 + return 151 + } 152 + if tt.wantErr { 153 + return 154 + } 155 + if gotURL != tt.wantURL { 156 + t.Errorf("parseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) 157 + } 158 + if len(gotBrowsers) != len(tt.wantBrowsers) { 159 + t.Errorf("parseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) 160 + return 161 + } 162 + for i, b := range gotBrowsers { 163 + if b != tt.wantBrowsers[i] { 164 + t.Errorf("parseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) 165 + } 166 + } 167 + }) 168 + } 169 + }