diff --git a/.isu/issues.json b/.isu/issues.json index 4ef084e..c087377 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -3222,7 +3222,7 @@ "labels": [], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-22T12:45:27Z" }, { diff --git a/crates/browser/src/chrome.rs b/crates/browser/src/chrome.rs index dd89701..b26eead 100644 --- a/crates/browser/src/chrome.rs +++ b/crates/browser/src/chrome.rs @@ -1,10 +1,9 @@ -//! Browser-chrome keyboard routing. +//! Browser-chrome keyboard routing and address-bar input handling. //! //! Provides a platform-free key-modifier struct, the macOS virtual key codes -//! the chrome cares about, and a pure dispatch function that maps a -//! `(key_code, modifiers)` pair to one of a small set of browser-wide -//! shortcut actions. The dispatch is independent of any real browser state so -//! it can be unit-tested without AppKit or a live `BrowserState`. +//! the chrome cares about, and pure dispatch/resolution functions used by the +//! browser chrome. Everything here is independent of any real browser state +//! so it can be unit-tested without AppKit or a live `BrowserState`. /// Modifier-key state for a keyboard event, decoupled from `we-platform`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -106,6 +105,163 @@ pub fn address_bar_should_insert(chars: &str, mods: KeyMods) -> bool { } } +/// The destination produced by interpreting a string the user typed into the +/// address bar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressBarTarget { + /// A URL to load directly. Always carries a usable scheme (`https://` + /// is prepended to bare hostnames so the loader sees a well-formed URL). + Url(String), + /// A search query — the input did not look like a URL, so it should be + /// handed to the default search engine. + Search(String), +} + +/// Resolve the raw text from the address bar into a navigation target. +/// +/// Returns `None` for empty or whitespace-only input (the caller should do +/// nothing in that case). Otherwise returns either a URL ready to load or a +/// search query that the caller should hand to [`build_search_url`]. +/// +/// The classification heuristic is intentionally simple: the URL bar is used +/// far more often for navigation than for search, and the user can always +/// disambiguate by including a scheme. Anything containing whitespace is +/// treated as a search query; otherwise we look at the portion before the +/// first `/`, `?`, or `#`, and call it a URL only if it has a scheme, a dot, +/// is bracketed IPv6 (`[::1]`), or is `localhost` (possibly with a port). +pub fn resolve_address_bar_input(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + // Explicit scheme — accept verbatim. `about:blank`, `data:...`, + // `file:///...`, `view-source:...`, etc. all flow through this branch. + if has_url_scheme(trimmed) { + return Some(AddressBarTarget::Url(trimmed.to_string())); + } + + // Heuristic URL detection for bare hostnames like `example.com`, + // `localhost:8080`, `127.0.0.1`, or `[::1]`. + if looks_like_bare_host(trimmed) { + return Some(AddressBarTarget::Url(format!("https://{trimmed}"))); + } + + Some(AddressBarTarget::Search(trimmed.to_string())) +} + +/// Build a Google search URL for `query`, percent-encoding the query per +/// `application/x-www-form-urlencoded` rules. +pub fn build_search_url(query: &str) -> String { + format!( + "https://www.google.com/search?q={}", + form_urlencoded_component(query) + ) +} + +/// Whether `input` begins with a URL scheme (`scheme:` where the scheme matches +/// the WHATWG URL spec's restricted alphabet of `[A-Za-z][A-Za-z0-9+.-]*`). +/// +/// To avoid mis-classifying bare hosts with a port (e.g. `localhost:8080`), +/// the colon must either be followed by `//` (hierarchical URL) or by +/// something that is not a plain numeric port. The scheme must also be at +/// least two characters long, since a single letter before `:` is almost +/// always either an accident or a drive letter the user wouldn't type into +/// the URL bar without `file:///`. +fn has_url_scheme(input: &str) -> bool { + let bytes = input.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() { + return false; + } + let mut colon = None; + for (i, &b) in bytes.iter().enumerate().skip(1) { + if b == b':' { + colon = Some(i); + break; + } + if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') { + return false; + } + } + let colon = match colon { + Some(c) if c >= 2 => c, + _ => return false, + }; + let after = &bytes[colon + 1..]; + if after.starts_with(b"//") { + return true; + } + // A purely numeric segment after `:` (up to the next path/query/fragment + // delimiter) means this was a port, not a scheme. + let port_end = after + .iter() + .position(|&b| matches!(b, b'/' | b'?' | b'#')) + .unwrap_or(after.len()); + if port_end > 0 && after[..port_end].iter().all(|b| b.is_ascii_digit()) { + return false; + } + true +} + +/// Whether `input` looks like a bare host (possibly with port/path/query/ +/// fragment) the user expected the browser to load as `https://`. +fn looks_like_bare_host(input: &str) -> bool { + if input.chars().any(char::is_whitespace) { + return false; + } + // Isolate the host portion: everything before the first path/query/fragment. + let host = input.split(['/', '?', '#']).next().unwrap_or(""); + if host.is_empty() { + return false; + } + // Bracketed IPv6 literal. + if host.starts_with('[') { + return host.contains(']'); + } + // Strip a trailing `:port` if present, but only if the port is numeric — + // otherwise this colon meant something else and the input isn't a URL. + let host_only = match host.rsplit_once(':') { + Some((h, p)) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => h, + _ => host, + }; + if host_only.eq_ignore_ascii_case("localhost") { + return true; + } + // A dot in the host part (domain or IPv4) is the strongest signal that the + // user typed a URL. We don't try to validate the TLD — `http://foo.bar` + // works in a browser, so `foo.bar` typed in the URL bar should too. + host_only.contains('.') +} + +/// Percent-encode `input` for an `application/x-www-form-urlencoded` query +/// component: spaces become `+`, unreserved characters pass through, and +/// everything else becomes `%XX`. +fn form_urlencoded_component(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.as_bytes() { + match *byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'*' => { + out.push(*byte as char); + } + b' ' => out.push('+'), + _ => { + out.push('%'); + out.push(hex_upper(*byte >> 4)); + out.push(hex_upper(*byte & 0x0F)); + } + } + } + out +} + +fn hex_upper(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + 10..=15 => (b'A' + nibble - 10) as char, + _ => '0', + } +} + #[cfg(test)] mod tests { use super::*; @@ -221,4 +377,192 @@ mod tests { assert!(!address_bar_should_insert("\u{8}", KeyMods::NONE)); // Backspace. assert!(!address_bar_should_insert("\u{1b}", KeyMods::NONE)); // Escape. } + + #[test] + fn resolve_empty_input_is_none() { + assert_eq!(resolve_address_bar_input(""), None); + assert_eq!(resolve_address_bar_input(" "), None); + assert_eq!(resolve_address_bar_input("\t\n"), None); + } + + #[test] + fn resolve_full_urls_pass_through() { + assert_eq!( + resolve_address_bar_input("https://example.com/path?a=1"), + Some(AddressBarTarget::Url("https://example.com/path?a=1".into())), + ); + assert_eq!( + resolve_address_bar_input("http://example.com"), + Some(AddressBarTarget::Url("http://example.com".into())), + ); + } + + #[test] + fn resolve_special_schemes_pass_through() { + // about:, data:, file:, view-source: must not be turned into searches. + assert_eq!( + resolve_address_bar_input("about:blank"), + Some(AddressBarTarget::Url("about:blank".into())), + ); + assert_eq!( + resolve_address_bar_input("file:///tmp/page.html"), + Some(AddressBarTarget::Url("file:///tmp/page.html".into())), + ); + assert_eq!( + resolve_address_bar_input("data:text/html,

hi

"), + Some(AddressBarTarget::Url("data:text/html,

hi

".into())), + ); + assert_eq!( + resolve_address_bar_input("view-source:https://example.com"), + Some(AddressBarTarget::Url( + "view-source:https://example.com".into() + )), + ); + } + + #[test] + fn resolve_bare_domain_gets_https_prepended() { + assert_eq!( + resolve_address_bar_input("example.com"), + Some(AddressBarTarget::Url("https://example.com".into())), + ); + assert_eq!( + resolve_address_bar_input("sub.domain.example.org/path"), + Some(AddressBarTarget::Url( + "https://sub.domain.example.org/path".into() + )), + ); + } + + #[test] + fn resolve_localhost_is_url() { + assert_eq!( + resolve_address_bar_input("localhost"), + Some(AddressBarTarget::Url("https://localhost".into())), + ); + assert_eq!( + resolve_address_bar_input("localhost:8080"), + Some(AddressBarTarget::Url("https://localhost:8080".into())), + ); + assert_eq!( + resolve_address_bar_input("localhost:8080/api/v1"), + Some(AddressBarTarget::Url( + "https://localhost:8080/api/v1".into() + )), + ); + // Case-insensitive — macOS users frequently capitalize Localhost. + assert_eq!( + resolve_address_bar_input("Localhost"), + Some(AddressBarTarget::Url("https://Localhost".into())), + ); + } + + #[test] + fn resolve_ip_addresses_are_urls() { + assert_eq!( + resolve_address_bar_input("127.0.0.1"), + Some(AddressBarTarget::Url("https://127.0.0.1".into())), + ); + assert_eq!( + resolve_address_bar_input("127.0.0.1:8080"), + Some(AddressBarTarget::Url("https://127.0.0.1:8080".into())), + ); + assert_eq!( + resolve_address_bar_input("[::1]"), + Some(AddressBarTarget::Url("https://[::1]".into())), + ); + assert_eq!( + resolve_address_bar_input("[::1]:8080"), + Some(AddressBarTarget::Url("https://[::1]:8080".into())), + ); + } + + #[test] + fn resolve_query_with_spaces_is_search() { + assert_eq!( + resolve_address_bar_input("rust web browser"), + Some(AddressBarTarget::Search("rust web browser".into())), + ); + // Even input with a dot becomes a search when it contains whitespace, + // because no real URL has whitespace in it. + assert_eq!( + resolve_address_bar_input("what is example.com"), + Some(AddressBarTarget::Search("what is example.com".into())), + ); + } + + #[test] + fn resolve_single_word_with_no_dot_is_search() { + assert_eq!( + resolve_address_bar_input("rust"), + Some(AddressBarTarget::Search("rust".into())), + ); + assert_eq!( + resolve_address_bar_input("weather"), + Some(AddressBarTarget::Search("weather".into())), + ); + } + + #[test] + fn resolve_trims_surrounding_whitespace() { + assert_eq!( + resolve_address_bar_input(" example.com "), + Some(AddressBarTarget::Url("https://example.com".into())), + ); + assert_eq!( + resolve_address_bar_input(" hello world "), + Some(AddressBarTarget::Search("hello world".into())), + ); + } + + #[test] + fn build_search_url_encodes_query() { + assert_eq!( + build_search_url("rust web browser"), + "https://www.google.com/search?q=rust+web+browser", + ); + // Reserved characters get percent-encoded. + assert_eq!( + build_search_url("a&b=c d"), + "https://www.google.com/search?q=a%26b%3Dc+d", + ); + // Unreserved characters pass through. + assert_eq!( + build_search_url("hello-world_test.page*1"), + "https://www.google.com/search?q=hello-world_test.page*1", + ); + // UTF-8 multibyte characters get encoded byte-by-byte. + assert_eq!( + build_search_url("café"), + "https://www.google.com/search?q=caf%C3%A9", + ); + // The '?' character itself gets encoded (it is reserved in queries). + assert_eq!( + build_search_url("what is rust?"), + "https://www.google.com/search?q=what+is+rust%3F", + ); + } + + #[test] + fn has_url_scheme_recognises_known_shapes() { + assert!(has_url_scheme("http://example.com")); + assert!(has_url_scheme("https://example.com")); + assert!(has_url_scheme("about:blank")); + assert!(has_url_scheme("file:///tmp/x.html")); + assert!(has_url_scheme("data:text/plain,hi")); + assert!(has_url_scheme("view-source:https://x.test")); + assert!(has_url_scheme("x+y:foo")); + } + + #[test] + fn has_url_scheme_rejects_non_schemes() { + // A leading digit is not a valid scheme start. + assert!(!has_url_scheme("8tracks.com")); + // A bare port-like host is not a scheme either. + assert!(!has_url_scheme("localhost:8080")); + // No colon at all. + assert!(!has_url_scheme("example.com")); + // Empty. + assert!(!has_url_scheme("")); + } } diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 8cc158c..209da93 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -1614,19 +1614,20 @@ fn chrome_char_to_byte(text: &str, char_idx: usize) -> usize { .unwrap_or(text.len()) } -/// Navigate to a URL string from the address bar. +/// Navigate to a URL or run a search for the text the user typed into the +/// address bar. Anything that does not look like a URL (single bare word, +/// multi-word phrase, no recognizable host) is handed to the default search +/// engine instead of being mangled into an invalid URL. fn navigate_to_address(state: &mut BrowserState, input: &str) { - let input = input.trim(); - if input.is_empty() { - return; - } + use we_browser::chrome::{build_search_url, resolve_address_bar_input, AddressBarTarget}; - // If it looks like a URL (has scheme or dots), use it directly. - // Otherwise, prepend https://. - let url_str = if input.contains("://") || input.starts_with("about:") { - input.to_string() - } else { - format!("https://{input}") + let target = match resolve_address_bar_input(input) { + Some(t) => t, + None => return, + }; + let url_str = match target { + AddressBarTarget::Url(u) => u, + AddressBarTarget::Search(q) => build_search_url(&q), }; state.chrome.focused = false;