From aa9328639fac626684034aac296fd017beeaa86e Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Wed, 21 Jan 2026 23:58:55 -0500 Subject: [PATCH] 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 --- README.md | 1 + data/io.github.alyraffauf.Switchyard.desktop | 2 +- ....github.alyraffauf.Switchyard.metainfo.xml | 3 +- docs/URI Scheme.md | 42 ++ justfile | 4 +- src/config.go | 123 --- src/config_test.go | 710 ------------------ src/main.go | 77 +- src/pattern_test.go | 79 ++ src/url.go | 117 +++ src/url_test.go | 169 +++++ 11 files changed, 481 insertions(+), 846 deletions(-) create mode 100644 docs/URI Scheme.md create mode 100644 src/pattern_test.go create mode 100644 src/url.go create mode 100644 src/url_test.go diff --git a/README.md b/README.md index f207f2e..34a7224 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Like a railroad switchyard directing trains to different tracks, Switchyard rout - **Rule-based routing**: Automatically open URLs in specific browsers based on powerful patterns. - **Multi-condition rules**: Combine multiple conditions with AND/OR logic for precise control. - **Multiple pattern types**: Exact Domain, URL Contains, Wildcard, and Regex matching. +- **Custom URI scheme**: Create links that specify browser preferences directly with `switchyard://` URLs. - **Quick browser picker**: When no rule matches, choose from your installed browsers with keyboard or mouse. - **Keyboard shortcuts**: Press Ctrl+1-9 to instantly select a browser. - **Lightweight**: Runs only when needed, no background processes. diff --git a/data/io.github.alyraffauf.Switchyard.desktop b/data/io.github.alyraffauf.Switchyard.desktop index 2bd6b9a..bee8182 100644 --- a/data/io.github.alyraffauf.Switchyard.desktop +++ b/data/io.github.alyraffauf.Switchyard.desktop @@ -5,7 +5,7 @@ Type=Application Name=Switchyard Comment=Rules-based URL router and browser launcher for Linux Categories=Network;WebBrowser;GNOME; -MimeType=x-scheme-handler/http;x-scheme-handler/https; +MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/switchyard; Icon=io.github.alyraffauf.Switchyard Exec=switchyard %U diff --git a/data/io.github.alyraffauf.Switchyard.metainfo.xml b/data/io.github.alyraffauf.Switchyard.metainfo.xml index a1bdf69..3b9447a 100644 --- a/data/io.github.alyraffauf.Switchyard.metainfo.xml +++ b/data/io.github.alyraffauf.Switchyard.metainfo.xml @@ -3,7 +3,7 @@ io.github.alyraffauf.Switchyard Switchyard - Rules-based link router and launcher + Rules-based browser launcher CC0-1.0 GPL-3.0-or-later @@ -22,6 +22,7 @@ switchyard x-scheme-handler/http x-scheme-handler/https + x-scheme-handler/switchyard https://switchyard.aly.codes/ diff --git a/docs/URI Scheme.md b/docs/URI Scheme.md new file mode 100644 index 0000000..86280b2 --- /dev/null +++ b/docs/URI Scheme.md @@ -0,0 +1,42 @@ +# Switchyard URI Scheme + +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. + +## Format + +``` +switchyard://open?url=&browser=[,,...] +``` + +## Parameters + +- **url** (required): The URL to open, percent-encoded. +- **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. + +Desktop file IDs match the `.desktop` filename without the extension (e.g. `org.mozilla.firefox`, `com.google.Chrome`). + +## Behavior + +1. Switchyard looks for each browser in the preference list in order. +2. The URL opens in the first browser found on the system. +3. If no listed browser is installed, Switchyard displays the browser launcher, allowing the user to select from installed browsers. + +## Examples + +Open in Firefox: + +``` +switchyard://open?url=https://example.com&browser=org.mozilla.firefox +``` + +Open in Firefox if installed, otherwise Chrome: + +``` +switchyard://open?url=https://example.com&browser=org.mozilla.firefox,com.google.Chrome +``` + +Percent-encoded special characters: + +``` +switchyard://open?url=https%3A%2F%2Fexample.com%3Ffoo%3Dbar&browser=org.mozilla.firefox +``` diff --git a/justfile b/justfile index 74339e3..ecfea63 100644 --- a/justfile +++ b/justfile @@ -52,12 +52,12 @@ vendor: # Run unit tests test: @echo "Running unit tests..." - go test -v ./src/config_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/validation.go + 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 # Run tests with coverage report test-coverage: @echo "Running tests with coverage..." - go test -coverprofile=coverage.out ./src/config_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/validation.go + 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 go tool cover -func=coverage.out @echo "" @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" diff --git a/src/config.go b/src/config.go index ab920e8..0291b1d 100644 --- a/src/config.go +++ b/src/config.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "strings" "github.com/pelletier/go-toml/v2" @@ -126,128 +125,6 @@ func (r *Rule) matchesConditions(url string) bool { } } -func matchesPattern(url, pattern, patternType string) bool { - domain := extractDomain(url) - - switch patternType { - case "domain": - // Exact domain match - return strings.EqualFold(domain, pattern) - case "keyword": - // URL contains text - return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) - case "regex": - re, err := regexp.Compile(pattern) - if err != nil { - return false - } - return re.MatchString(url) - case "glob": - return matchGlob(url, pattern) - default: - return false - } -} - -func matchGlob(url, pattern string) bool { - // Extract domain from URL for matching - domain := extractDomain(url) - - // Simple glob matching: * matches any characters - pattern = strings.ReplaceAll(pattern, ".", "\\.") - pattern = strings.ReplaceAll(pattern, "*", ".*") - pattern = "^" + pattern + "$" - - re, err := regexp.Compile(pattern) - if err != nil { - return false - } - - // Match against domain or full URL - return re.MatchString(domain) || re.MatchString(url) -} - -func extractDomain(url string) string { - // Remove protocol - u := url - if idx := strings.Index(u, "://"); idx != -1 { - u = u[idx+3:] - } - // Remove path - if idx := strings.Index(u, "/"); idx != -1 { - u = u[:idx] - } - // Remove port - if idx := strings.Index(u, ":"); idx != -1 { - u = u[:idx] - } - return u -} - -func sanitizeURL(url string) string { - // Trim whitespace - url = strings.TrimSpace(url) - - if url == "" { - return "" - } - - // Handle file:// URIs that GIO sometimes creates from bare domains - // If it's a file:// URI but the path doesn't exist and looks like a domain, convert it - if strings.HasPrefix(url, "file://") { - filePath := strings.TrimPrefix(url, "file://") - - // Check if file actually exists - if _, err := os.Stat(filePath); os.IsNotExist(err) { - // File doesn't exist - might be a bare domain that GIO converted - // Extract just the filename (last component) - lastSlash := strings.LastIndex(filePath, "/") - if lastSlash != -1 { - possibleDomain := filePath[lastSlash+1:] - // Check if it looks like a domain (not a file extension) - // Domain should have multiple parts separated by dots, not just "file.txt" - if strings.Contains(possibleDomain, ".") && !strings.Contains(possibleDomain, " ") { - // Split by dot to check if it looks like a domain vs a filename - parts := strings.Split(possibleDomain, ".") - // A domain typically has at least 2 parts and the TLD is not a file extension - // Common file extensions to reject: .txt, .pdf, .doc, .jpg, etc. - if len(parts) >= 2 { - lastPart := strings.ToLower(parts[len(parts)-1]) - // List of common file extensions to reject - fileExtensions := []string{"txt", "pdf", "doc", "docx", "jpg", "jpeg", "png", "gif", "zip", "tar", "gz"} - isFileExt := false - for _, ext := range fileExtensions { - if lastPart == ext { - isFileExt = true - break - } - } - // If it doesn't look like a file extension, treat as domain - if !isFileExt && len(lastPart) > 1 { - return "https://" + possibleDomain - } - } - } - } - } - // Real file path - reject (browsers handle file:// directly) - return "" - } - - // Reject local file paths (browsers don't need routing for these) - if strings.HasPrefix(url, "/") || strings.HasPrefix(url, ".") { - return "" - } - - // If it already has a scheme, return as-is - if strings.Contains(url, "://") { - return url - } - - // Add https:// prefix for bare domains/URLs - return "https://" + url -} - // hostCommand creates a command that runs on the host system when in flatpak, // or directly otherwise func hostCommand(name string, args ...string) *exec.Cmd { diff --git a/src/config_test.go b/src/config_test.go index 3a27a5e..5e0ac11 100644 --- a/src/config_test.go +++ b/src/config_test.go @@ -6,365 +6,6 @@ import ( "testing" ) -// TestExtractDomain tests URL domain extraction -func TestExtractDomain(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - { - name: "simple https url", - url: "https://example.com", - expected: "example.com", - }, - { - name: "https url with path", - url: "https://example.com/path/to/page", - expected: "example.com", - }, - { - name: "https url with port", - url: "https://example.com:8080", - expected: "example.com", - }, - { - name: "https url with port and path", - url: "https://example.com:8080/path", - expected: "example.com", - }, - { - name: "subdomain", - url: "https://sub.example.com", - expected: "sub.example.com", - }, - { - name: "multiple subdomains", - url: "https://deep.sub.example.com/path", - expected: "deep.sub.example.com", - }, - { - name: "http protocol", - url: "http://example.com", - expected: "example.com", - }, - { - name: "no protocol", - url: "example.com", - expected: "example.com", - }, - { - name: "no protocol with path", - url: "example.com/path", - expected: "example.com", - }, - { - name: "url with query params", - url: "https://example.com/path?key=value", - expected: "example.com", - }, - { - name: "url with fragment", - url: "https://example.com/path#section", - expected: "example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractDomain(tt.url) - if result != tt.expected { - t.Errorf("extractDomain(%q) = %q, want %q", tt.url, result, tt.expected) - } - }) - } -} - -// TestSanitizeURL tests URL sanitization logic -func TestSanitizeURL(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - { - name: "already has https", - url: "https://example.com", - expected: "https://example.com", - }, - { - name: "already has http", - url: "http://example.com", - expected: "http://example.com", - }, - { - name: "bare domain", - url: "example.com", - expected: "https://example.com", - }, - { - name: "bare domain with path", - url: "example.com/path", - expected: "https://example.com/path", - }, - { - name: "domain with whitespace", - url: " example.com ", - expected: "https://example.com", - }, - { - name: "empty string", - url: "", - expected: "", - }, - { - name: "whitespace only", - url: " ", - expected: "", - }, - { - name: "file path with leading slash rejected", - url: "/home/user/file.txt", - expected: "", - }, - { - name: "relative file path rejected", - url: "./file.txt", - expected: "", - }, - { - name: "file:// uri with existing file rejected", - url: "file:///etc/hosts", - expected: "", - }, - { - name: "file:// uri that looks like bare domain converted", - url: "file:///nonexistent/path/example.com", - expected: "https://example.com", - }, - { - name: "file:// uri with nonexistent file rejected", - url: "file:///nonexistent/file.txt", - expected: "", - }, - { - name: "custom protocol", - url: "ftp://example.com", - expected: "ftp://example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := sanitizeURL(tt.url) - if result != tt.expected { - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.url, result, tt.expected) - } - }) - } -} - -// TestMatchGlob tests glob pattern matching -func TestMatchGlob(t *testing.T) { - tests := []struct { - name string - url string - pattern string - want bool - }{ - { - name: "wildcard subdomain", - url: "https://sub.example.com", - pattern: "*.example.com", - want: true, - }, - { - name: "wildcard subdomain no match", - url: "https://different.com", - pattern: "*.example.com", - want: false, - }, - { - name: "exact match", - url: "https://example.com", - pattern: "example.com", - want: true, - }, - { - name: "wildcard at end", - url: "https://example.com/path", - pattern: "example.com*", - want: true, - }, - { - name: "multiple subdomains", - url: "https://deep.sub.example.com", - pattern: "*.example.com", - want: true, - }, - { - name: "wildcard in middle", - url: "https://test.example.com", - pattern: "*.example.*", - want: true, - }, - { - name: "invalid pattern causes regex error", - url: "https://example.com", - pattern: "[invalid", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := matchGlob(tt.url, tt.pattern) - if result != tt.want { - t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.url, tt.pattern, result, tt.want) - } - }) - } -} - -// TestMatchesPattern tests pattern matching for different types -func TestMatchesPattern(t *testing.T) { - tests := []struct { - name string - url string - pattern string - patternType string - want bool - }{ - // Domain type tests - { - name: "domain exact match", - url: "https://github.com", - pattern: "github.com", - patternType: "domain", - want: true, - }, - { - name: "domain case insensitive", - url: "https://GitHub.COM", - pattern: "github.com", - patternType: "domain", - want: true, - }, - { - name: "domain with path still matches", - url: "https://github.com/user/repo", - pattern: "github.com", - patternType: "domain", - want: true, - }, - { - name: "domain no match different domain", - url: "https://gitlab.com", - pattern: "github.com", - patternType: "domain", - want: false, - }, - { - name: "domain subdomain no match", - url: "https://api.github.com", - pattern: "github.com", - patternType: "domain", - want: false, - }, - // Keyword type tests - { - name: "keyword in domain", - url: "https://github.com", - pattern: "github", - patternType: "keyword", - want: true, - }, - { - name: "keyword in path", - url: "https://example.com/github/repo", - pattern: "github", - patternType: "keyword", - want: true, - }, - { - name: "keyword case insensitive", - url: "https://GITHUB.com", - pattern: "github", - patternType: "keyword", - want: true, - }, - { - name: "keyword no match", - url: "https://gitlab.com", - pattern: "github", - patternType: "keyword", - want: false, - }, - // Regex type tests - { - name: "regex simple match", - url: "https://github.com/user/repo", - pattern: "github\\.com", - patternType: "regex", - want: true, - }, - { - name: "regex with groups", - url: "https://github.com/user123/repo", - pattern: "github\\.com/user\\d+", - patternType: "regex", - want: true, - }, - { - name: "regex no match", - url: "https://github.com", - pattern: "gitlab\\.com", - patternType: "regex", - want: false, - }, - { - name: "regex invalid pattern", - url: "https://github.com", - pattern: "[invalid(regex", - patternType: "regex", - want: false, - }, - // Glob type tests - { - name: "glob wildcard subdomain", - url: "https://api.github.com", - pattern: "*.github.com", - patternType: "glob", - want: true, - }, - { - name: "glob exact", - url: "https://github.com", - pattern: "github.com", - patternType: "glob", - want: true, - }, - // Unknown type - { - name: "unknown type returns false", - url: "https://github.com", - pattern: "github.com", - patternType: "unknown", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := matchesPattern(tt.url, tt.pattern, tt.patternType) - if result != tt.want { - t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", - tt.url, tt.pattern, tt.patternType, result, tt.want) - } - }) - } -} - // TestRuleMatchesConditions_AND tests AND logic (all conditions must match) func TestRuleMatchesConditions_AND(t *testing.T) { tests := []struct { @@ -759,255 +400,6 @@ func TestConfigMatchRule(t *testing.T) { } } -// TestExtractDomain_EdgeCases tests URL domain extraction with edge cases -func TestExtractDomain_EdgeCases(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - { - name: "URL with authentication credentials strips at colon", - url: "https://user:password@example.com/path", - expected: "user", // Note: extractDomain strips at colon (port removal logic) - }, - { - name: "URL with username only", - url: "https://user@example.com/path", - expected: "user@example.com", - }, - { - name: "internationalized domain (IDN) ASCII form", - url: "https://xn--n3h.com/path", - expected: "xn--n3h.com", - }, - { - name: "internationalized domain Unicode", - url: "https://münchen.example/path", - expected: "münchen.example", - }, - { - name: "very long subdomain", - url: "https://this.is.a.very.long.subdomain.chain.example.com/path", - expected: "this.is.a.very.long.subdomain.chain.example.com", - }, - { - name: "IP address v4", - url: "https://192.168.1.1/path", - expected: "192.168.1.1", - }, - { - name: "IP address v4 with port", - url: "https://192.168.1.1:8080/path", - expected: "192.168.1.1", - }, - { - name: "localhost", - url: "http://localhost/path", - expected: "localhost", - }, - { - name: "localhost with port", - url: "http://localhost:3000/path", - expected: "localhost", - }, - { - name: "single word TLD", - url: "https://localhost", - expected: "localhost", - }, - { - name: "double slash in path doesn't affect domain", - url: "https://example.com//double//slashes", - expected: "example.com", - }, - { - name: "ftp protocol", - url: "ftp://files.example.com/file.zip", - expected: "files.example.com", - }, - { - name: "custom protocol", - url: "myapp://example.com/action", - expected: "example.com", - }, - { - name: "mailto protocol extracts scheme name", - url: "mailto:user@example.com", - expected: "mailto", // Note: mailto: has no //, so extractDomain returns text before colon - }, - { - name: "URL with empty path", - url: "https://example.com/", - expected: "example.com", - }, - { - name: "URL with only question mark", - url: "https://example.com?", - expected: "example.com?", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractDomain(tt.url) - if result != tt.expected { - t.Errorf("extractDomain(%q) = %q, want %q", tt.url, result, tt.expected) - } - }) - } -} - -// TestMatchesPattern_EdgeCases tests pattern matching with edge case URLs -func TestMatchesPattern_EdgeCases(t *testing.T) { - tests := []struct { - name string - url string - pattern string - patternType string - expected bool - }{ - // Long URL tests - { - name: "very long URL with keyword match", - url: "https://example.com/" + string(make([]byte, 1000)) + "keyword" + string(make([]byte, 1000)), - pattern: "keyword", - patternType: "keyword", - expected: true, - }, - { - name: "URL with many query parameters", - 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", - pattern: "example.com", - patternType: "domain", - expected: true, - }, - // Special character tests - { - name: "URL with encoded spaces", - url: "https://example.com/path%20with%20spaces", - pattern: "%20", - patternType: "keyword", - expected: true, - }, - { - name: "URL with plus signs", - url: "https://search.example.com/q=hello+world", - pattern: "hello+world", - patternType: "keyword", - expected: true, - }, - { - name: "URL with hash fragment", - url: "https://example.com/page#section-id", - pattern: "section-id", - patternType: "keyword", - expected: true, - }, - { - name: "URL with unicode characters", - url: "https://example.com/日本語", - pattern: "日本語", - patternType: "keyword", - expected: true, - }, - // Data URI tests - { - name: "data URI matches domain 'data' since it extracts scheme", - url: "data:text/html,

Hello

", - pattern: "data", - patternType: "domain", - expected: true, // extractDomain returns "data" for data: URIs - }, - { - name: "data URI matches keyword", - url: "data:text/html,

Hello

", - pattern: "text/html", - patternType: "keyword", - expected: true, - }, - // JavaScript URI tests - { - name: "javascript URI keyword match", - url: "javascript:alert('test')", - pattern: "javascript", - patternType: "keyword", - expected: true, - }, - // Blob URI tests - { - name: "blob URI keyword match", - url: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000", - pattern: "blob:", - patternType: "keyword", - expected: true, - }, - // Regex edge cases - { - name: "regex with special characters in URL", - url: "https://example.com/path?key=value&other=123", - pattern: `key=value.*other=\d+`, - patternType: "regex", - expected: true, - }, - { - name: "regex matching entire URL", - url: "https://subdomain.example.com/path", - pattern: `^https://[a-z]+\.example\.com/.*$`, - patternType: "regex", - expected: true, - }, - // Glob edge cases - { - name: "glob with multiple wildcards", - url: "https://api.v2.example.com/endpoint", - pattern: "*.*.example.com", - patternType: "glob", - expected: true, - }, - { - name: "glob matching only TLD", - url: "https://anything.io/path", - pattern: "*.io", - patternType: "glob", - expected: true, - }, - // Empty and whitespace tests - { - name: "URL with only whitespace in query", - url: "https://example.com/search?q= ", - pattern: " ", - patternType: "keyword", - expected: true, - }, - // Case sensitivity verification - { - name: "domain match is case insensitive", - url: "https://EXAMPLE.COM/path", - pattern: "example.com", - patternType: "domain", - expected: true, - }, - { - name: "keyword match is case insensitive", - url: "https://example.com/PATH", - pattern: "path", - patternType: "keyword", - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := matchesPattern(tt.url, tt.pattern, tt.patternType) - if result != tt.expected { - t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", - tt.url, tt.pattern, tt.patternType, result, tt.expected) - } - }) - } -} - // TestConfigMatchRule_RuleOrdering tests that rules are matched in order (first match wins) func TestConfigMatchRule_RuleOrdering(t *testing.T) { tests := []struct { @@ -1223,105 +615,3 @@ func TestConfigMatchRule_RuleOrdering(t *testing.T) { }) } } - -// TestSanitizeURL_EdgeCases tests URL sanitization with edge cases -func TestSanitizeURL_EdgeCases(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - // Note: sanitizeURL checks for "://" to identify schemes. - // URIs with single colon (data:, mailto:, tel:, javascript:) get https:// prepended. - // This is acceptable for a browser URL router since these aren't routable web URLs. - { - name: "data URI gets https prefix (no :// in original)", - url: "data:text/html,

Test

", - expected: "https://data:text/html,

Test

", - }, - { - name: "javascript URI gets https prefix (no :// in original)", - url: "javascript:void(0)", - expected: "https://javascript:void(0)", - }, - { - name: "blob URI is preserved (has ://)", - url: "blob:https://example.com/guid", - expected: "blob:https://example.com/guid", - }, - { - name: "mailto URI gets https prefix (no :// in original)", - url: "mailto:user@example.com", - expected: "https://mailto:user@example.com", - }, - { - name: "tel URI gets https prefix (no :// in original)", - url: "tel:+1234567890", - expected: "https://tel:+1234567890", - }, - { - name: "URL with leading whitespace", - url: " https://example.com", - expected: "https://example.com", - }, - { - name: "URL with trailing whitespace", - url: "https://example.com ", - expected: "https://example.com", - }, - { - name: "URL with both leading and trailing whitespace", - url: " https://example.com ", - expected: "https://example.com", - }, - { - name: "bare domain gets https prefix", - url: "example.com", - expected: "https://example.com", - }, - { - name: "bare domain with path gets https prefix", - url: "example.com/path/to/page", - expected: "https://example.com/path/to/page", - }, - { - name: "relative path is rejected", - url: "./relative/path", - expected: "", - }, - { - name: "absolute path is rejected", - url: "/absolute/path", - expected: "", - }, - { - name: "empty string", - url: "", - expected: "", - }, - { - name: "only whitespace", - url: " ", - expected: "", - }, - { - name: "ftp URL is preserved", - url: "ftp://files.example.com/file.zip", - expected: "ftp://files.example.com/file.zip", - }, - { - name: "custom app scheme is preserved", - url: "myapp://action/param", - expected: "myapp://action/param", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := sanitizeURL(tt.url) - if result != tt.expected { - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.url, result, tt.expected) - } - }) - } -} diff --git a/src/main.go b/src/main.go index 500e6ed..dea85ee 100644 --- a/src/main.go +++ b/src/main.go @@ -4,7 +4,9 @@ package main import ( + "net/url" "os" + "strings" "sync" "github.com/diamondburned/gotk4-adwaita/pkg/adw" @@ -37,9 +39,22 @@ func main() { return } - url := files[0].URI() - url = sanitizeURL(url) - handleURL(app, cfg, url) + rawURL := files[0].URI() + + // Check if this is a switchyard:// URL + if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { + handleSwitchyardURL(app, rawURL) + return + } + + sanitized := sanitizeURL(rawURL) + if sanitized == "" { + // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open + cmd := hostCommand("xdg-open", rawURL) + cmd.Start() + return + } + handleURL(app, cfg, sanitized) }) if code := app.Run(os.Args); code > 0 { @@ -67,22 +82,66 @@ func setupApp(cfg *Config) { } } +// handleSwitchyardURL processes switchyard:// URLs with browser preferences +func handleSwitchyardURL(app *adw.Application, rawURL string) { + cfg := loadConfig() + + targetURL, browserPrefs, err := parseSwitchyardURL(rawURL) + if err != nil { + // Invalid switchyard URL - ignore + return + } + + // Sanitize the target URL + sanitized := sanitizeURL(targetURL) + if sanitized == "" { + // Pass non-browser URLs to xdg-open + cmd := hostCommand("xdg-open", targetURL) + cmd.Start() + return + } + + browsers := detectBrowsers() + + // 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" + } + if browser := findBrowserByID(browsers, id); browser != nil { + launchBrowser(browser, sanitized) + app.Quit() + return + } + } + // No preferred browser found - show picker + showPickerWindow(app, sanitized, browsers, cfg) + return + } + + // No browser specified - use standard routing + handleURL(app, cfg, sanitized) +} + // handleURL routes a URL to the appropriate browser based on rules -func handleURL(app *adw.Application, cfg *Config, url string) { +func handleURL(app *adw.Application, cfg *Config, urlStr string) { browsers := detectBrowsers() // Try to match a rule - browserID, alwaysAsk, matched := cfg.matchRule(url) + browserID, alwaysAsk, matched := cfg.matchRule(urlStr) if matched { // Check if rule has AlwaysAsk enabled if alwaysAsk { - showPickerWindow(app, url, browsers, cfg) + showPickerWindow(app, urlStr, browsers, cfg) return } // Find the browser and launch it if browser := findBrowserByID(browsers, browserID); browser != nil { - launchBrowser(browser, url) + launchBrowser(browser, urlStr) app.Quit() return } @@ -91,12 +150,12 @@ func handleURL(app *adw.Application, cfg *Config, url string) { // No rule matched if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { if browser := findBrowserByID(browsers, cfg.FavoriteBrowser); browser != nil { - launchBrowser(browser, url) + launchBrowser(browser, urlStr) app.Quit() return } } // Show picker - showPickerWindow(app, url, browsers, cfg) + showPickerWindow(app, urlStr, browsers, cfg) } diff --git a/src/pattern_test.go b/src/pattern_test.go new file mode 100644 index 0000000..e443102 --- /dev/null +++ b/src/pattern_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "testing" +) + +func TestMatchesPattern(t *testing.T) { + tests := []struct { + name string + url string + pattern string + patternType string + want bool + }{ + // Domain matching - exact match only + {"domain match", "https://github.com/user/repo", "github.com", "domain", true}, + {"domain case insensitive", "https://GitHub.COM/user", "github.com", "domain", true}, + {"domain no match", "https://gitlab.com", "github.com", "domain", false}, + {"domain subdomain no match", "https://api.github.com", "github.com", "domain", false}, + + // Keyword matching - anywhere in URL + {"keyword in domain", "https://github.com", "github", "keyword", true}, + {"keyword in path", "https://example.com/github/repo", "github", "keyword", true}, + {"keyword in query", "https://example.com?repo=github", "github", "keyword", true}, + {"keyword case insensitive", "https://GITHUB.com", "github", "keyword", true}, + {"keyword no match", "https://gitlab.com", "github", "keyword", false}, + + // Glob matching - wildcards for subdomains + {"glob wildcard subdomain", "https://api.github.com", "*.github.com", "glob", true}, + {"glob exact", "https://github.com", "github.com", "glob", true}, + {"glob no match", "https://github.com", "*.gitlab.com", "glob", false}, + {"glob multiple wildcards", "https://api.v2.example.com", "*.*.example.com", "glob", true}, + + // Regex matching - full control + {"regex simple", "https://github.com/user/repo", `github\.com`, "regex", true}, + {"regex path pattern", "https://github.com/user123/repo", `github\.com/user\d+`, "regex", true}, + {"regex no match", "https://github.com", `gitlab\.com`, "regex", false}, + {"regex invalid pattern", "https://example.com", "[invalid", "regex", false}, + + // Unknown pattern type + {"unknown type", "https://example.com", "example.com", "invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesPattern(tt.url, tt.pattern, tt.patternType) + if result != tt.want { + t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", + tt.url, tt.pattern, tt.patternType, result, tt.want) + } + }) + } +} + +func TestMatchGlob(t *testing.T) { + tests := []struct { + name string + url string + pattern string + want bool + }{ + {"wildcard subdomain", "https://api.example.com", "*.example.com", true}, + {"wildcard any subdomain depth", "https://deep.sub.example.com", "*.example.com", true}, + {"exact match", "https://example.com", "example.com", true}, + {"no match different domain", "https://other.com", "*.example.com", false}, + {"wildcard TLD", "https://example.io", "*.io", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchGlob(tt.url, tt.pattern) + if result != tt.want { + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.url, tt.pattern, result, tt.want) + } + }) + } +} diff --git a/src/url.go b/src/url.go new file mode 100644 index 0000000..8d06727 --- /dev/null +++ b/src/url.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +func extractDomain(rawURL string) string { + // Add scheme if missing so url.Parse works correctly + if !strings.Contains(rawURL, "://") { + rawURL = "https://" + rawURL + } + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Hostname() +} + +func sanitizeURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + + // Reject local file paths + if strings.HasPrefix(rawURL, "/") || strings.HasPrefix(rawURL, ".") { + return "" + } + + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + + // Add https scheme if missing + if u.Scheme == "" { + rawURL = "https://" + rawURL + u, _ = url.Parse(rawURL) + } + + // Only allow browser-routable schemes + switch u.Scheme { + case "http", "https", "file", "ftp": + return u.String() + default: + return "" + } +} + +func matchesPattern(url, pattern, patternType string) bool { + domain := extractDomain(url) + + switch patternType { + case "domain": + // Exact domain match + return strings.EqualFold(domain, pattern) + case "keyword": + // URL contains text + return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) + case "regex": + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + return re.MatchString(url) + case "glob": + return matchGlob(url, pattern) + default: + return false + } +} + +func matchGlob(url, pattern string) bool { + // Extract domain from URL for matching + domain := extractDomain(url) + + // Simple glob matching: * matches any characters + pattern = strings.ReplaceAll(pattern, ".", "\\.") + pattern = strings.ReplaceAll(pattern, "*", ".*") + pattern = "^" + pattern + "$" + + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + + // Match against domain or full URL + return re.MatchString(domain) || re.MatchString(url) +} + +func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", nil, err + } + + if u.Scheme != "switchyard" || u.Host != "open" { + return "", nil, fmt.Errorf("invalid switchyard URL") + } + + query := u.Query() + targetURL = query.Get("url") + if targetURL == "" { + return "", nil, fmt.Errorf("missing url parameter") + } + + if browser := query.Get("browser"); browser != "" { + browserPrefs = strings.Split(browser, ",") + } + + return targetURL, browserPrefs, nil +} diff --git a/src/url_test.go b/src/url_test.go new file mode 100644 index 0000000..765480b --- /dev/null +++ b/src/url_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "testing" +) + +func TestSanitizeURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Common inputs from users and applications + {"bare domain", "example.com", "https://example.com"}, + {"bare domain with path", "example.com/page", "https://example.com/page"}, + {"bare domain with whitespace", " example.com ", "https://example.com"}, + {"https URL", "https://example.com", "https://example.com"}, + {"http URL", "http://example.com", "http://example.com"}, + {"https with path and query", "https://example.com/path?q=1", "https://example.com/path?q=1"}, + + // File URLs - pass through for local HTML files + {"file URL", "file:///home/user/doc.html", "file:///home/user/doc.html"}, + + // FTP - still used for some downloads + {"ftp URL", "ftp://ftp.example.com/file.zip", "ftp://ftp.example.com/file.zip"}, + + // Schemes that should go to xdg-open, not browsers + {"mailto rejected", "mailto:user@example.com", ""}, + {"tel rejected", "tel:+1234567890", ""}, + {"javascript rejected", "javascript:void(0)", ""}, + {"data rejected", "data:text/html,

Hi

", ""}, + + // Invalid/unsupported inputs + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"absolute path rejected", "/home/user/file.html", ""}, + {"relative path rejected", "./file.html", ""}, + {"unknown scheme rejected", "myapp://action", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeURL(tt.input) + if result != tt.expected { + t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractDomain(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Standard URLs + {"https URL", "https://example.com", "example.com"}, + {"https with path", "https://example.com/path/page", "example.com"}, + {"https with port", "https://example.com:8080/path", "example.com"}, + {"https with query", "https://example.com?q=test", "example.com"}, + {"http URL", "http://example.com", "example.com"}, + + // Subdomains + {"subdomain", "https://www.example.com", "www.example.com"}, + {"deep subdomain", "https://api.v2.example.com", "api.v2.example.com"}, + + // Bare domains (no scheme) - common user input + {"bare domain", "example.com", "example.com"}, + {"bare domain with path", "example.com/path", "example.com"}, + + // Auth in URL (legacy but still seen) + {"URL with credentials", "https://user:pass@example.com", "example.com"}, + + // IP addresses + {"IPv4", "https://192.168.1.1", "192.168.1.1"}, + {"IPv4 with port", "https://192.168.1.1:8080", "192.168.1.1"}, + {"localhost", "http://localhost:3000", "localhost"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractDomain(tt.input) + if result != tt.expected { + t.Errorf("extractDomain(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseSwitchyardURL(t *testing.T) { + tests := []struct { + name string + input string + wantURL string + wantBrowsers []string + wantErr bool + }{ + // Valid URLs + { + name: "basic with browser", + input: "switchyard://open?url=https://example.com&browser=org.mozilla.firefox", + wantURL: "https://example.com", + wantBrowsers: []string{"org.mozilla.firefox"}, + }, + { + name: "multiple browsers", + input: "switchyard://open?url=https://example.com&browser=org.mozilla.firefox,com.google.Chrome", + wantURL: "https://example.com", + wantBrowsers: []string{"org.mozilla.firefox", "com.google.Chrome"}, + }, + { + name: "no browser specified", + input: "switchyard://open?url=https://example.com", + wantURL: "https://example.com", + wantBrowsers: nil, + }, + { + name: "encoded URL", + input: "switchyard://open?url=https%3A%2F%2Fexample.com%3Ffoo%3Dbar&browser=org.mozilla.firefox", + wantURL: "https://example.com?foo=bar", + wantBrowsers: []string{"org.mozilla.firefox"}, + }, + + // Invalid URLs + { + name: "wrong scheme", + input: "http://open?url=https://example.com", + wantErr: true, + }, + { + name: "wrong host", + input: "switchyard://launch?url=https://example.com", + wantErr: true, + }, + { + name: "missing url parameter", + input: "switchyard://open?browser=org.mozilla.firefox", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotURL, gotBrowsers, err := parseSwitchyardURL(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if gotURL != tt.wantURL { + t.Errorf("parseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) + } + if len(gotBrowsers) != len(tt.wantBrowsers) { + t.Errorf("parseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) + return + } + for i, b := range gotBrowsers { + if b != tt.wantBrowsers[i] { + t.Errorf("parseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) + } + } + }) + } +} -- 2.51.2