1use crate::types::{
4 BrowserNavigationPolicyDecision, BrowserNavigationPolicyRequest,
5 BrowserNavigationPolicyResponse,
6};
7use std::time::{Duration, Instant};
8
9pub(crate) const LINGXIA_SCHEME: &str = "lingxia";
10const BROWSER_IN_WEBVIEW_SCHEMES: &[&str] = &["http", "https", "lx", "lingxia", "file"];
13const BROWSER_NON_EXTERNAL_SCHEMES: &[&str] = &["about", "data", "blob", "javascript"];
14const TRANSIENT_USER_ACTIVATION_TTL: Duration = Duration::from_secs(10);
15
16#[derive(Debug, Default)]
22struct BrowserTransientUserActivation {
23 expires_at: Option<Instant>,
24}
25
26impl BrowserTransientUserActivation {
27 fn apply_at(&mut self, request: &mut BrowserNavigationPolicyRequest, now: Instant) -> bool {
28 if self.expires_at.is_some_and(|expires_at| now >= expires_at) {
29 self.expires_at = None;
30 }
31
32 if !request.is_main_frame {
33 return false;
34 }
35
36 let scheme = extract_url_scheme(request.raw_url.trim());
37 let can_begin_redirect_chain = matches!(scheme.as_deref(), Some("http" | "https"));
38 let is_external = scheme.as_deref().is_some_and(|scheme| {
39 !scheme_in_list(scheme, BROWSER_IN_WEBVIEW_SCHEMES)
40 && !scheme_in_list(scheme, BROWSER_NON_EXTERNAL_SCHEMES)
41 });
42
43 if request.has_user_gesture {
44 self.expires_at =
45 can_begin_redirect_chain.then_some(now + TRANSIENT_USER_ACTIVATION_TTL);
46 return false;
47 }
48
49 if is_external
50 && self
51 .expires_at
52 .take()
53 .is_some_and(|expires_at| now < expires_at)
54 {
55 request.has_user_gesture = true;
56 return true;
57 }
58
59 false
60 }
61}
62
63pub(crate) struct BrowserNavigationPolicyEvaluation {
64 pub response: BrowserNavigationPolicyResponse,
65 pub inherited_user_activation: bool,
66}
67
68#[derive(Debug, Default)]
70pub(crate) struct BrowserNavigationPolicySession {
71 user_activation: BrowserTransientUserActivation,
72}
73
74impl BrowserNavigationPolicySession {
75 pub(crate) fn evaluate(
76 &mut self,
77 request: BrowserNavigationPolicyRequest,
78 ) -> BrowserNavigationPolicyEvaluation {
79 self.evaluate_at(request, Instant::now())
80 }
81
82 fn evaluate_at(
83 &mut self,
84 mut request: BrowserNavigationPolicyRequest,
85 now: Instant,
86 ) -> BrowserNavigationPolicyEvaluation {
87 let inherited_user_activation = self.user_activation.apply_at(&mut request, now);
88 BrowserNavigationPolicyEvaluation {
89 response: handle_browser_navigation_policy(request),
90 inherited_user_activation,
91 }
92 }
93}
94
95pub fn extract_url_scheme(raw: &str) -> Option<String> {
98 let (scheme, _) = raw.split_once(':')?;
99 if scheme.is_empty() {
100 return None;
101 }
102 let is_valid = scheme
103 .chars()
104 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
105 if !is_valid {
106 return None;
107 }
108 Some(scheme.to_ascii_lowercase())
109}
110
111pub fn is_lingxia_startup_url(url: &str) -> Option<bool> {
118 if extract_url_scheme(url).as_deref() != Some(LINGXIA_SCHEME) {
119 return None;
120 }
121 let host = lingxia_url_host(url);
122 Some(host.is_empty() || host == "newtab")
123}
124
125pub(crate) fn lingxia_url_host(url: &str) -> String {
126 url.split_once("://")
127 .map(|x| x.1)
128 .unwrap_or("")
129 .split(['/', '?', '#'])
130 .next()
131 .unwrap_or("")
132 .to_ascii_lowercase()
133}
134
135pub(crate) fn normalize_browser_target_url(raw: &str) -> String {
136 let trimmed = raw.trim();
137 if trimmed
140 .as_bytes()
141 .get(..7)
142 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"http://"))
143 {
144 if url_host_is_loopback(trimmed)
148 || (lxapp::is_dev_session() && preserves_plain_http_for_private_url(trimmed))
149 {
150 return trimmed.to_string();
151 }
152 format!("https://{}", &trimmed[7..])
154 } else {
155 trimmed.to_string()
156 }
157}
158
159fn url_host_is_loopback(trimmed: &str) -> bool {
162 let Ok(uri) = trimmed.parse::<http::Uri>() else {
163 return false;
164 };
165 uri.host().is_some_and(is_loopback_http_host)
166}
167
168fn is_loopback_http_host(host: &str) -> bool {
169 let host = host
170 .trim_matches(|ch| ch == '[' || ch == ']')
171 .trim_end_matches('.')
172 .to_ascii_lowercase();
173 if host == "localhost" || host.ends_with(".localhost") {
174 return true;
175 }
176 host.parse::<std::net::IpAddr>()
177 .is_ok_and(|ip| ip.is_loopback())
178}
179
180fn preserves_plain_http_for_private_url(trimmed: &str) -> bool {
181 let Ok(uri) = trimmed.parse::<http::Uri>() else {
182 return false;
183 };
184 if uri
185 .scheme_str()
186 .is_none_or(|scheme| !scheme.eq_ignore_ascii_case("http"))
187 {
188 return false;
189 }
190 uri.host().is_some_and(is_private_http_host)
191}
192
193fn is_private_http_host(host: &str) -> bool {
194 let host = host
195 .trim_matches(|ch| ch == '[' || ch == ']')
196 .trim_end_matches('.')
197 .to_ascii_lowercase();
198
199 let Ok(ip) = host.parse::<std::net::IpAddr>() else {
200 return false;
201 };
202 match ip {
203 std::net::IpAddr::V4(ip) => ip.is_private() || ip.is_link_local(),
204 std::net::IpAddr::V6(ip) => {
205 let first_segment = ip.segments()[0];
206 (first_segment & 0xfe00) == 0xfc00 || (first_segment & 0xffc0) == 0xfe80
207 }
208 }
209}
210
211pub fn normalize_url_for_wait_compare(raw: &str) -> String {
212 let normalized = normalize_browser_target_url(raw);
213 let trimmed = normalized.trim();
214 let Ok(uri) = trimmed.parse::<http::Uri>() else {
215 return trimmed.to_string();
216 };
217 let Some(scheme) = uri.scheme_str().map(str::to_ascii_lowercase) else {
218 return trimmed.to_string();
219 };
220 if !matches!(scheme.as_str(), "http" | "https") {
221 return trimmed.to_string();
222 }
223 let Some(host) = uri.host() else {
224 return trimmed.to_string();
225 };
226 let host = host.to_ascii_lowercase();
227 let host = if host.contains(':') && !host.starts_with('[') {
228 format!("[{host}]")
229 } else {
230 host
231 };
232 let port = uri
233 .port()
234 .map(|port| format!(":{}", port.as_str()))
235 .unwrap_or_default();
236 let path_and_query = uri
237 .path_and_query()
238 .map(|value| value.as_str())
239 .filter(|value| !value.is_empty())
240 .unwrap_or("/");
241 format!("{scheme}://{host}{port}{path_and_query}")
242}
243
244fn scheme_in_list(scheme: &str, candidates: &[&str]) -> bool {
245 candidates
246 .iter()
247 .any(|candidate| candidate.eq_ignore_ascii_case(scheme))
248}
249
250fn browser_policy_response(
251 decision: BrowserNavigationPolicyDecision,
252 reason: Option<&str>,
253) -> BrowserNavigationPolicyResponse {
254 BrowserNavigationPolicyResponse {
255 decision,
256 reason: reason.map(str::to_string),
257 }
258}
259
260pub(crate) fn handle_browser_navigation_policy(
270 request: BrowserNavigationPolicyRequest,
271) -> BrowserNavigationPolicyResponse {
272 let trimmed = request.raw_url.trim();
273 if trimmed.is_empty() {
274 return browser_policy_response(BrowserNavigationPolicyDecision::Deny, Some("empty"));
275 }
276
277 if trimmed.chars().any(|c| c.is_whitespace()) {
278 return browser_policy_response(
279 BrowserNavigationPolicyDecision::Deny,
280 Some("whitespace_url"),
281 );
282 }
283
284 let Some(scheme) = extract_url_scheme(trimmed) else {
285 return browser_policy_response(
286 BrowserNavigationPolicyDecision::Deny,
287 Some("missing_scheme"),
288 );
289 };
290
291 if !request.is_main_frame && matches!(scheme.as_str(), "lx" | "lingxia") {
292 return browser_policy_response(
293 BrowserNavigationPolicyDecision::Deny,
294 Some("non_main_frame_internal"),
295 );
296 }
297
298 if scheme_in_list(&scheme, BROWSER_IN_WEBVIEW_SCHEMES) {
299 return browser_policy_response(BrowserNavigationPolicyDecision::InWebview, None);
300 }
301
302 if scheme_in_list(&scheme, BROWSER_NON_EXTERNAL_SCHEMES) {
303 return browser_policy_response(
304 BrowserNavigationPolicyDecision::Deny,
305 Some("non_external_scheme"),
306 );
307 }
308
309 if !request.is_main_frame {
310 return browser_policy_response(
311 BrowserNavigationPolicyDecision::Deny,
312 Some("non_main_frame_external"),
313 );
314 }
315
316 if !request.has_user_gesture {
317 return browser_policy_response(
318 BrowserNavigationPolicyDecision::Deny,
319 Some("gesture_required"),
320 );
321 }
322
323 browser_policy_response(BrowserNavigationPolicyDecision::OpenExternal, None)
324}
325
326pub(crate) fn handle_browser_navigation_policy_json(request_json: &str) -> Option<String> {
327 let request: BrowserNavigationPolicyRequest = serde_json::from_str(request_json).ok()?;
328 serde_json::to_string(&handle_browser_navigation_policy(request)).ok()
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn normalize_browser_target_url_upgrades_http_case_insensitively() {
337 assert_eq!(
338 normalize_browser_target_url(" HTTP://Example.com/path?q=1 "),
339 "https://Example.com/path?q=1"
340 );
341 assert_eq!(
342 normalize_browser_target_url("http://example.com"),
343 "https://example.com"
344 );
345 assert_eq!(
346 normalize_browser_target_url("https://example.com"),
347 "https://example.com"
348 );
349 }
350
351 #[test]
352 fn normalize_browser_target_url_upgrades_private_ip_http_outside_dev_session() {
353 assert_eq!(
354 normalize_browser_target_url("http://192.168.1.16:8080/activate?user_code=1234"),
355 "https://192.168.1.16:8080/activate?user_code=1234"
356 );
357 assert_eq!(
358 normalize_browser_target_url("http://10.0.0.4:8080/activate"),
359 "https://10.0.0.4:8080/activate"
360 );
361 assert_eq!(
362 normalize_browser_target_url("http://172.16.0.8:8080/activate"),
363 "https://172.16.0.8:8080/activate"
364 );
365 }
366
367 #[test]
368 fn normalize_browser_target_url_preserves_loopback_http_unconditionally() {
369 assert_eq!(
372 normalize_browser_target_url("http://127.0.0.1:8080/activate"),
373 "http://127.0.0.1:8080/activate"
374 );
375 assert_eq!(
376 normalize_browser_target_url("http://localhost:8799/"),
377 "http://localhost:8799/"
378 );
379 assert_eq!(
380 normalize_browser_target_url("http://app.localhost:3000/x"),
381 "http://app.localhost:3000/x"
382 );
383 assert_eq!(
384 normalize_browser_target_url("http://[::1]:8080/"),
385 "http://[::1]:8080/"
386 );
387 assert_eq!(
389 normalize_browser_target_url("http://example.com/"),
390 "https://example.com/"
391 );
392 }
393
394 #[test]
395 fn normalize_browser_target_url_handles_multibyte_input() {
396 assert_eq!(normalize_browser_target_url("http🌐//x"), "http🌐//x");
398 assert_eq!(normalize_browser_target_url("中文网址"), "中文网址");
399 assert_eq!(
400 normalize_browser_target_url("http://例え.jp/路径"),
401 "https://例え.jp/路径"
402 );
403 }
404
405 #[test]
406 fn lingxia_startup_host_ignores_path_query_and_fragment_delimiters() {
407 assert_eq!(
408 lingxia_url_host("lingxia://settings#clear-browsing-data"),
409 "settings"
410 );
411 assert_eq!(lingxia_url_host("lingxia://history/?q=deepseek"), "history");
412 assert_eq!(is_lingxia_startup_url("lingxia://newtab#top"), Some(true));
413 assert_eq!(
414 is_lingxia_startup_url("lingxia://settings#clear-browsing-data"),
415 Some(false)
416 );
417 }
418
419 #[test]
420 fn normalize_url_for_wait_compare_canonicalizes_browser_urls() {
421 assert_eq!(
422 normalize_url_for_wait_compare("https://Example.com"),
423 "https://example.com/"
424 );
425 assert_eq!(
426 normalize_url_for_wait_compare("http://example.com"),
427 "https://example.com/"
428 );
429 assert_eq!(
430 normalize_url_for_wait_compare("https://[::1]:8443/path?q=1"),
431 "https://[::1]:8443/path?q=1"
432 );
433 }
434
435 #[test]
436 fn browser_nav_policy_allows_lark_with_gesture() {
437 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
438 raw_url: "lark://client/auth?code=1".to_string(),
439 has_user_gesture: true,
440 is_main_frame: true,
441 });
442
443 assert_eq!(
444 response.decision,
445 BrowserNavigationPolicyDecision::OpenExternal
446 );
447 }
448
449 #[test]
450 fn browser_nav_policy_opens_dingtalk_main_frame_with_gesture() {
451 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
452 raw_url: "dingtalk://dingtalkclient/page/link?url=https%3A%2F%2Fexample.com"
453 .to_string(),
454 has_user_gesture: true,
455 is_main_frame: true,
456 });
457
458 assert_eq!(
459 response.decision,
460 BrowserNavigationPolicyDecision::OpenExternal
461 );
462 }
463
464 #[test]
465 fn browser_activation_survives_redirect_chain_and_is_consumed_once() {
466 let now = Instant::now();
467 let mut session = BrowserNavigationPolicySession::default();
468 let clicked_https = BrowserNavigationPolicyRequest {
469 raw_url: "https://login.example/oauth/start".to_string(),
470 has_user_gesture: true,
471 is_main_frame: true,
472 };
473 let evaluation = session.evaluate_at(clicked_https, now);
474 assert!(!evaluation.inherited_user_activation);
475 assert_eq!(
476 evaluation.response.decision,
477 BrowserNavigationPolicyDecision::InWebview
478 );
479
480 let redirected_https = BrowserNavigationPolicyRequest {
481 raw_url: "https://idp.example/oauth/challenge".to_string(),
482 has_user_gesture: false,
483 is_main_frame: true,
484 };
485 let evaluation = session.evaluate_at(redirected_https, now + Duration::from_secs(3));
486 assert!(!evaluation.inherited_user_activation);
487 assert_eq!(
488 evaluation.response.decision,
489 BrowserNavigationPolicyDecision::InWebview
490 );
491
492 let dingtalk = BrowserNavigationPolicyRequest {
493 raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
494 has_user_gesture: false,
495 is_main_frame: true,
496 };
497 let evaluation = session.evaluate_at(dingtalk, now + Duration::from_secs(4));
498 assert!(evaluation.inherited_user_activation);
499 assert_eq!(
500 evaluation.response.decision,
501 BrowserNavigationPolicyDecision::OpenExternal
502 );
503
504 let replay = BrowserNavigationPolicyRequest {
505 raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
506 has_user_gesture: false,
507 is_main_frame: true,
508 };
509 let evaluation = session.evaluate_at(replay, now + Duration::from_secs(5));
510 assert!(!evaluation.inherited_user_activation);
511 assert_eq!(
512 evaluation.response.decision,
513 BrowserNavigationPolicyDecision::Deny
514 );
515 assert_eq!(
516 evaluation.response.reason.as_deref(),
517 Some("gesture_required")
518 );
519 }
520
521 #[test]
522 fn browser_activation_expires_before_external_navigation() {
523 let now = Instant::now();
524 let mut session = BrowserNavigationPolicySession::default();
525 let clicked_https = BrowserNavigationPolicyRequest {
526 raw_url: "https://login.example/oauth/start".to_string(),
527 has_user_gesture: true,
528 is_main_frame: true,
529 };
530 session.evaluate_at(clicked_https, now);
531
532 let dingtalk = BrowserNavigationPolicyRequest {
533 raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
534 has_user_gesture: false,
535 is_main_frame: true,
536 };
537 let evaluation = session.evaluate_at(dingtalk, now + TRANSIENT_USER_ACTIVATION_TTL);
538 assert!(!evaluation.inherited_user_activation);
539 assert_eq!(
540 evaluation.response.decision,
541 BrowserNavigationPolicyDecision::Deny
542 );
543 }
544
545 #[test]
546 fn browser_activation_never_promotes_subframe_navigation() {
547 let now = Instant::now();
548 let mut session = BrowserNavigationPolicySession::default();
549 let clicked_https = BrowserNavigationPolicyRequest {
550 raw_url: "https://login.example/oauth/start".to_string(),
551 has_user_gesture: true,
552 is_main_frame: true,
553 };
554 session.evaluate_at(clicked_https, now);
555
556 let subframe = BrowserNavigationPolicyRequest {
557 raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
558 has_user_gesture: false,
559 is_main_frame: false,
560 };
561 let evaluation = session.evaluate_at(subframe, now + Duration::from_secs(1));
562 assert!(!evaluation.inherited_user_activation);
563 assert_eq!(
564 evaluation.response.decision,
565 BrowserNavigationPolicyDecision::Deny
566 );
567
568 let main_frame = BrowserNavigationPolicyRequest {
569 raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
570 has_user_gesture: false,
571 is_main_frame: true,
572 };
573 let evaluation = session.evaluate_at(main_frame, now + Duration::from_secs(2));
574 assert!(evaluation.inherited_user_activation);
575 assert_eq!(
576 evaluation.response.decision,
577 BrowserNavigationPolicyDecision::OpenExternal
578 );
579 }
580
581 #[test]
582 fn browser_nav_policy_denies_lark_without_gesture() {
583 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
584 raw_url: "lark://client/auth?code=1".to_string(),
585 has_user_gesture: false,
586 is_main_frame: true,
587 });
588
589 assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
590 assert_eq!(response.reason.as_deref(), Some("gesture_required"));
591 }
592
593 #[test]
594 fn browser_nav_policy_allows_unknown_custom_scheme_with_gesture() {
595 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
596 raw_url: "customxyz://hello".to_string(),
597 has_user_gesture: true,
598 is_main_frame: true,
599 });
600
601 assert_eq!(
602 response.decision,
603 BrowserNavigationPolicyDecision::OpenExternal
604 );
605 }
606
607 #[test]
608 fn browser_nav_policy_denies_non_external_scheme() {
609 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
610 raw_url: "javascript:alert(1)".to_string(),
611 has_user_gesture: true,
612 is_main_frame: true,
613 });
614
615 assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
616 assert_eq!(response.reason.as_deref(), Some("non_external_scheme"));
617 }
618
619 #[test]
620 fn browser_nav_policy_denies_external_in_subframe() {
621 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
622 raw_url: "lark://client/auth".to_string(),
623 has_user_gesture: true,
624 is_main_frame: false,
625 });
626
627 assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
628 assert_eq!(response.reason.as_deref(), Some("non_main_frame_external"));
629 }
630
631 #[test]
632 fn browser_nav_policy_allows_lingxia_in_webview() {
633 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
635 raw_url: "lingxia://settings".to_string(),
636 has_user_gesture: false,
637 is_main_frame: true,
638 });
639 assert_eq!(
640 response.decision,
641 BrowserNavigationPolicyDecision::InWebview
642 );
643 }
644
645 #[test]
646 fn browser_nav_policy_denies_internal_schemes_in_subframes() {
647 for raw_url in [
648 "lingxia://settings/history/downloads",
649 "lx://userdata/private.html",
650 ] {
651 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
652 raw_url: raw_url.to_string(),
653 has_user_gesture: true,
654 is_main_frame: false,
655 });
656
657 assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
658 assert_eq!(response.reason.as_deref(), Some("non_main_frame_internal"));
659 }
660 }
661
662 #[test]
663 fn lingxia_newtab_is_startup_url() {
664 assert_eq!(is_lingxia_startup_url("lingxia://newtab"), Some(true));
665 assert_eq!(is_lingxia_startup_url("lingxia://"), Some(true));
666 assert_eq!(is_lingxia_startup_url("lingxia://downloads"), Some(false));
667 assert_eq!(is_lingxia_startup_url("https://example.com"), None);
668 }
669
670 #[test]
671 fn browser_nav_policy_allows_file_in_webview() {
672 let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
674 raw_url: "file:///Users/me/page.html".to_string(),
675 has_user_gesture: false,
676 is_main_frame: true,
677 });
678 assert_eq!(
679 response.decision,
680 BrowserNavigationPolicyDecision::InWebview
681 );
682 }
683}