1use crate::BUILTIN_BROWSER_APPID;
5use crate::internal_pages::registered_control_page_route;
6use crate::policy::{is_lingxia_startup_url, normalize_browser_target_url};
7use crate::types::{BrowserAutomationError, BrowserTabInfo, TrustedControlPageNavigation};
8use crate::webview::{
9 browser_create_webview, browser_destroy_webview_if_matches, browser_find_webview,
10 browser_load_url,
11};
12use lingxia_platform::traits::app_runtime::AppRuntime;
13use lingxia_webview::{WebView, WebViewDataMode};
14use lxapp::{LxApp, LxAppError};
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
18
19pub(crate) const INTERNAL_TAB_PATH_PREFIX: &str = "/tabs/";
20
21#[derive(Clone)]
29pub(crate) struct BrowserTabState {
30 pub(crate) session_id: u64,
31 pub(crate) created_order: u64,
35 pub(crate) create_token: u64,
38 pub(crate) create_in_flight: bool,
43 pub(crate) pending_url: Option<String>,
45 pub(crate) initial_url: Option<String>,
48 pub(crate) current_url: Option<String>,
49 pub(crate) title: Option<String>,
50 pub(crate) title_url: Option<String>,
53 pub(crate) favicon_png: Option<Arc<Vec<u8>>>,
57 pub(crate) can_go_back: bool,
61 pub(crate) can_go_forward: bool,
62 pub(crate) discarded: bool,
66 pub(crate) data_mode: WebViewDataMode,
68 pub(crate) url_callback: Arc<AtomicBool>,
71 pub(crate) standalone: bool,
76 pub(crate) aside: bool,
78 pub(crate) owner_appid: Option<String>,
82 pub(crate) owner_session_id: Option<u64>,
85}
86
87fn tab_generation_matches(tab: &BrowserTabState, session_id: u64, create_token: u64) -> bool {
88 tab.session_id == session_id && tab.create_token == create_token
89}
90
91pub(crate) fn browser_tab_generation_matches(
92 tab_id: &str,
93 session_id: u64,
94 create_token: u64,
95) -> bool {
96 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
97 return false;
98 };
99 lock_state()
100 .tabs
101 .get(&normalized)
102 .is_some_and(|tab| tab_generation_matches(tab, session_id, create_token))
103}
104
105pub(crate) fn browser_internal_url_if_token_matches(
106 tab_id: &str,
107 session_id: u64,
108 create_token: u64,
109) -> Option<String> {
110 let normalized = normalize_runtime_tab_id(tab_id)?;
111 let url = {
112 let state = lock_state();
113 let tab = state.tabs.get(&normalized)?;
114 tab_generation_matches(tab, session_id, create_token)
115 .then(|| tab.pending_url.clone().or_else(|| tab.current_url.clone()))
116 .flatten()?
117 };
118 (crate::policy::extract_url_scheme(&url).as_deref() == Some(crate::policy::LINGXIA_SCHEME))
119 .then_some(url)
120}
121
122fn browser_find_webview_for_generation(
123 tab_id: &str,
124 tab_path: &str,
125 session_id: u64,
126 create_token: u64,
127) -> Option<Arc<WebView>> {
128 let current_generation = lock_state()
129 .tabs
130 .get(tab_id)
131 .is_some_and(|tab| tab_generation_matches(tab, session_id, create_token));
132 current_generation
133 .then(|| browser_find_webview(tab_path, session_id).ok())
134 .flatten()
135}
136
137pub(crate) struct BrowserState {
138 pub(crate) tabs: HashMap<String, BrowserTabState>,
140 recently_closed: Vec<ClosedBrowserTab>,
141 pub(crate) user_agent_override: Option<String>,
144}
145
146static BROWSER_STATE: OnceLock<Mutex<BrowserState>> = OnceLock::new();
147static BROWSER_TAB_COUNTER: AtomicU64 = AtomicU64::new(1);
148static BROWSER_CREATE_TOKEN: AtomicU64 = AtomicU64::new(1);
149static BROWSER_CREATED_ORDER: AtomicU64 = AtomicU64::new(1);
150static BROWSER_LOAD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
151static BROWSER_ACTIVE_TAB_ID: OnceLock<Mutex<Option<String>>> = OnceLock::new();
152static BROWSER_AUTOMATION_TAB_ID: OnceLock<Mutex<Option<String>>> = OnceLock::new();
153static BROWSER_TABS_CHANGED_HANDLER: OnceLock<Mutex<Option<TabsChangedHandler>>> = OnceLock::new();
154static BROWSER_TAB_PRESENT_HANDLER: OnceLock<Mutex<Option<TabPresentHandler>>> = OnceLock::new();
155static BROWSER_NAVIGATION_FINISHED_HANDLER: OnceLock<Mutex<Option<NavigationFinishedHandler>>> =
156 OnceLock::new();
157static BROWSER_TITLE_CHANGED_HANDLER: OnceLock<Mutex<Option<TitleChangedHandler>>> =
158 OnceLock::new();
159
160type TabsChangedHandler = Arc<dyn Fn() + Send + Sync>;
162type TabPresentHandler = Arc<dyn Fn(&str) + Send + Sync>;
164type NavigationFinishedHandler = Arc<dyn Fn(&str, &str) + Send + Sync>;
165type TitleChangedHandler = Arc<dyn Fn(&str, &str) + Send + Sync>;
166
167pub(crate) fn set_tabs_changed_handler(handler: TabsChangedHandler) {
168 let slot = BROWSER_TABS_CHANGED_HANDLER.get_or_init(|| Mutex::new(None));
169 if let Ok(mut slot) = slot.lock() {
170 *slot = Some(handler);
171 }
172}
173
174pub(crate) fn set_tab_present_handler(handler: TabPresentHandler) {
175 let slot = BROWSER_TAB_PRESENT_HANDLER.get_or_init(|| Mutex::new(None));
176 if let Ok(mut slot) = slot.lock() {
177 *slot = Some(handler);
178 }
179}
180
181pub(crate) fn set_navigation_finished_handler(handler: NavigationFinishedHandler) {
182 let slot = BROWSER_NAVIGATION_FINISHED_HANDLER.get_or_init(|| Mutex::new(None));
183 if let Ok(mut slot) = slot.lock() {
184 *slot = Some(handler);
185 }
186}
187
188pub(crate) fn set_title_changed_handler(handler: TitleChangedHandler) {
189 let slot = BROWSER_TITLE_CHANGED_HANDLER.get_or_init(|| Mutex::new(None));
190 if let Ok(mut slot) = slot.lock() {
191 *slot = Some(handler);
192 }
193}
194
195fn records_browser_history(tab: &BrowserTabState) -> bool {
196 tab.data_mode != WebViewDataMode::Ephemeral
197 && !tab.url_callback.load(Ordering::Acquire)
198 && !tab.standalone
199}
200
201fn validate_reused_tab_policy(
202 tab: &BrowserTabState,
203 data_mode: WebViewDataMode,
204 standalone: bool,
205) -> Result<(), LxAppError> {
206 if tab.data_mode != data_mode || tab.standalone != standalone {
207 return Err(LxAppError::InvalidParameter(
208 "an existing browser tab cannot change data mode or standalone status".to_string(),
209 ));
210 }
211 Ok(())
212}
213
214pub(crate) fn notify_navigation_finished(
215 tab_id: &str,
216 session_id: u64,
217 create_token: u64,
218 url: &str,
219) {
220 let handler = BROWSER_NAVIGATION_FINISHED_HANDLER
221 .get()
222 .and_then(|slot| slot.lock().ok())
223 .and_then(|slot| slot.clone());
224 if let Some(handler) = handler {
225 let title = {
228 let state = lock_state();
229 normalize_runtime_tab_id(tab_id)
230 .and_then(|normalized| state.tabs.get(&normalized))
231 .filter(|tab| tab.session_id == session_id && tab.create_token == create_token)
232 .filter(|tab| records_browser_history(tab))
233 .map(|tab| {
234 (tab.title_url.as_deref() == Some(url))
235 .then(|| tab.title.clone())
236 .flatten()
237 .unwrap_or_default()
238 })
239 };
240 if let Some(title) = title {
241 handler(url, &title);
242 }
243 }
244}
245
246fn notify_title_changed(url: &str, title: &str) {
247 let handler = BROWSER_TITLE_CHANGED_HANDLER
248 .get()
249 .and_then(|slot| slot.lock().ok())
250 .and_then(|slot| slot.clone());
251 if let Some(handler) = handler {
252 handler(url, title);
253 }
254}
255
256pub(crate) fn notify_tabs_changed() {
260 let handler = BROWSER_TABS_CHANGED_HANDLER
261 .get()
262 .and_then(|slot| slot.lock().ok())
263 .and_then(|slot| slot.clone());
264 if let Some(handler) = handler {
265 handler();
266 }
267}
268
269fn notify_tab_present_requested(tab_id: &str) {
270 let handler = BROWSER_TAB_PRESENT_HANDLER
271 .get()
272 .and_then(|slot| slot.lock().ok())
273 .and_then(|slot| slot.clone());
274 if let Some(handler) = handler {
275 handler(tab_id);
276 }
277}
278
279pub(crate) fn lock_state() -> MutexGuard<'static, BrowserState> {
280 BROWSER_STATE
281 .get_or_init(|| {
282 Mutex::new(BrowserState {
283 tabs: HashMap::new(),
284 recently_closed: Vec::new(),
285 user_agent_override: None,
286 })
287 })
288 .lock()
289 .unwrap_or_else(|e| {
290 lxapp::warn!("[InternalBrowser] recovered poisoned browser state mutex");
291 e.into_inner()
292 })
293}
294
295fn lock_active_tab() -> MutexGuard<'static, Option<String>> {
296 BROWSER_ACTIVE_TAB_ID
297 .get_or_init(|| Mutex::new(None))
298 .lock()
299 .unwrap_or_else(|e| e.into_inner())
300}
301
302fn lock_automation_tab() -> MutexGuard<'static, Option<String>> {
303 BROWSER_AUTOMATION_TAB_ID
304 .get_or_init(|| Mutex::new(None))
305 .lock()
306 .unwrap_or_else(|e| e.into_inner())
307}
308
309fn set_active_browser_tab(tab_id: &str) -> bool {
311 if is_standalone_tab(tab_id) {
312 return false;
313 }
314 let mut active = lock_active_tab();
315 if active.as_deref() == Some(tab_id) {
316 return false;
317 }
318 *active = Some(tab_id.to_string());
319 true
320}
321
322#[derive(Clone, Copy)]
323pub(crate) enum BrowserTabScope<'a> {
324 Global,
325 OwnerSession {
326 owner_appid: &'a str,
327 owner_session_id: u64,
328 },
329}
330
331fn generate_tab_id() -> String {
332 loop {
333 let candidate = format!(
334 "tab-{}",
335 BROWSER_TAB_COUNTER.fetch_add(1, Ordering::Relaxed)
336 );
337 if !lock_state().tabs.contains_key(&candidate) {
338 return candidate;
339 }
340 }
341}
342
343fn validate_requested_tab_key(input: &str) -> Result<String, LxAppError> {
344 let trimmed = input.trim();
345 if trimmed.is_empty() {
346 return Err(LxAppError::InvalidParameter(
347 "tab_id is required".to_string(),
348 ));
349 }
350 if !trimmed
351 .chars()
352 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
353 {
354 return Err(LxAppError::InvalidParameter(
355 "tab_id must contain only ASCII letters, digits, '-' or '_'".to_string(),
356 ));
357 }
358 Ok(trimmed.to_ascii_lowercase())
359}
360
361pub(crate) fn normalize_runtime_tab_id(input: &str) -> Option<String> {
362 validate_requested_tab_key(input).ok()
363}
364
365fn resolve_tab_scope_seed(scope: BrowserTabScope<'_>, stable_tab_key: &str) -> String {
366 match scope {
367 BrowserTabScope::Global => format!("global:{stable_tab_key}"),
368 BrowserTabScope::OwnerSession {
369 owner_appid,
370 owner_session_id,
371 } => format!("owner:{owner_appid}:{owner_session_id}:{stable_tab_key}"),
372 }
373}
374
375fn deterministic_tab_suffix(seed: &str) -> String {
376 const FNV_OFFSET_A: u64 = 0xcbf29ce484222325;
377 const FNV_PRIME: u64 = 0x100000001b3;
378
379 fn fnv1a64(bytes: &[u8], offset: u64, prime: u64) -> u64 {
380 let mut hash = offset;
381 for byte in bytes {
382 hash ^= u64::from(*byte);
383 hash = hash.wrapping_mul(prime);
384 }
385 hash
386 }
387
388 format!(
389 "{:08x}",
390 fnv1a64(seed.as_bytes(), FNV_OFFSET_A, FNV_PRIME) as u32
391 )
392}
393
394fn resolve_browser_tab_id(
395 requested_tab_key: Option<&str>,
396 scope: BrowserTabScope<'_>,
397) -> Result<String, LxAppError> {
398 match requested_tab_key {
399 Some(tab_key) => {
400 let stable_tab_key = validate_requested_tab_key(tab_key)?;
401 match scope {
402 BrowserTabScope::Global => Ok(stable_tab_key),
403 BrowserTabScope::OwnerSession { .. } => {
404 let seed = resolve_tab_scope_seed(scope, &stable_tab_key);
405 Ok(format!(
406 "{}-{}",
407 stable_tab_key,
408 deterministic_tab_suffix(&seed)
409 ))
410 }
411 }
412 }
413 None => Ok(generate_tab_id()),
414 }
415}
416
417fn next_browser_create_token() -> u64 {
418 BROWSER_CREATE_TOKEN.fetch_add(1, Ordering::Relaxed)
419}
420
421fn next_browser_created_order() -> u64 {
422 BROWSER_CREATED_ORDER.fetch_add(1, Ordering::Relaxed)
423}
424
425fn resolve_owner_lxapp(owner_appid: &str, owner_session_id: u64) -> Result<Arc<LxApp>, LxAppError> {
430 let owner_appid = owner_appid.trim();
431 if owner_appid.is_empty() || owner_session_id == 0 {
432 return Err(LxAppError::InvalidParameter(
433 "owner_appid and owner_session_id are required".to_string(),
434 ));
435 }
436
437 let owner = lxapp::try_get(owner_appid).ok_or_else(|| {
438 LxAppError::ResourceNotFound(format!(
439 "owner lxapp not found for browser tab operation: {}",
440 owner_appid
441 ))
442 })?;
443
444 if owner.session_id() != owner_session_id {
445 return Err(LxAppError::InvalidParameter(format!(
446 "owner session mismatch for {}: expected {}, got {}",
447 owner_appid,
448 owner.session_id(),
449 owner_session_id
450 )));
451 }
452
453 Ok(owner)
454}
455
456pub(crate) fn register_builtin_browser_host() {
457 lxapp::register_synthetic_lxapp(BUILTIN_BROWSER_APPID);
460}
461
462pub(crate) fn ensure_browser_lxapp() -> Result<Arc<LxApp>, LxAppError> {
464 let _load_guard = BROWSER_LOAD_MUTEX
465 .get_or_init(|| Mutex::new(()))
466 .lock()
467 .unwrap_or_else(|e| e.into_inner());
468
469 if let Some(browser) = lxapp::try_get(BUILTIN_BROWSER_APPID) {
470 return Ok(browser);
471 }
472
473 lxapp::ensure_builtin_lxapp(BUILTIN_BROWSER_APPID)
474}
475
476pub(crate) fn browser_tab_path_for_runtime_id(tab_id: &str) -> String {
477 format!("{INTERNAL_TAB_PATH_PREFIX}{tab_id}")
478}
479
480pub(crate) fn browser_tab_path_for_id(tab_id: &str) -> String {
481 normalize_runtime_tab_id(tab_id)
482 .map(|tab_id| browser_tab_path_for_runtime_id(&tab_id))
483 .unwrap_or_else(|| INTERNAL_TAB_PATH_PREFIX.to_string())
484}
485
486pub(crate) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
487 let text = value.unwrap_or_default().trim();
488 if text.is_empty() {
489 None
490 } else {
491 Some(text.to_string())
492 }
493}
494
495fn build_tab_info(tab_id: &str, state: &BrowserTabState) -> BrowserTabInfo {
496 BrowserTabInfo {
497 tab_id: tab_id.to_string(),
498 path: browser_tab_path_for_runtime_id(tab_id),
499 session_id: state.session_id,
500 current_url: state.current_url.clone(),
501 title: state.title.clone(),
502 can_go_back: state.can_go_back,
503 can_go_forward: state.can_go_forward,
504 }
505}
506
507pub fn browser_tab_info(tab_id: &str) -> Option<BrowserTabInfo> {
508 let normalized = normalize_runtime_tab_id(tab_id)?;
509 let state = lock_state();
510 state
511 .tabs
512 .get(&normalized)
513 .map(|tab| build_tab_info(&normalized, tab))
514}
515
516pub fn browser_tabs() -> Vec<BrowserTabInfo> {
519 let state = lock_state();
520 let mut tabs: Vec<(u64, BrowserTabInfo)> = state
521 .tabs
522 .iter()
523 .map(|(tab_id, tab)| (tab.created_order, build_tab_info(tab_id, tab)))
524 .collect();
525 tabs.sort_by_key(|(created_order, _)| *created_order);
526 tabs.into_iter().map(|(_, tab)| tab).collect()
527}
528
529pub fn browser_current_tab() -> Option<BrowserTabInfo> {
530 let active_tab_id = lock_active_tab().clone();
531 let state = lock_state();
532 if let Some(tab_id) = active_tab_id
533 && let Some(tab) = state.tabs.get(&tab_id)
534 && !tab.standalone
535 {
536 return Some(build_tab_info(&tab_id, tab));
537 }
538 state
540 .tabs
541 .iter()
542 .filter(|(_, tab)| !tab.standalone)
543 .min_by_key(|(_, tab)| tab.created_order)
544 .map(|(tab_id, tab)| build_tab_info(tab_id, tab))
545}
546
547pub fn browser_automation_current_tab() -> Option<BrowserTabInfo> {
548 if let Some(tab_id) = lock_automation_tab().clone()
549 && let Some(info) = browser_tab_info(&tab_id)
550 {
551 return Some(info);
552 }
553 browser_current_tab().or_else(|| browser_tabs().into_iter().next())
554}
555
556pub fn browser_activate_tab(tab_id: &str) -> Result<BrowserTabInfo, BrowserAutomationError> {
557 let normalized_tab_id = normalize_runtime_tab_id(tab_id)
558 .ok_or_else(|| BrowserAutomationError::TabNotFound(tab_id.to_string()))?;
559 let (info, standalone) = {
560 let state = lock_state();
561 let tab = state
562 .tabs
563 .get(&normalized_tab_id)
564 .ok_or_else(|| BrowserAutomationError::TabNotFound(tab_id.to_string()))?;
565 (build_tab_info(&normalized_tab_id, tab), tab.standalone)
566 };
567 *lock_automation_tab() = Some(normalized_tab_id.clone());
568 if !standalone && set_active_browser_tab(&normalized_tab_id) {
571 notify_tabs_changed();
572 }
573 Ok(info)
574}
575
576pub fn browser_present_tab(tab_id: &str) -> Result<BrowserTabInfo, BrowserAutomationError> {
577 let info = browser_activate_tab(tab_id)?;
578 notify_tab_present_requested(&info.tab_id);
579 Ok(info)
580}
581
582fn browser_update_tab_info_inner(
583 tab_id: &str,
584 expected_generation: Option<(u64, u64)>,
585 current_url: Option<&str>,
586 title: Option<&str>,
587) -> bool {
588 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
589 return false;
590 };
591 let (changed, changed_title) = {
592 let mut state = lock_state();
593 let Some(tab) = state.tabs.get_mut(&normalized) else {
594 return false;
595 };
596 if expected_generation.is_some_and(|(session_id, create_token)| {
597 tab.session_id != session_id || tab.create_token != create_token
598 }) {
599 return false;
600 }
601 let mut changed = false;
602 let mut changed_title = None;
603 if current_url.is_some() {
604 let value = normalize_optional_string(current_url);
605 if tab.current_url != value {
606 tab.current_url = value;
607 changed = true;
608 }
609 }
610 if let Some(value) = normalize_optional_string(title) {
611 tab.title_url = tab.current_url.clone();
617 if tab.title.as_deref() != Some(value.as_str()) {
618 tab.title = Some(value.clone());
619 if records_browser_history(tab)
620 && let Some(url) = tab.current_url.clone()
621 {
622 changed_title = Some((url, value));
623 }
624 changed = true;
625 }
626 }
627 (changed, changed_title)
628 };
629 if changed {
630 notify_tabs_changed();
631 }
632 if let Some((url, title)) = changed_title {
633 notify_title_changed(&url, &title);
634 }
635 true
636}
637
638pub(crate) fn browser_update_tab_info(
639 tab_id: &str,
640 current_url: Option<&str>,
641 title: Option<&str>,
642) -> bool {
643 browser_update_tab_info_inner(tab_id, None, current_url, title)
644}
645
646pub(crate) fn browser_update_tab_info_if_token_matches(
647 tab_id: &str,
648 session_id: u64,
649 create_token: u64,
650 current_url: Option<&str>,
651 title: Option<&str>,
652) -> bool {
653 browser_update_tab_info_inner(tab_id, Some((session_id, create_token)), current_url, title)
654}
655
656pub(crate) fn browser_update_tab_nav_state(
660 tab_id: &str,
661 can_go_back: bool,
662 can_go_forward: bool,
663) -> bool {
664 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
665 return false;
666 };
667 let changed = {
668 let mut state = lock_state();
669 let Some(tab) = state.tabs.get_mut(&normalized) else {
670 return false;
671 };
672 let changed = tab.can_go_back != can_go_back || tab.can_go_forward != can_go_forward;
673 tab.can_go_back = can_go_back;
674 tab.can_go_forward = can_go_forward;
675 changed
676 };
677 if changed {
678 notify_tabs_changed();
679 }
680 true
681}
682
683pub(crate) fn browser_update_tab_favicon(tab_id: &str, png_bytes: Vec<u8>) -> bool {
687 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
688 return false;
689 };
690 let value = if png_bytes.is_empty() {
691 None
692 } else {
693 Some(Arc::new(png_bytes))
694 };
695 let cache_bytes = value.clone();
696 let (changed, url) = {
697 let mut state = lock_state();
698 let Some(tab) = state.tabs.get_mut(&normalized) else {
699 return false;
700 };
701 let same = match (&tab.favicon_png, &value) {
702 (None, None) => true,
703 (Some(old), Some(new)) => old.as_ref() == new.as_ref(),
704 _ => false,
705 };
706 if !same {
707 tab.favicon_png = value;
708 }
709 (
710 !same,
711 (tab.data_mode != WebViewDataMode::Ephemeral)
712 .then(|| tab.current_url.clone())
713 .flatten(),
714 )
715 };
716 if changed {
717 if let (Some(url), Some(bytes), Some(runtime)) = (url, cache_bytes, lxapp::get_platform()) {
718 lingxia_service::favicon::store_for_url(&runtime.app_cache_dir(), &url, &bytes);
719 }
720 notify_tabs_changed();
721 }
722 true
723}
724
725pub(crate) fn browser_tab_favicon(tab_id: &str) -> Option<Arc<Vec<u8>>> {
727 let normalized = normalize_runtime_tab_id(tab_id)?;
728 lock_state()
729 .tabs
730 .get(&normalized)
731 .and_then(|tab| tab.favicon_png.clone())
732}
733
734#[derive(Debug)]
739pub(crate) enum TabCreateState {
740 Active {
741 pending_url: Option<String>,
742 user_agent_override: Option<String>,
743 },
744 Missing,
745 Stale,
746}
747
748pub(crate) fn browser_tab_create_state(
749 tab_id: &str,
750 session_id: u64,
751 create_token: u64,
752) -> TabCreateState {
753 let mut state = lock_state();
754 let user_agent_override = state.user_agent_override.clone();
755 match state.tabs.get_mut(tab_id) {
756 Some(tab) if tab.session_id == session_id && tab.create_token == create_token => {
757 tab.create_in_flight = false;
760 TabCreateState::Active {
761 pending_url: tab.pending_url.clone(),
762 user_agent_override,
763 }
764 }
765 Some(_) => TabCreateState::Stale,
766 None => TabCreateState::Missing,
767 }
768}
769
770pub(crate) fn browser_remove_tab_if_token_matches(
771 tab_id: &str,
772 session_id: u64,
773 create_token: u64,
774) {
775 let removed = {
776 let mut state = lock_state();
777 let should_remove = state
778 .tabs
779 .get(tab_id)
780 .map(|tab| tab.session_id == session_id && tab.create_token == create_token)
781 .unwrap_or(false);
782 if should_remove {
783 state.tabs.remove(tab_id);
784 }
785 should_remove
786 };
787 if removed {
788 notify_tabs_changed();
789 }
790}
791
792pub(crate) fn browser_clear_pending_if_token_matches(
793 tab_id: &str,
794 session_id: u64,
795 create_token: u64,
796) {
797 let mut state = lock_state();
798 if let Some(tab) = state.tabs.get_mut(tab_id)
799 && tab.session_id == session_id
800 && tab.create_token == create_token
801 {
802 tab.pending_url = None;
803 }
804}
805
806pub(crate) fn browser_commit_navigation_if_token_matches(
807 tab_id: &str,
808 session_id: u64,
809 create_token: u64,
810 current_url: Option<&str>,
811) {
812 let committed = {
813 let mut state = lock_state();
814 if let Some(tab) = state.tabs.get_mut(tab_id)
815 && tab.session_id == session_id
816 && tab.create_token == create_token
817 {
818 tab.pending_url = None;
819 tab.current_url = normalize_optional_string(current_url);
820 true
821 } else {
822 false
823 }
824 };
825 if committed {
826 notify_tabs_changed();
827 }
828}
829
830fn browser_clear_pending_url(tab_id: &str) {
831 let mut state = lock_state();
832 if let Some(tab) = state.tabs.get_mut(tab_id) {
833 tab.pending_url = None;
834 }
835}
836
837fn open_internal_browser_tab_with_scope(
842 url: &str,
843 requested_tab_key: Option<&str>,
844 scope: BrowserTabScope<'_>,
845 standalone: bool,
846 aside: bool,
847 data_mode: WebViewDataMode,
848 url_callback: bool,
849) -> Result<String, LxAppError> {
850 let browser = ensure_browser_lxapp()?;
851 let browser_session_id = browser.session_id();
852
853 let raw_url = url.trim();
854
855 let effective_url: String = match is_lingxia_startup_url(raw_url) {
858 Some(true) => String::new(),
859 _ => raw_url.to_string(),
860 };
861 let target_url = effective_url.as_str();
862
863 let normalized_target_url = normalize_browser_target_url(target_url);
864 let has_target_url = !normalized_target_url.is_empty();
865 let (owner_appid, owner_session_id) = match scope {
866 BrowserTabScope::Global => (None, None),
867 BrowserTabScope::OwnerSession {
868 owner_appid,
869 owner_session_id,
870 } => (Some(owner_appid.to_string()), Some(owner_session_id)),
871 };
872 let tab_id = resolve_browser_tab_id(requested_tab_key, scope)?;
873 let path = browser_tab_path_for_runtime_id(&tab_id);
874 let session_id = browser_session_id;
875 let mut create_token: Option<u64> = None;
876 let mut is_new_tab = false;
877
878 let url_callback_policy = {
879 let mut state = lock_state();
880 if let Some(existing) = state.tabs.get_mut(&tab_id) {
881 validate_reused_tab_policy(existing, data_mode, standalone)?;
882 if existing.session_id != session_id {
883 existing.create_in_flight = false;
886 }
887 existing.session_id = session_id;
888 existing.url_callback.store(url_callback, Ordering::Release);
889 if has_target_url {
890 existing.pending_url = Some(normalized_target_url.clone());
891 }
892 existing.url_callback.clone()
893 } else {
894 is_new_tab = true;
895 let token = next_browser_create_token();
896 create_token = Some(token);
897 let url_callback_policy = Arc::new(AtomicBool::new(url_callback));
898 state.tabs.insert(
899 tab_id.clone(),
900 BrowserTabState {
901 session_id,
902 created_order: next_browser_created_order(),
903 create_token: token,
904 create_in_flight: true,
905 pending_url: if has_target_url {
906 Some(normalized_target_url.clone())
907 } else {
908 None
909 },
910 initial_url: has_target_url.then(|| {
911 lxapp::lingxia_surface::normalize_initial_url(&normalized_target_url)
912 }),
913 current_url: None,
914 title: None,
915 title_url: None,
916 favicon_png: None,
917 can_go_back: false,
918 can_go_forward: false,
919 discarded: false,
920 data_mode,
921 url_callback: url_callback_policy.clone(),
922 standalone,
923 aside,
924 owner_appid,
925 owner_session_id,
926 },
927 );
928 url_callback_policy
929 }
930 };
931
932 if is_new_tab {
933 let token = create_token.expect("create_token must exist for new tab");
934 if let Err(e) = browser_create_webview(
935 &path,
936 session_id,
937 &tab_id,
938 token,
939 data_mode,
940 url_callback_policy.clone(),
941 standalone,
942 ) {
943 lock_state().tabs.remove(&tab_id);
944 return Err(e);
945 }
946 if !standalone {
949 let _ = set_active_browser_tab(&tab_id);
950 }
951 notify_tabs_changed();
952 return Ok(tab_id);
953 }
954
955 if has_target_url {
957 let create_token = lock_state()
958 .tabs
959 .get(&tab_id)
960 .map(|tab| tab.create_token)
961 .ok_or_else(|| {
962 LxAppError::ResourceNotFound(format!("browser tab not found: {tab_id}"))
963 })?;
964 match browser_load_url(&path, session_id, create_token, &normalized_target_url) {
965 Ok(()) => {
966 if let Some(s) = lock_state().tabs.get_mut(&tab_id) {
967 s.pending_url = None;
968 s.current_url = Some(normalized_target_url.clone());
969 }
970 }
971 Err(LxAppError::ResourceNotFound(_)) => {
972 let retry_token = {
977 let mut state = lock_state();
978 match state.tabs.get_mut(&tab_id) {
979 Some(tab) if !tab.create_in_flight => {
980 let token = next_browser_create_token();
981 tab.create_token = token;
982 tab.create_in_flight = true;
983 tab.discarded = false;
984 Some(token)
985 }
986 _ => None,
987 }
988 };
989 if let Some(token) = retry_token
990 && let Err(e) = browser_create_webview(
991 &path,
992 session_id,
993 &tab_id,
994 token,
995 data_mode,
996 url_callback_policy.clone(),
997 standalone,
998 )
999 {
1000 lock_state().tabs.remove(&tab_id);
1001 return Err(e);
1002 }
1003 }
1004 Err(e) => {
1005 browser_clear_pending_url(&tab_id);
1006 return Err(e);
1007 }
1008 }
1009 }
1010
1011 if !standalone {
1012 let _ = set_active_browser_tab(&tab_id);
1013 }
1014 notify_tabs_changed();
1015 Ok(tab_id)
1016}
1017
1018pub(crate) fn open_internal_browser_tab(
1019 url: &str,
1020 tab_id: Option<&str>,
1021) -> Result<String, LxAppError> {
1022 open_internal_browser_tab_with_scope(
1023 url,
1024 tab_id,
1025 BrowserTabScope::Global,
1026 false,
1027 false,
1028 WebViewDataMode::ProfileDefault,
1029 false,
1030 )
1031}
1032
1033pub(crate) fn navigate_trusted_control_page(
1038 url: &str,
1039) -> Result<TrustedControlPageNavigation, LxAppError> {
1040 let route = registered_control_page_route(url).ok_or_else(|| {
1041 LxAppError::InvalidParameter(format!(
1042 "trusted browser control route is not registered: {url}"
1043 ))
1044 })?;
1045 let tab_id = open_internal_browser_tab_with_scope(
1046 url,
1047 Some(&route),
1048 BrowserTabScope::Global,
1049 false,
1050 false,
1051 WebViewDataMode::ProfileDefault,
1052 false,
1053 )?;
1054 let identity = browser_tab_info(&tab_id).ok_or_else(|| {
1055 LxAppError::ResourceNotFound(format!("trusted browser control tab disappeared: {tab_id}"))
1056 })?;
1057 Ok(TrustedControlPageNavigation {
1058 tab_id,
1059 browser_session_id: identity.session_id,
1060 })
1061}
1062
1063pub(crate) fn open_internal_browser_tab_for_owner(
1064 owner_appid: &str,
1065 owner_session_id: u64,
1066 url: &str,
1067 tab_id: Option<&str>,
1068 standalone: bool,
1069 aside: bool,
1070 data_mode: WebViewDataMode,
1071 url_callback: bool,
1072) -> Result<String, LxAppError> {
1073 let _owner = resolve_owner_lxapp(owner_appid, owner_session_id)?;
1074 if aside && tab_id.is_none() {
1075 let normalized_target = normalize_browser_target_url(url);
1076 let initial_url = lxapp::lingxia_surface::normalize_initial_url(&normalized_target);
1077 let reusable = {
1078 let state = lock_state();
1079 state.tabs.iter().find_map(|(tab_id, tab)| {
1080 (tab.aside
1081 && tab.owner_appid.as_deref() == Some(owner_appid)
1082 && tab.owner_session_id == Some(owner_session_id)
1083 && tab.initial_url.as_deref() == Some(initial_url.as_str()))
1084 .then(|| tab_id.clone())
1085 })
1086 };
1087 if let Some(tab_id) = reusable {
1088 let _ = set_active_browser_tab(&tab_id);
1089 notify_tabs_changed();
1090 return Ok(tab_id);
1091 }
1092 }
1093 open_internal_browser_tab_with_scope(
1094 url,
1095 tab_id,
1096 BrowserTabScope::OwnerSession {
1097 owner_appid,
1098 owner_session_id,
1099 },
1100 standalone,
1101 aside,
1102 data_mode,
1103 url_callback,
1104 )
1105}
1106
1107pub(crate) fn tab_owner_appid(tab_id: &str) -> Option<String> {
1109 let normalized = normalize_runtime_tab_id(tab_id)?;
1110 lock_state()
1111 .tabs
1112 .get(&normalized)
1113 .and_then(|tab| tab.owner_appid.clone())
1114}
1115
1116pub(crate) fn is_aside_tab(tab_id: &str) -> bool {
1118 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
1119 return false;
1120 };
1121 lock_state()
1122 .tabs
1123 .get(&normalized)
1124 .map(|tab| tab.aside)
1125 .unwrap_or(false)
1126}
1127
1128pub(crate) fn is_standalone_tab(tab_id: &str) -> bool {
1132 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
1133 return false;
1134 };
1135 lock_state()
1136 .tabs
1137 .get(&normalized)
1138 .map(|tab| tab.standalone)
1139 .unwrap_or(false)
1140}
1141
1142pub fn browser_tab_exists(tab_id: &str) -> bool {
1143 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
1144 return false;
1145 };
1146 lock_state().tabs.contains_key(&normalized)
1147}
1148
1149pub(crate) fn close_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
1150 close_browser_tab_inner(tab_id, true)
1151}
1152
1153fn close_browser_tab_inner(tab_id: &str, remember: bool) -> Result<(), LxAppError> {
1154 let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
1155 LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
1156 })?;
1157
1158 let tab_path = browser_tab_path_for_runtime_id(&normalized);
1159 let generation = {
1160 let state = lock_state();
1161 state
1162 .tabs
1163 .get(&normalized)
1164 .map(|tab| (tab.session_id, tab.create_token))
1165 };
1166 let closing_webview = generation.and_then(|(session_id, create_token)| {
1170 browser_find_webview_for_generation(&normalized, &tab_path, session_id, create_token)
1171 });
1172 let removed = {
1173 let mut state = lock_state();
1174 let removed = state.tabs.remove(&normalized);
1175 if remember
1176 && let Some(tab) = removed.as_ref()
1177 && let Some(entry) = ClosedBrowserTab::from_tab(tab)
1178 {
1179 state.recently_closed.push(entry);
1180 if state.recently_closed.len() > 25 {
1181 state.recently_closed.remove(0);
1182 }
1183 }
1184 removed
1185 };
1186 let removed_any = removed.is_some();
1187 if let Some(tab) = removed {
1188 if let Ok(browser) = ensure_browser_lxapp() {
1191 let startup_path = browser.initial_route();
1192 if let Some(page) = browser.get_page(&startup_path) {
1193 let startup_webview = page.webview();
1194 let closing_tab_webview = closing_webview.clone();
1195 if let (Some(startup_webview), Some(closing_tab_webview)) =
1196 (startup_webview, closing_tab_webview)
1197 && Arc::ptr_eq(&startup_webview, &closing_tab_webview)
1198 {
1199 page.detach_webview();
1200 }
1201 }
1202 if let Some(page) = browser.get_page(&tab_path) {
1203 page.detach_webview();
1204 }
1205 if let Some(page) = browser.get_page(&tab_path) {
1207 browser.remove_pages(std::slice::from_ref(&page.instance_id_string()));
1208 }
1209 }
1210 if let Some(webview) = closing_webview {
1211 browser_destroy_webview_if_matches(&tab_path, tab.session_id, &webview);
1212 }
1213 }
1214 let active_matches_closed = lock_active_tab().as_deref() == Some(normalized.as_str());
1215 if active_matches_closed {
1216 let next = browser_current_tab().map(|tab| tab.tab_id);
1217 *lock_active_tab() = next;
1218 }
1219 if lock_automation_tab().as_deref() == Some(normalized.as_str()) {
1220 lock_automation_tab().take();
1221 }
1222 if removed_any || active_matches_closed {
1223 notify_tabs_changed();
1224 }
1225 Ok(())
1226}
1227
1228pub(crate) fn prune_stale_owner_tabs(owner_appid: &str, current_session_id: u64) -> usize {
1231 let stale_ids = {
1232 let state = lock_state();
1233 state
1234 .tabs
1235 .iter()
1236 .filter(|(_, tab)| {
1237 tab.owner_appid.as_deref() == Some(owner_appid)
1238 && tab.owner_session_id != Some(current_session_id)
1239 })
1240 .map(|(tab_id, _)| tab_id.clone())
1241 .collect::<Vec<_>>()
1242 };
1243 for tab_id in &stale_ids {
1244 let _ = close_browser_tab_inner(tab_id, false);
1245 }
1246 lock_state().recently_closed.retain(|entry| {
1247 entry.owner_appid.as_deref() != Some(owner_appid)
1248 || entry.owner_session_id == Some(current_session_id)
1249 });
1250 stale_ids.len()
1251}
1252
1253pub(crate) fn discard_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
1258 let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
1259 LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
1260 })?;
1261 if lock_active_tab().as_deref() == Some(normalized.as_str()) {
1262 return Err(LxAppError::InvalidParameter(
1263 "cannot discard the active browser tab".to_string(),
1264 ));
1265 }
1266 let tab = match lock_state().tabs.get(&normalized).cloned() {
1267 Some(tab) if !tab.discarded => tab,
1269 _ => return Ok(()),
1270 };
1271 let tab_path = browser_tab_path_for_runtime_id(&normalized);
1272
1273 let webview = browser_find_webview_for_generation(
1274 &normalized,
1275 &tab_path,
1276 tab.session_id,
1277 tab.create_token,
1278 );
1279
1280 if let Some(state) = lock_state().tabs.get_mut(&normalized) {
1286 state.create_token = next_browser_create_token();
1287 }
1288
1289 if let Ok(browser) = ensure_browser_lxapp() {
1292 let startup_path = browser.initial_route();
1293 if let Some(page) = browser.get_page(&startup_path) {
1294 let startup_webview = page.webview();
1295 let tab_webview = webview.clone();
1296 if let (Some(startup_webview), Some(tab_webview)) = (startup_webview, tab_webview)
1297 && Arc::ptr_eq(&startup_webview, &tab_webview)
1298 {
1299 page.detach_webview();
1300 }
1301 }
1302 if let Some(page) = browser.get_page(&tab_path) {
1303 page.detach_webview();
1304 }
1305 if let Some(page) = browser.get_page(&tab_path) {
1307 browser.remove_pages(std::slice::from_ref(&page.instance_id_string()));
1308 }
1309 }
1310 if let Some(webview) = webview {
1311 browser_destroy_webview_if_matches(&tab_path, tab.session_id, &webview);
1312 }
1313
1314 if let Some(state) = lock_state().tabs.get_mut(&normalized) {
1318 state.discarded = true;
1319 state.create_in_flight = false;
1320 if state.pending_url.is_none() {
1321 state.pending_url = state.current_url.clone();
1322 }
1323 }
1324 Ok(())
1325}
1326
1327pub(crate) fn mark_browser_tab_active(tab_id: &str) {
1331 let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
1332 return;
1333 };
1334 let exists = lock_state().tabs.contains_key(&normalized);
1335 if exists && set_active_browser_tab(&normalized) {
1336 notify_tabs_changed();
1337 }
1338}
1339
1340pub(crate) fn clear_active_browser_tab() {
1344 let changed = lock_active_tab().take().is_some();
1345 if changed {
1346 notify_tabs_changed();
1347 }
1348}
1349
1350pub(crate) fn reactivate_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
1353 let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
1354 LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
1355 })?;
1356 let recreate = {
1359 let mut state = lock_state();
1360 let Some(tab) = state.tabs.get_mut(&normalized) else {
1361 return Err(LxAppError::ResourceNotFound(
1362 "browser tab not found".to_string(),
1363 ));
1364 };
1365 if tab.discarded {
1366 let token = next_browser_create_token();
1367 tab.create_token = token;
1368 tab.discarded = false;
1369 tab.create_in_flight = true;
1370 Some((
1372 tab.session_id,
1373 token,
1374 tab.data_mode,
1375 tab.url_callback.clone(),
1376 tab.standalone,
1377 ))
1378 } else {
1379 None
1380 }
1381 };
1382
1383 if let Some((session_id, token, data_mode, url_callback, standalone)) = recreate {
1384 let path = browser_tab_path_for_runtime_id(&normalized);
1385 if let Err(error) = browser_create_webview(
1386 &path,
1387 session_id,
1388 &normalized,
1389 token,
1390 data_mode,
1391 url_callback,
1392 standalone,
1393 ) {
1394 if let Some(tab) = lock_state().tabs.get_mut(&normalized) {
1395 tab.discarded = true;
1396 tab.create_in_flight = false;
1397 }
1398 return Err(error);
1399 }
1400 }
1401
1402 if set_active_browser_tab(&normalized) {
1403 notify_tabs_changed();
1404 }
1405 Ok(())
1406}
1407
1408#[derive(Clone, Debug, serde::Serialize)]
1410#[serde(rename_all = "camelCase")]
1411pub struct ClosedBrowserTab {
1412 pub id: String,
1413 pub url: String,
1414 pub title: String,
1415 #[serde(skip)]
1416 owner_appid: Option<String>,
1417 #[serde(skip)]
1418 owner_session_id: Option<u64>,
1419}
1420
1421impl ClosedBrowserTab {
1422 fn from_tab(tab: &BrowserTabState) -> Option<Self> {
1423 if tab.standalone || tab.aside || tab.data_mode == WebViewDataMode::Ephemeral {
1424 return None;
1425 }
1426 let url = tab
1427 .current_url
1428 .clone()
1429 .or_else(|| tab.pending_url.clone())?;
1430 if !matches!(
1432 crate::policy::extract_url_scheme(&url).as_deref(),
1433 Some("http" | "https")
1434 ) {
1435 return None;
1436 }
1437 Some(Self {
1438 id: uuid::Uuid::new_v4().to_string(),
1439 url,
1440 title: tab.title.clone().unwrap_or_default(),
1441 owner_appid: tab.owner_appid.clone(),
1442 owner_session_id: tab.owner_session_id,
1443 })
1444 }
1445}
1446
1447pub fn recently_closed() -> Vec<ClosedBrowserTab> {
1448 lock_state().recently_closed.iter().rev().cloned().collect()
1449}
1450
1451pub fn reopen_closed(id: Option<&str>) -> Result<String, LxAppError> {
1452 let entry = {
1453 let mut state = lock_state();
1454 let index = state
1455 .recently_closed
1456 .iter()
1457 .rposition(|entry| id.is_none_or(|id| entry.id == id))
1458 .ok_or_else(|| LxAppError::ResourceNotFound("no recently closed tab".to_string()))?;
1459 state.recently_closed.remove(index)
1460 };
1461 let result = match (&entry.owner_appid, entry.owner_session_id) {
1462 (Some(appid), Some(session_id)) => crate::open_for_app(appid, session_id, &entry.url, None),
1463 _ => crate::open(&entry.url, None),
1464 };
1465 match result {
1466 Ok(tab_id) => {
1467 browser_update_tab_info(&tab_id, None, Some(&entry.title));
1468 let _ = crate::present(&tab_id);
1469 Ok(tab_id)
1470 }
1471 Err(error) => {
1472 let mut state = lock_state();
1473 state.recently_closed.push(entry);
1474 if state.recently_closed.len() > 25 {
1475 state.recently_closed.remove(0);
1476 }
1477 Err(error)
1478 }
1479 }
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484 use super::*;
1485
1486 #[test]
1487 fn recently_closed_only_records_normal_website_tabs_with_owner() {
1488 let mut tab = BrowserTabState {
1489 session_id: 1,
1490 created_order: 1,
1491 create_token: 1,
1492 create_in_flight: false,
1493 pending_url: None,
1494 initial_url: None,
1495 current_url: Some("https://example.test/".into()),
1496 title: Some("Example".into()),
1497 title_url: None,
1498 favicon_png: None,
1499 can_go_back: false,
1500 can_go_forward: false,
1501 discarded: false,
1502 data_mode: WebViewDataMode::ProfileDefault,
1503 url_callback: Arc::new(AtomicBool::new(false)),
1504 standalone: false,
1505 aside: false,
1506 owner_appid: Some("owner".into()),
1507 owner_session_id: Some(42),
1508 };
1509 let entry = ClosedBrowserTab::from_tab(&tab).unwrap();
1510 assert_eq!(entry.url, "https://example.test/");
1511 assert_eq!(entry.owner_session_id, Some(42));
1512 assert_eq!(entry.title, "Example");
1513 tab.data_mode = WebViewDataMode::Ephemeral;
1514 assert!(ClosedBrowserTab::from_tab(&tab).is_none());
1515 tab.data_mode = WebViewDataMode::ProfileDefault;
1516 tab.aside = true;
1517 assert!(ClosedBrowserTab::from_tab(&tab).is_none());
1518 tab.aside = false;
1519 tab.standalone = true;
1520 assert!(ClosedBrowserTab::from_tab(&tab).is_none());
1521 tab.standalone = false;
1522 tab.current_url = Some("lingxia://settings".into());
1523 assert!(ClosedBrowserTab::from_tab(&tab).is_none());
1524 }
1525
1526 #[test]
1527 fn webview_creation_receives_the_browser_user_agent_override() {
1528 let tab_id = generate_tab_id();
1529 let previous_user_agent = {
1530 let mut state = lock_state();
1531 let previous = state
1532 .user_agent_override
1533 .replace("TestAgent/1.0".to_string());
1534 state.tabs.insert(
1535 tab_id.clone(),
1536 BrowserTabState {
1537 session_id: 42,
1538 created_order: next_browser_created_order(),
1539 create_token: 7,
1540 create_in_flight: true,
1541 pending_url: Some("https://example.test/".to_string()),
1542 initial_url: Some("https://example.test/".to_string()),
1543 current_url: None,
1544 title: None,
1545 title_url: None,
1546 favicon_png: None,
1547 can_go_back: false,
1548 can_go_forward: false,
1549 discarded: false,
1550 data_mode: WebViewDataMode::ProfileDefault,
1551 url_callback: Arc::new(AtomicBool::new(false)),
1552 standalone: false,
1553 aside: false,
1554 owner_appid: None,
1555 owner_session_id: None,
1556 },
1557 );
1558 previous
1559 };
1560
1561 let state = browser_tab_create_state(&tab_id, 42, 7);
1562 assert!(matches!(
1563 state,
1564 TabCreateState::Active {
1565 pending_url: Some(url),
1566 user_agent_override: Some(user_agent),
1567 } if url == "https://example.test/" && user_agent == "TestAgent/1.0"
1568 ));
1569 assert!(!lock_state().tabs[&tab_id].create_in_flight);
1570 let mut state = lock_state();
1571 state.tabs.remove(&tab_id);
1572 state.user_agent_override = previous_user_agent;
1573 }
1574
1575 #[test]
1576 fn tab_generation_match_rejects_a_recreated_tab() {
1577 let tab = BrowserTabState {
1578 session_id: 42,
1579 created_order: 1,
1580 create_token: 8,
1581 create_in_flight: false,
1582 pending_url: None,
1583 initial_url: None,
1584 current_url: None,
1585 title: None,
1586 title_url: None,
1587 favicon_png: None,
1588 can_go_back: false,
1589 can_go_forward: false,
1590 discarded: false,
1591 data_mode: WebViewDataMode::ProfileDefault,
1592 url_callback: Arc::new(AtomicBool::new(false)),
1593 standalone: false,
1594 aside: false,
1595 owner_appid: None,
1596 owner_session_id: None,
1597 };
1598
1599 assert!(tab_generation_matches(&tab, 42, 8));
1600 assert!(!tab_generation_matches(&tab, 42, 7));
1601 assert!(!tab_generation_matches(&tab, 41, 8));
1602 }
1603
1604 #[test]
1605 fn discarded_or_recreated_tab_rejects_stale_internal_reload() {
1606 let tab_id = generate_tab_id();
1607 lock_state().tabs.insert(
1608 tab_id.clone(),
1609 BrowserTabState {
1610 session_id: 42,
1611 created_order: next_browser_created_order(),
1612 create_token: 8,
1613 create_in_flight: false,
1614 pending_url: None,
1615 initial_url: Some("lingxia://settings".to_string()),
1616 current_url: Some("lingxia://settings".to_string()),
1617 title: None,
1618 title_url: None,
1619 favicon_png: None,
1620 can_go_back: false,
1621 can_go_forward: false,
1622 discarded: false,
1623 data_mode: WebViewDataMode::ProfileDefault,
1624 url_callback: Arc::new(AtomicBool::new(false)),
1625 standalone: false,
1626 aside: false,
1627 owner_appid: None,
1628 owner_session_id: None,
1629 },
1630 );
1631
1632 assert_eq!(
1633 browser_internal_url_if_token_matches(&tab_id, 42, 8).as_deref(),
1634 Some("lingxia://settings")
1635 );
1636 lock_state().tabs.get_mut(&tab_id).unwrap().create_token = 9;
1637 assert_eq!(browser_internal_url_if_token_matches(&tab_id, 42, 8), None);
1638 lock_state().tabs.remove(&tab_id);
1639 }
1640
1641 #[test]
1642 fn stable_browser_tab_ids_are_deterministic_per_scope() {
1643 let global_a = resolve_browser_tab_id(Some("settings"), BrowserTabScope::Global).unwrap();
1644 let global_b = resolve_browser_tab_id(Some("settings"), BrowserTabScope::Global).unwrap();
1645 let owner_a = resolve_browser_tab_id(
1646 Some("settings"),
1647 BrowserTabScope::OwnerSession {
1648 owner_appid: "app.demo",
1649 owner_session_id: 1,
1650 },
1651 )
1652 .unwrap();
1653 let owner_b = resolve_browser_tab_id(
1654 Some("settings"),
1655 BrowserTabScope::OwnerSession {
1656 owner_appid: "app.demo",
1657 owner_session_id: 2,
1658 },
1659 )
1660 .unwrap();
1661
1662 assert_eq!(global_a, global_b);
1663 assert_ne!(global_a, owner_a);
1664 assert_ne!(owner_a, owner_b);
1665 }
1666
1667 #[test]
1668 fn stable_browser_tab_ids_reject_invalid_keys() {
1669 let result = resolve_browser_tab_id(Some("settings/main"), BrowserTabScope::Global);
1670 assert!(matches!(result, Err(LxAppError::InvalidParameter(_))));
1671 }
1672
1673 #[test]
1674 fn navigation_history_respects_title_privacy_and_generation() {
1675 let tab_id = "navfinishtitletest";
1677 lock_state().tabs.insert(
1678 tab_id.to_string(),
1679 BrowserTabState {
1680 session_id: 1,
1681 created_order: next_browser_created_order(),
1682 create_token: 1,
1683 create_in_flight: false,
1684 pending_url: None,
1685 initial_url: None,
1686 current_url: None,
1687 title: None,
1688 title_url: None,
1689 favicon_png: None,
1690 can_go_back: false,
1691 can_go_forward: false,
1692 discarded: false,
1693 data_mode: WebViewDataMode::ProfileDefault,
1694 url_callback: Arc::new(AtomicBool::new(false)),
1695 standalone: false,
1696 aside: false,
1697 owner_appid: None,
1698 owner_session_id: None,
1699 },
1700 );
1701 assert!(browser_update_tab_info(
1702 tab_id,
1703 Some("https://a.test/"),
1704 Some("Page A")
1705 ));
1706
1707 let visits: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1708 let visit_sink = visits.clone();
1709 set_navigation_finished_handler(Arc::new(move |url, title| {
1710 visit_sink
1711 .lock()
1712 .unwrap()
1713 .push((url.to_string(), title.to_string()));
1714 }));
1715 let titles: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1716 let title_sink = titles.clone();
1717 set_title_changed_handler(Arc::new(move |url, title| {
1718 title_sink
1719 .lock()
1720 .unwrap()
1721 .push((url.to_string(), title.to_string()));
1722 }));
1723
1724 notify_navigation_finished(tab_id, 1, 1, "https://b.test/");
1726 notify_navigation_finished(tab_id, 1, 1, "https://a.test/");
1727 assert_eq!(
1728 *visits.lock().unwrap(),
1729 vec![
1730 ("https://b.test/".to_string(), String::new()),
1731 ("https://a.test/".to_string(), "Page A".to_string()),
1732 ]
1733 );
1734
1735 assert!(browser_update_tab_info_if_token_matches(
1736 tab_id,
1737 1,
1738 1,
1739 Some("https://normal.test/"),
1740 Some("Normal")
1741 ));
1742 assert_eq!(
1743 *titles.lock().unwrap(),
1744 vec![("https://normal.test/".to_string(), "Normal".to_string())]
1745 );
1746
1747 let assert_private_policy =
1748 |data_mode: WebViewDataMode, url_callback: bool, standalone: bool, suffix: &str| {
1749 {
1750 let mut state = lock_state();
1751 let tab = state.tabs.get_mut(tab_id).unwrap();
1752 tab.data_mode = data_mode;
1753 tab.url_callback.store(url_callback, Ordering::Release);
1754 tab.standalone = standalone;
1755 }
1756 let url = format!("https://auth.test/callback?code={suffix}");
1757 notify_navigation_finished(tab_id, 1, 1, &url);
1758 assert!(browser_update_tab_info_if_token_matches(
1759 tab_id,
1760 1,
1761 1,
1762 Some(&url),
1763 Some(&format!("Secret {suffix}"))
1764 ));
1765 assert_eq!(visits.lock().unwrap().len(), 2);
1766 assert_eq!(titles.lock().unwrap().len(), 1);
1767 };
1768
1769 assert_private_policy(WebViewDataMode::Ephemeral, false, false, "ephemeral");
1770 assert_private_policy(WebViewDataMode::ProfileDefault, true, false, "callback");
1771 assert_private_policy(WebViewDataMode::ProfileDefault, false, true, "standalone");
1772
1773 {
1774 let mut state = lock_state();
1775 let tab = state.tabs.get_mut(tab_id).unwrap();
1776 tab.data_mode = WebViewDataMode::ProfileDefault;
1777 tab.standalone = false;
1778 tab.create_token = 2;
1779 }
1780 assert!(!browser_update_tab_info_if_token_matches(
1781 tab_id,
1782 1,
1783 1,
1784 Some("https://auth.test/callback?code=stale"),
1785 Some("Stale secret")
1786 ));
1787 notify_navigation_finished(tab_id, 1, 1, "https://auth.test/callback?code=stale");
1788 assert_eq!(visits.lock().unwrap().len(), 2);
1789 assert_eq!(titles.lock().unwrap().len(), 1);
1790 lock_state().tabs.remove(tab_id);
1791 }
1792
1793 #[test]
1794 fn stable_tab_reuse_rejects_privacy_policy_changes() {
1795 let tab = BrowserTabState {
1796 session_id: 1,
1797 created_order: next_browser_created_order(),
1798 create_token: 1,
1799 create_in_flight: false,
1800 pending_url: None,
1801 initial_url: None,
1802 current_url: None,
1803 title: None,
1804 title_url: None,
1805 favicon_png: None,
1806 can_go_back: false,
1807 can_go_forward: false,
1808 discarded: false,
1809 data_mode: WebViewDataMode::ProfileDefault,
1810 url_callback: Arc::new(AtomicBool::new(false)),
1811 standalone: false,
1812 aside: false,
1813 owner_appid: None,
1814 owner_session_id: None,
1815 };
1816
1817 assert!(validate_reused_tab_policy(&tab, WebViewDataMode::ProfileDefault, false).is_ok());
1818 assert!(validate_reused_tab_policy(&tab, WebViewDataMode::Ephemeral, false).is_err());
1819 assert!(validate_reused_tab_policy(&tab, WebViewDataMode::ProfileDefault, true).is_err());
1820 }
1821
1822 #[test]
1823 fn runtime_tab_id_lookup_normalizes_stable_keys() {
1824 assert_eq!(
1825 normalize_runtime_tab_id("settings"),
1826 Some("settings".to_string())
1827 );
1828 assert_eq!(
1829 normalize_runtime_tab_id("SeTtings"),
1830 Some("settings".to_string())
1831 );
1832 assert!(normalize_runtime_tab_id("settings/main").is_none());
1833 }
1834
1835 #[test]
1836 fn standalone_surface_tabs_remain_in_automation_inventory() {
1837 let tab_id = generate_tab_id();
1838 lock_state().tabs.insert(
1839 tab_id.clone(),
1840 BrowserTabState {
1841 session_id: 7,
1842 created_order: next_browser_created_order(),
1843 create_token: 1,
1844 create_in_flight: false,
1845 pending_url: None,
1846 initial_url: Some("https://auth.example.test/".to_string()),
1847 current_url: Some("https://auth.example.test/".to_string()),
1848 title: None,
1849 title_url: None,
1850 favicon_png: None,
1851 can_go_back: false,
1852 can_go_forward: false,
1853 discarded: false,
1854 data_mode: WebViewDataMode::Ephemeral,
1855 url_callback: Arc::new(AtomicBool::new(true)),
1856 standalone: true,
1857 aside: false,
1858 owner_appid: Some("app.demo".to_string()),
1859 owner_session_id: Some(3),
1860 },
1861 );
1862
1863 let info = browser_tabs()
1864 .into_iter()
1865 .find(|tab| tab.tab_id == tab_id)
1866 .expect("standalone URL surface should be visible to devtools");
1867 assert_eq!(
1868 info.current_url.as_deref(),
1869 Some("https://auth.example.test/")
1870 );
1871 lock_state().tabs.remove(&tab_id);
1872 }
1873
1874 #[test]
1875 fn activating_standalone_tab_does_not_replace_product_active_tab() {
1876 let product_tab_id = generate_tab_id();
1877 let standalone_tab_id = generate_tab_id();
1878 let make_tab = |standalone| BrowserTabState {
1879 session_id: 7,
1880 created_order: next_browser_created_order(),
1881 create_token: 1,
1882 create_in_flight: false,
1883 pending_url: None,
1884 initial_url: None,
1885 current_url: None,
1886 title: None,
1887 title_url: None,
1888 favicon_png: None,
1889 can_go_back: false,
1890 can_go_forward: false,
1891 discarded: false,
1892 data_mode: WebViewDataMode::ProfileDefault,
1893 url_callback: Arc::new(AtomicBool::new(false)),
1894 standalone,
1895 aside: false,
1896 owner_appid: None,
1897 owner_session_id: None,
1898 };
1899 lock_state()
1900 .tabs
1901 .insert(product_tab_id.clone(), make_tab(false));
1902 lock_state()
1903 .tabs
1904 .insert(standalone_tab_id.clone(), make_tab(true));
1905 assert!(set_active_browser_tab(&product_tab_id));
1906
1907 let activated = browser_activate_tab(&standalone_tab_id).unwrap();
1908
1909 assert_eq!(activated.tab_id, standalone_tab_id);
1910 assert_eq!(
1911 browser_current_tab().map(|tab| tab.tab_id),
1912 Some(product_tab_id.clone())
1913 );
1914 assert_eq!(
1915 browser_automation_current_tab().map(|tab| tab.tab_id),
1916 Some(standalone_tab_id.clone())
1917 );
1918 lock_state().tabs.remove(&standalone_tab_id);
1919 lock_state().tabs.remove(&product_tab_id);
1920 lock_active_tab().take();
1921 lock_automation_tab().take();
1922 }
1923}