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])
+ }
+ }
+ })
+ }
+}