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