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
119fn classify_browser_address_value(raw: &str) -> BrowserAddressValueKind {
120    let trimmed = raw.trim();
121    if trimmed.is_empty() {
122        BrowserAddressValueKind::Empty
123    } else if trimmed.contains(char::is_whitespace) || !trimmed.contains("://") {
124        BrowserAddressValueKind::SearchQuery
125    } else {
126        BrowserAddressValueKind::Invalid
127    }
128}
129
130pub fn resolve_input(request: BrowserAddressInputRequest) -> BrowserAddressInputResponse {
131    let preferred_scheme =
132        normalize_browser_preferred_scheme(request.context.preferred_scheme.as_deref());
133    let trimmed = request.raw_input.trim();
134
135    // `lingxia://` (in-app) and `file://` (local files) navigate as-is.
136    if matches!(
137        extract_url_scheme(trimmed).as_deref(),
138        Some(LINGXIA_SCHEME) | Some("file")
139    ) {
140        let url = trimmed.to_string();
141        let action = match request.trigger {
142            BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
143            BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
144        };
145        let state = BrowserAddressState {
146            raw_input: request.raw_input,
147            normalized_input: url.clone(),
148            display_text: url.clone(),
149            value_kind: BrowserAddressValueKind::Url,
150            canonical_url: Some(url.clone()),
151            inferred_scheme: None,
152        };
153        let navigation = BrowserAddressNavigation {
154            url,
155            target: BrowserNavigationTarget::CurrentTab,
156        };
157        return BrowserAddressInputResponse {
158            action,
159            state,
160            navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
161            suggestions: None,
162            error: None,
163        };
164    }
165
166    if let Some(resolved) = resolve_browser_http_url(trimmed, &preferred_scheme) {
167        let BrowserUrlResolution {
168            url: resolved_url,
169            inferred_scheme,
170        } = resolved;
171
172        let action = match request.trigger {
173            BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
174            BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
175        };
176
177        let display_text = resolved_url.clone();
178        let state = BrowserAddressState {
179            raw_input: request.raw_input,
180            normalized_input: display_text.clone(),
181            display_text,
182            value_kind: BrowserAddressValueKind::Url,
183            canonical_url: Some(resolved_url.clone()),
184            inferred_scheme,
185        };
186
187        let navigation = BrowserAddressNavigation {
188            url: resolved_url,
189            target: BrowserNavigationTarget::CurrentTab,
190        };
191
192        return BrowserAddressInputResponse {
193            action,
194            state,
195            navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
196            suggestions: None,
197            error: None,
198        };
199    }
200
201    let value_kind = classify_browser_address_value(trimmed);
202    let normalized = trimmed.to_string();
203    let state = BrowserAddressState {
204        raw_input: request.raw_input,
205        display_text: normalized.clone(),
206        normalized_input: normalized,
207        value_kind,
208        canonical_url: None,
209        inferred_scheme: None,
210    };
211
212    let should_suggest = matches!(request.trigger, BrowserAddressInputTrigger::Edit)
213        || (matches!(value_kind, BrowserAddressValueKind::SearchQuery)
214            && request.context.allow_search_fallback);
215
216    if should_suggest {
217        return BrowserAddressInputResponse {
218            action: BrowserAddressAction::Suggest,
219            state,
220            navigation: None,
221            suggestions: None,
222            error: None,
223        };
224    }
225
226    let error = match value_kind {
227        BrowserAddressValueKind::Empty => BrowserAddressInputError {
228            code: "empty_input".to_string(),
229            message: "Address input is empty".to_string(),
230        },
231        BrowserAddressValueKind::SearchQuery => BrowserAddressInputError {
232            code: "search_fallback_unavailable".to_string(),
233            message: "Search fallback is not enabled for this browser input".to_string(),
234        },
235        BrowserAddressValueKind::Invalid | BrowserAddressValueKind::Url => {
236            BrowserAddressInputError {
237                code: "invalid_url".to_string(),
238                message: "Address input is not a supported URL".to_string(),
239            }
240        }
241    };
242
243    BrowserAddressInputResponse {
244        action: BrowserAddressAction::Reject,
245        state,
246        navigation: None,
247        suggestions: None,
248        error: Some(error),
249    }
250}
251
252pub fn resolve_input_json(request_json: &str) -> Option<String> {
253    let request: BrowserAddressInputRequest = serde_json::from_str(request_json).ok()?;
254    serde_json::to_string(&resolve_input(request)).ok()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::resolve_input;
260    use lingxia_browser::{
261        BrowserAddressAction, BrowserAddressInputContext, BrowserAddressInputRequest,
262        BrowserAddressInputTrigger, BrowserAddressValueKind,
263    };
264
265    #[test]
266    fn submit_without_scheme_navigates_with_https() {
267        let response = resolve_input(BrowserAddressInputRequest {
268            raw_input: "example.com/docs".to_string(),
269            trigger: BrowserAddressInputTrigger::Submit,
270            context: BrowserAddressInputContext::default(),
271        });
272
273        assert_eq!(response.action, BrowserAddressAction::Navigate);
274        assert_eq!(
275            response.navigation.as_ref().map(|value| value.url.as_str()),
276            Some("https://example.com/docs")
277        );
278        assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
279        assert_eq!(response.state.inferred_scheme.as_deref(), Some("https"));
280    }
281
282    #[test]
283    fn submit_keeps_http_fragments() {
284        let response = resolve_input(BrowserAddressInputRequest {
285            raw_input: "http://example.com/path?q=1#frag".to_string(),
286            trigger: BrowserAddressInputTrigger::Submit,
287            context: BrowserAddressInputContext::default(),
288        });
289
290        assert_eq!(response.action, BrowserAddressAction::Navigate);
291        assert_eq!(
292            response.navigation.as_ref().map(|value| value.url.as_str()),
293            Some("http://example.com/path?q=1#frag")
294        );
295        assert_eq!(response.state.inferred_scheme, None);
296    }
297
298    #[test]
299    fn submit_supports_localhost() {
300        let response = resolve_input(BrowserAddressInputRequest {
301            raw_input: "localhost:3000".to_string(),
302            trigger: BrowserAddressInputTrigger::Submit,
303            context: BrowserAddressInputContext::default(),
304        });
305
306        assert_eq!(response.action, BrowserAddressAction::Navigate);
307        assert_eq!(
308            response.navigation.as_ref().map(|value| value.url.as_str()),
309            Some("https://localhost:3000")
310        );
311    }
312
313    #[test]
314    fn edit_search_query_returns_suggest_action() {
315        let response = resolve_input(BrowserAddressInputRequest {
316            raw_input: "openai docs".to_string(),
317            trigger: BrowserAddressInputTrigger::Edit,
318            context: BrowserAddressInputContext::default(),
319        });
320
321        assert_eq!(response.action, BrowserAddressAction::Suggest);
322        assert_eq!(
323            response.state.value_kind,
324            BrowserAddressValueKind::SearchQuery
325        );
326        assert!(response.navigation.is_none());
327    }
328
329    #[test]
330    fn submit_search_query_rejects_when_fallback_is_disabled() {
331        let response = resolve_input(BrowserAddressInputRequest {
332            raw_input: "openai".to_string(),
333            trigger: BrowserAddressInputTrigger::Submit,
334            context: BrowserAddressInputContext::default(),
335        });
336
337        assert_eq!(response.action, BrowserAddressAction::Reject);
338        assert_eq!(
339            response.error.as_ref().map(|value| value.code.as_str()),
340            Some("search_fallback_unavailable")
341        );
342        assert_eq!(
343            response.state.value_kind,
344            BrowserAddressValueKind::SearchQuery
345        );
346    }
347
348    #[test]
349    fn address_input_submit_lingxia_newtab_navigates() {
350        let response = resolve_input(BrowserAddressInputRequest {
351            raw_input: "lingxia://newtab".to_string(),
352            trigger: BrowserAddressInputTrigger::Submit,
353            context: BrowserAddressInputContext::default(),
354        });
355        assert_eq!(response.action, BrowserAddressAction::Navigate);
356        assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
357        assert_eq!(
358            response.navigation.as_ref().map(|n| n.url.as_str()),
359            Some("lingxia://newtab")
360        );
361    }
362
363    #[test]
364    fn address_input_submit_lingxia_transfer_navigates() {
365        let response = resolve_input(BrowserAddressInputRequest {
366            raw_input: "lingxia://downloads".to_string(),
367            trigger: BrowserAddressInputTrigger::Submit,
368            context: BrowserAddressInputContext::default(),
369        });
370        assert_eq!(response.action, BrowserAddressAction::Navigate);
371        assert_eq!(
372            response.navigation.as_ref().map(|n| n.url.as_str()),
373            Some("lingxia://downloads")
374        );
375    }
376
377    #[test]
378    fn submit_file_url_navigates_as_is() {
379        let response = resolve_input(BrowserAddressInputRequest {
380            raw_input: "file:///Users/me/page.html".to_string(),
381            trigger: BrowserAddressInputTrigger::Submit,
382            context: BrowserAddressInputContext::default(),
383        });
384        assert_eq!(response.action, BrowserAddressAction::Navigate);
385        assert_eq!(
386            response.navigation.as_ref().map(|n| n.url.as_str()),
387            Some("file:///Users/me/page.html")
388        );
389    }
390}