Skip to main content

lingxia_browser_shell/
address_bar.rs

1use lingxia_browser::{
2    BrowserAddressAction, BrowserAddressInputError, BrowserAddressInputRequest,
3    BrowserAddressInputResponse, BrowserAddressInputTrigger, BrowserAddressNavigation,
4    BrowserAddressState, BrowserAddressValueKind, BrowserNavigationTarget, extract_url_scheme,
5};
6use std::net::IpAddr;
7
8const DEFAULT_BROWSER_PREFERRED_SCHEME: &str = "https";
9const LINGXIA_SCHEME: &str = "lingxia";
10
11#[derive(Debug, Clone)]
12struct BrowserUrlResolution {
13    url: String,
14    inferred_scheme: Option<String>,
15}
16
17fn normalize_browser_preferred_scheme(raw: Option<&str>) -> String {
18    let candidate = raw
19        .map(str::trim)
20        .filter(|value| !value.is_empty())
21        .unwrap_or(DEFAULT_BROWSER_PREFERRED_SCHEME);
22    let lowered = candidate.to_ascii_lowercase();
23    match lowered.as_str() {
24        "http" | "https" => lowered,
25        _ => DEFAULT_BROWSER_PREFERRED_SCHEME.to_string(),
26    }
27}
28
29fn extract_host_from_authority(authority: &str) -> Option<&str> {
30    let authority = authority.rsplit('@').next()?;
31    if authority.is_empty() {
32        return None;
33    }
34
35    if let Some(rest) = authority.strip_prefix('[') {
36        let end = rest.find(']')?;
37        let host = &rest[..end];
38        if host.is_empty() {
39            return None;
40        }
41        let suffix = &rest[end + 1..];
42        if suffix.is_empty() {
43            return Some(host);
44        }
45        if !suffix.starts_with(':') || suffix.len() == 1 {
46            return None;
47        }
48        if suffix[1..].chars().all(|c| c.is_ascii_digit()) {
49            return Some(host);
50        }
51        return None;
52    }
53
54    let host = match authority.rsplit_once(':') {
55        Some((host, port))
56            if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) =>
57        {
58            host
59        }
60        Some(_) => return None,
61        _ => authority,
62    };
63
64    if host.is_empty() { None } else { Some(host) }
65}
66
67fn is_probable_web_host_without_scheme(host: &str) -> bool {
68    if host.eq_ignore_ascii_case("localhost") {
69        return true;
70    }
71    if host.contains('.') {
72        return true;
73    }
74    host.parse::<IpAddr>().is_ok()
75}
76
77fn resolve_browser_http_url(raw: &str, preferred_scheme: &str) -> Option<BrowserUrlResolution> {
78    let trimmed = raw.trim();
79    if trimmed.is_empty() {
80        return None;
81    }
82    if trimmed.chars().any(|c| c.is_whitespace()) {
83        return None;
84    }
85
86    let (candidate, inferred_scheme) = if trimmed.contains("://") {
87        (trimmed.to_string(), None)
88    } else {
89        (
90            format!("{preferred_scheme}://{trimmed}"),
91            Some(preferred_scheme.to_string()),
92        )
93    };
94
95    let (scheme_raw, rest) = candidate.split_once("://")?;
96    let scheme = scheme_raw.to_ascii_lowercase();
97    if !matches!(scheme.as_str(), "http" | "https") {
98        return None;
99    }
100    if rest.is_empty() || rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#') {
101        return None;
102    }
103
104    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
105    let host = extract_host_from_authority(authority)?;
106    if host.trim().is_empty() || host.chars().any(|c| c.is_whitespace()) {
107        return None;
108    }
109    if inferred_scheme.is_some() && !is_probable_web_host_without_scheme(host) {
110        return None;
111    }
112
113    Some(BrowserUrlResolution {
114        url: format!("{scheme}://{rest}"),
115        inferred_scheme,
116    })
117}
118
119pub(crate) fn is_valid_explicit_http_url(raw: &str) -> bool {
120    resolve_browser_http_url(raw, DEFAULT_BROWSER_PREFERRED_SCHEME)
121        .is_some_and(|resolved| resolved.inferred_scheme.is_none())
122}
123
124fn classify_browser_address_value(raw: &str) -> BrowserAddressValueKind {
125    let trimmed = raw.trim();
126    if trimmed.is_empty() {
127        BrowserAddressValueKind::Empty
128    } else if trimmed.contains(char::is_whitespace) || !trimmed.contains("://") {
129        BrowserAddressValueKind::SearchQuery
130    } else {
131        BrowserAddressValueKind::Invalid
132    }
133}
134
135pub fn resolve_input(request: BrowserAddressInputRequest) -> BrowserAddressInputResponse {
136    let preferred_scheme =
137        normalize_browser_preferred_scheme(request.context.preferred_scheme.as_deref());
138    let trimmed = request.raw_input.trim();
139
140    // `lingxia://` (in-app) and `file://` (local files) navigate as-is.
141    if matches!(
142        extract_url_scheme(trimmed).as_deref(),
143        Some(LINGXIA_SCHEME) | Some("file")
144    ) {
145        let url = trimmed.to_string();
146        let action = match request.trigger {
147            BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
148            BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
149        };
150        let state = BrowserAddressState {
151            raw_input: request.raw_input,
152            normalized_input: url.clone(),
153            display_text: url.clone(),
154            value_kind: BrowserAddressValueKind::Url,
155            canonical_url: Some(url.clone()),
156            inferred_scheme: None,
157        };
158        let navigation = BrowserAddressNavigation {
159            url,
160            target: BrowserNavigationTarget::CurrentTab,
161        };
162        return BrowserAddressInputResponse {
163            action,
164            state,
165            navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
166            suggestions: None,
167            error: None,
168        };
169    }
170
171    if let Some(resolved) = resolve_browser_http_url(trimmed, &preferred_scheme) {
172        let BrowserUrlResolution {
173            url: resolved_url,
174            inferred_scheme,
175        } = resolved;
176
177        let action = match request.trigger {
178            BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
179            BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
180        };
181
182        let display_text = resolved_url.clone();
183        let state = BrowserAddressState {
184            raw_input: request.raw_input,
185            normalized_input: display_text.clone(),
186            display_text,
187            value_kind: BrowserAddressValueKind::Url,
188            canonical_url: Some(resolved_url.clone()),
189            inferred_scheme,
190        };
191
192        let navigation = BrowserAddressNavigation {
193            url: resolved_url,
194            target: BrowserNavigationTarget::CurrentTab,
195        };
196
197        return BrowserAddressInputResponse {
198            action,
199            state,
200            navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
201            suggestions: None,
202            error: None,
203        };
204    }
205
206    let value_kind = classify_browser_address_value(trimmed);
207    let normalized = trimmed.to_string();
208    let state = BrowserAddressState {
209        raw_input: request.raw_input,
210        display_text: normalized.clone(),
211        normalized_input: normalized,
212        value_kind,
213        canonical_url: None,
214        inferred_scheme: None,
215    };
216
217    let should_suggest = matches!(request.trigger, BrowserAddressInputTrigger::Edit)
218        || (matches!(value_kind, BrowserAddressValueKind::SearchQuery)
219            && request.context.allow_search_fallback);
220
221    if should_suggest {
222        return BrowserAddressInputResponse {
223            action: BrowserAddressAction::Suggest,
224            state,
225            navigation: None,
226            suggestions: None,
227            error: None,
228        };
229    }
230
231    let error = match value_kind {
232        BrowserAddressValueKind::Empty => BrowserAddressInputError {
233            code: "empty_input".to_string(),
234            message: "Address input is empty".to_string(),
235        },
236        BrowserAddressValueKind::SearchQuery => BrowserAddressInputError {
237            code: "search_fallback_unavailable".to_string(),
238            message: "Search fallback is not enabled for this browser input".to_string(),
239        },
240        BrowserAddressValueKind::Invalid | BrowserAddressValueKind::Url => {
241            BrowserAddressInputError {
242                code: "invalid_url".to_string(),
243                message: "Address input is not a supported URL".to_string(),
244            }
245        }
246    };
247
248    BrowserAddressInputResponse {
249        action: BrowserAddressAction::Reject,
250        state,
251        navigation: None,
252        suggestions: None,
253        error: Some(error),
254    }
255}
256
257pub fn resolve_input_json(request_json: &str) -> Option<String> {
258    let request: BrowserAddressInputRequest = serde_json::from_str(request_json).ok()?;
259    serde_json::to_string(&resolve_input(request)).ok()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::resolve_input;
265    use lingxia_browser::{
266        BrowserAddressAction, BrowserAddressInputContext, BrowserAddressInputRequest,
267        BrowserAddressInputTrigger, BrowserAddressValueKind,
268    };
269
270    #[test]
271    fn submit_without_scheme_navigates_with_https() {
272        let response = resolve_input(BrowserAddressInputRequest {
273            raw_input: "example.com/docs".to_string(),
274            trigger: BrowserAddressInputTrigger::Submit,
275            context: BrowserAddressInputContext::default(),
276        });
277
278        assert_eq!(response.action, BrowserAddressAction::Navigate);
279        assert_eq!(
280            response.navigation.as_ref().map(|value| value.url.as_str()),
281            Some("https://example.com/docs")
282        );
283        assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
284        assert_eq!(response.state.inferred_scheme.as_deref(), Some("https"));
285    }
286
287    #[test]
288    fn submit_keeps_http_fragments() {
289        let response = resolve_input(BrowserAddressInputRequest {
290            raw_input: "http://example.com/path?q=1#frag".to_string(),
291            trigger: BrowserAddressInputTrigger::Submit,
292            context: BrowserAddressInputContext::default(),
293        });
294
295        assert_eq!(response.action, BrowserAddressAction::Navigate);
296        assert_eq!(
297            response.navigation.as_ref().map(|value| value.url.as_str()),
298            Some("http://example.com/path?q=1#frag")
299        );
300        assert_eq!(response.state.inferred_scheme, None);
301    }
302
303    #[test]
304    fn submit_supports_localhost() {
305        let response = resolve_input(BrowserAddressInputRequest {
306            raw_input: "localhost:3000".to_string(),
307            trigger: BrowserAddressInputTrigger::Submit,
308            context: BrowserAddressInputContext::default(),
309        });
310
311        assert_eq!(response.action, BrowserAddressAction::Navigate);
312        assert_eq!(
313            response.navigation.as_ref().map(|value| value.url.as_str()),
314            Some("https://localhost:3000")
315        );
316    }
317
318    #[test]
319    fn edit_search_query_returns_suggest_action() {
320        let response = resolve_input(BrowserAddressInputRequest {
321            raw_input: "openai docs".to_string(),
322            trigger: BrowserAddressInputTrigger::Edit,
323            context: BrowserAddressInputContext::default(),
324        });
325
326        assert_eq!(response.action, BrowserAddressAction::Suggest);
327        assert_eq!(
328            response.state.value_kind,
329            BrowserAddressValueKind::SearchQuery
330        );
331        assert!(response.navigation.is_none());
332    }
333
334    #[test]
335    fn submit_search_query_rejects_when_fallback_is_disabled() {
336        let response = resolve_input(BrowserAddressInputRequest {
337            raw_input: "openai".to_string(),
338            trigger: BrowserAddressInputTrigger::Submit,
339            context: BrowserAddressInputContext::default(),
340        });
341
342        assert_eq!(response.action, BrowserAddressAction::Reject);
343        assert_eq!(
344            response.error.as_ref().map(|value| value.code.as_str()),
345            Some("search_fallback_unavailable")
346        );
347        assert_eq!(
348            response.state.value_kind,
349            BrowserAddressValueKind::SearchQuery
350        );
351    }
352
353    #[test]
354    fn address_input_submit_lingxia_newtab_navigates() {
355        let response = resolve_input(BrowserAddressInputRequest {
356            raw_input: "lingxia://newtab".to_string(),
357            trigger: BrowserAddressInputTrigger::Submit,
358            context: BrowserAddressInputContext::default(),
359        });
360        assert_eq!(response.action, BrowserAddressAction::Navigate);
361        assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
362        assert_eq!(
363            response.navigation.as_ref().map(|n| n.url.as_str()),
364            Some("lingxia://newtab")
365        );
366    }
367
368    #[test]
369    fn address_input_submit_lingxia_transfer_navigates() {
370        let response = resolve_input(BrowserAddressInputRequest {
371            raw_input: "lingxia://downloads".to_string(),
372            trigger: BrowserAddressInputTrigger::Submit,
373            context: BrowserAddressInputContext::default(),
374        });
375        assert_eq!(response.action, BrowserAddressAction::Navigate);
376        assert_eq!(
377            response.navigation.as_ref().map(|n| n.url.as_str()),
378            Some("lingxia://downloads")
379        );
380    }
381
382    #[test]
383    fn submit_file_url_navigates_as_is() {
384        let response = resolve_input(BrowserAddressInputRequest {
385            raw_input: "file:///Users/me/page.html".to_string(),
386            trigger: BrowserAddressInputTrigger::Submit,
387            context: BrowserAddressInputContext::default(),
388        });
389        assert_eq!(response.action, BrowserAddressAction::Navigate);
390        assert_eq!(
391            response.navigation.as_ref().map(|n| n.url.as_str()),
392            Some("file:///Users/me/page.html")
393        );
394    }
395}