1// Package url provides URL manipulation utilities for the browser.
2package url
3
4import (
5 "fmt"
6 "net/url"
7 "os"
8 "path/filepath"
9 "strings"
10)
11
12// expandHome expands ~ prefix to user's home directory.
13func expandHome(path string) string {
14 if strings.HasPrefix(path, "~/") {
15 if home, err := os.UserHomeDir(); err == nil {
16 return filepath.Join(home, path[2:])
17 }
18 }
19 return path
20}
21
22// looksLikeFilePath returns true if the input looks like a filesystem path.
23// This includes absolute paths (/path), relative paths (./path, ../path),
24// and home-relative paths (~/path).
25func looksLikeFilePath(input string) bool {
26 switch {
27 case strings.HasPrefix(input, "/"):
28 return true
29 case strings.HasPrefix(input, "./"):
30 return true
31 case strings.HasPrefix(input, "../"):
32 return true
33 case strings.HasPrefix(input, "~/"):
34 return true
35 }
36 return false
37}
38
39// Normalize adds https:// prefix if missing for URL-like inputs.
40// Returns the input unchanged if it already has a scheme or doesn't look like a URL.
41// If the input is an existing local file, returns a file:// URL.
42func Normalize(input string) string {
43 if input == "" {
44 return ""
45 }
46
47 // Already has scheme
48 switch {
49 case strings.HasPrefix(input, "http://"):
50 return input
51 case strings.HasPrefix(input, "https://"):
52 return input
53 case strings.HasPrefix(input, "dumb://"):
54 return input
55 case strings.HasPrefix(input, "file://"):
56 return input
57 case strings.HasPrefix(input, "about:"):
58 return input
59 }
60
61 // Check if input looks like a file path
62 if looksLikeFilePath(input) {
63 expanded := expandHome(input)
64 absPath, err := filepath.Abs(expanded)
65 if err == nil {
66 if _, statErr := os.Stat(absPath); statErr == nil {
67 // File exists, convert to file:// URL
68 return "file://" + absPath
69 }
70 }
71 // Path-like input but file doesn't exist - return unchanged
72 // Don't treat as URL to avoid malformed URLs like https:///path
73 return input
74 }
75
76 // Check if input is an existing file in current directory (relative without ./ prefix)
77 absPath, err := filepath.Abs(input)
78 if err == nil {
79 if _, statErr := os.Stat(absPath); statErr == nil {
80 return "file://" + absPath
81 }
82 }
83
84 // Looks like a URL (contains . and no spaces)
85 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
86 return "http://" + input
87 }
88 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
89 return "https://" + input
90 }
91
92 return input
93}
94
95// LooksLikeURL checks if the input appears to be a URL (not a search query).
96// Returns true for strings like "github.com", "google.com/search", etc.
97// Also returns true for URLs with explicit schemes like "dumb://".
98func LooksLikeURL(input string) bool {
99 if input == "" {
100 return false
101 }
102
103 // Explicit schemes should always be treated as URLs.
104 switch {
105 case strings.HasPrefix(input, "http://"):
106 return true
107 case strings.HasPrefix(input, "https://"):
108 return true
109 case strings.HasPrefix(input, "dumb://"):
110 return true
111 case strings.HasPrefix(input, "file://"):
112 return true
113 case strings.HasPrefix(input, "about:"):
114 return true
115 }
116
117 // Contains a dot and no spaces = likely a URL
118 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119 return true
120 }
121 return strings.Contains(input, ".") && !strings.Contains(input, " ")
122}
123
124// ExtractDomain extracts the normalized domain (host) from a URL string.
125// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
126// resolve to the same value.
127func ExtractDomain(rawURL string) string {
128 if rawURL == "" {
129 return ""
130 }
131 parsed, err := url.Parse(rawURL)
132 if err != nil || parsed.Host == "" {
133 return ""
134 }
135 return strings.TrimPrefix(parsed.Host, "www.")
136}
137
138// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
139// Replaces unsafe filesystem characters with underscores.
140func SanitizeDomainForFilename(domain string) string {
141 return sanitizeDomain(domain) + ".ico"
142}
143
144// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
145// Used for favicon export for tools like rofi/fuzzel that require PNG format.
146func SanitizeDomainForPNG(domain string) string {
147 return sanitizeDomain(domain) + ".png"
148}
149
150// sanitizeDomain replaces unsafe filesystem characters with underscores.
151func sanitizeDomain(domain string) string {
152 replacer := strings.NewReplacer(
153 ":", "_",
154 "/", "_",
155 "\\", "_",
156 "*", "_",
157 "?", "_",
158 "\"", "_",
159 "<", "_",
160 ">", "_",
161 "|", "_",
162 )
163 return replacer.Replace(domain)
164}
165
166// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
167// Example: "google.com" with size 32 -> "google.com.32.png"
168// Used for normalized favicon export for tools like rofi/fuzzel.
169func SanitizeDomainForPNGSized(domain string, size int) string {
170 return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
171}
172
173// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
174// result looks like a URL. Returns the original input unchanged if it doesn't
175// contain leading spaces or if the trimmed result is not a URL.
176// This handles cases like pasting " https://example.com" from clipboard.
177func TrimLeadingSpacesIfURL(input string) string {
178 trimmed := strings.TrimLeft(input, " \t")
179 if trimmed != input && LooksLikeURL(trimmed) {
180 return trimmed
181 }
182 return input
183}
184
185// IsExternalScheme returns true if the URL uses a scheme that should be
186// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187// rather than handled by the browser.
188// URL schemes are case-insensitive per RFC 3986.
189func IsExternalScheme(uri string) bool {
190 if uri == "" {
191 return false
192 }
193
194 parsed, err := url.Parse(uri)
195 if err != nil || parsed.Scheme == "" {
196 return false
197 }
198
199 switch strings.ToLower(parsed.Scheme) {
200 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
201 return false
202 default:
203 return true
204 }
205}
206
207// ExtractOrigin extracts the origin (scheme://host) from a URI.
208// This normalizes URIs to origins for permission storage and comparison.
209// Canonicalizes by lowercasing scheme and host, and omitting default ports.
210// Example: "https://example.com/path?query" -> "https://example.com"
211// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
212func ExtractOrigin(uri string) (string, error) {
213 parsed, err := url.Parse(uri)
214 if err != nil {
215 return "", fmt.Errorf("invalid URI: %w", err)
216 }
217
218 if parsed.Scheme == "" || parsed.Host == "" {
219 return "", fmt.Errorf("URI missing scheme or host: %s", uri)
220 }
221
222 // Canonicalize: lowercase scheme and hostname
223 scheme := strings.ToLower(parsed.Scheme)
224 hostname := strings.ToLower(parsed.Hostname())
225 if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
226 hostname = "[" + hostname + "]"
227 }
228
229 // Determine if we should include the port
230 port := parsed.Port()
231 if port != "" {
232 // Omit default ports
233 if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
234 port = ""
235 }
236 }
237
238 // Build canonical origin
239 origin := scheme + "://" + hostname
240 if port != "" {
241 origin = origin + ":" + port
242 }
243
244 return origin, nil
245}