Skip to main content

lingxia_browser/
tabs.rs

1//! Browser tab state: tab id resolution and scopes, open/close/update/activate,
2//! and the create-token machinery shared with WebView creation.
3
4use 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// Internal browser tab model:
22// 1) All tabs are hosted by the built-in browser lxapp (BUILTIN_BROWSER_APPID).
23// 2) Callers may provide a stable tab key; the core resolves that key against an
24//    explicit scope and maps it to a canonical runtime UUID tab id.
25// 3) One canonical runtime tab id maps to one page path: /tabs/{tab_id}.
26// 4) One canonical runtime tab id owns one managed WebView instance lifecycle.
27
28#[derive(Clone)]
29pub(crate) struct BrowserTabState {
30    pub(crate) session_id: u64,
31    /// Stable creation sequence of the tab entry. Listing APIs order by this
32    /// value so `tabs()` reflects creation order regardless of id naming;
33    /// unlike `create_token` it never changes when a WebView is recreated.
34    pub(crate) created_order: u64,
35    /// Monotonic token to identify the current create lifecycle of this tab.
36    /// Used to ignore stale async callbacks when tab gets recreated quickly.
37    pub(crate) create_token: u64,
38    /// True while a WebView create for `create_token` is still in-flight.
39    /// Cleared once the create resolves; used to detect dead tabs whose
40    /// earlier create failed so they can be recreated instead of being
41    /// stuck with a `pending_url` that is never replayed.
42    pub(crate) create_in_flight: bool,
43    /// URL queued for loading while WebView creation is in-flight.
44    pub(crate) pending_url: Option<String>,
45    /// Normalized first URL. Aside reuse is keyed to this value and navigation
46    /// never rewrites it.
47    pub(crate) initial_url: Option<String>,
48    pub(crate) current_url: Option<String>,
49    pub(crate) title: Option<String>,
50    /// URL `title` was reported for. Titles are never cleared on navigation,
51    /// so nav-finish must not attribute page A's title to page B.
52    pub(crate) title_url: Option<String>,
53    /// PNG-encoded favicon of the current page, as reported by the platform
54    /// webview (`WebViewDelegate::on_favicon_changed`). `Arc`'d so shell
55    /// layers can mirror it into layout snapshots without copying.
56    pub(crate) favicon_png: Option<Arc<Vec<u8>>>,
57    /// Session-history availability reported by the platform webview
58    /// (`WebViewDelegate::on_history_changed`); drives smart back/forward
59    /// affordances in shell chrome.
60    pub(crate) can_go_back: bool,
61    pub(crate) can_go_forward: bool,
62    /// When true the tab's WebView has been destroyed to free memory
63    /// (Chrome-style discard); the entry/metadata is kept and the WebView is
64    /// recreated from `current_url` on reactivation.
65    pub(crate) discarded: bool,
66    /// Website-data lifetime preserved when a discarded WebView is recreated.
67    pub(crate) data_mode: WebViewDataMode,
68    /// URL-callback tabs reject every file navigation, including redirects
69    /// initiated after the initial HTTP(S) document loads.
70    pub(crate) url_callback: Arc<AtomicBool>,
71    /// When true this tab is hosted outside product browser chrome (e.g. a
72    /// docked aside or URL surface). New-window requests (`target=_blank`,
73    /// `window.open`) load in the same WebView instead of spawning a new
74    /// main-area tab, while automation can still discover the tab.
75    pub(crate) standalone: bool,
76    /// When true this tab belongs to the API-managed aside browser group.
77    pub(crate) aside: bool,
78    /// The lxapp that opened this tab (None for globally-scoped tabs). Used
79    /// to attribute follow-up surfaces (e.g. a new-window request from a
80    /// docked aside tab) to the right owner.
81    pub(crate) owner_appid: Option<String>,
82    /// Session that owned the tab. Keeping this separately from the browser
83    /// lxapp's `session_id` lets shells retire tabs after their owner restarts.
84    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    // tab_id -> tab lifecycle state (single WebView lifecycle per tab_id)
139    pub(crate) tabs: HashMap<String, BrowserTabState>,
140    recently_closed: Vec<ClosedBrowserTab>,
141    /// Complete browser-session user-agent override. WebView creation reads it
142    /// before the first load, including for tabs created or restored later.
143    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
160/// Process-wide observer invoked when the browser tab set/metadata changes.
161type TabsChangedHandler = Arc<dyn Fn() + Send + Sync>;
162/// Process-wide observer invoked when a caller wants a tab brought onscreen.
163type 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        // Pass the stored title only when it belongs to the finishing URL;
226        // otherwise it is a stale title from the previous page.
227        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
256/// Invokes the registered tabs-changed handler (if any). Must never be
257/// called while a browser state lock is held: handlers typically read the
258/// tab list back synchronously.
259pub(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
309/// Sets the active tab; returns whether the active tab actually changed.
310fn 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
425// ---------------------------------------------------------------------------
426// Owner resolution (used by FFI bridge layer)
427// ---------------------------------------------------------------------------
428
429fn 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    // Synthetic host: just owns tab session_id + page lifecycle. browser-shell
458    // upgrades this to a real asset bundle later (see lingxia-browser-shell).
459    lxapp::register_synthetic_lxapp(BUILTIN_BROWSER_APPID);
460}
461
462/// Ensure browser lxapp instance exists in manager.
463pub(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
516/// Tabs in creation order — the listing contract for automation and shells;
517/// id ordering is an accident of stable-key naming and must not leak out.
518pub 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    // Fall back to the earliest-created product tab (the stable "first" tab).
539    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    // Automation may select a standalone tab, but that must not change product
569    // browser chrome, LRU, or lifecycle ownership.
570    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            // Only non-empty titles update the record: an empty title means "not
612            // yet known" (e.g. a webview's initial KVO fire before the document
613            // title is parsed) and must never clobber a title already reported.
614            // Even an unchanged title re-binds title_url: the report is for the
615            // page currently loaded in the tab.
616            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
656/// Stores the webview-reported session-history availability for `tab_id`
657/// and fires the tabs-changed observer when it changed. Returns `false`
658/// when the tab does not exist.
659pub(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
683/// Stores the PNG favicon reported by the platform webview for `tab_id`
684/// (empty bytes clear it) and fires the tabs-changed observer when it
685/// actually changed. Returns `false` when the tab does not exist.
686pub(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
725/// PNG favicon currently stored for `tab_id`, if any.
726pub(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// ---------------------------------------------------------------------------
735// Create-token machinery (shared with the WebView creation flow)
736// ---------------------------------------------------------------------------
737
738#[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            // This create cycle now owns a live WebView; clear the in-flight
758            // marker so a missing WebView later means the tab must be recreated.
759            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
837// ---------------------------------------------------------------------------
838// Open / close
839// ---------------------------------------------------------------------------
840
841fn 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    // `lingxia://newtab` (and bare `lingxia://`) → startup page (no URL).
856    // Other `lingxia://` pages stay as-is and are served by the lingxia:// scheme handler.
857    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                // The browser lxapp restarted. Its old WebView generation
884                // cannot satisfy a navigation for the new native session.
885                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        // A standalone browser is hosted outside product browser chrome, so it
947        // must not drive the main coordinator's active-tab and memory policy.
948        if !standalone {
949            let _ = set_active_browser_tab(&tab_id);
950        }
951        notify_tabs_changed();
952        return Ok(tab_id);
953    }
954
955    // Existing tab — load target URL if provided.
956    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                // WebView is missing. If a create is still in-flight, keep
973                // pending_url for replay once the WebView becomes ready.
974                // Otherwise the earlier create failed (or the WebView is gone),
975                // so start a fresh create cycle instead of leaving the tab dead.
976                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
1033/// Start a new trusted top-level load for a registered browser control page.
1034/// Stable-tab reuse never degrades to focus-only: a live tab reloads through
1035/// `browser_load_internal_document`, while a missing/discarded generation is
1036/// recreated with this URL pending.
1037pub(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
1107/// The lxapp that opened `tab_id`, when it was owner-scoped.
1108pub(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
1116/// Whether `tab_id` belongs to the API-managed aside browser group.
1117pub(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
1128/// Whether `tab_id` is hosted outside product browser chrome. New-window
1129/// requests from a URL-callback (login) tab go to the OS browser; other
1130/// standalone tabs spawn a sibling aside rather than a main-area tab.
1131pub(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    // Resolve the concrete instance while this close still names the current
1167    // tab generation. A delayed close must never tear down a successor that
1168    // reused the same logical tab path.
1169    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        // Detach only when this tab currently backs the startup page bridge.
1189        // Closing a background tab must not break the active tab bridge.
1190        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            // remove_pages takes instance ids; resolve the tab page's live instance.
1206            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
1228/// Remove tabs owned by earlier incarnations of an lxapp. Pinned shortcuts
1229/// remain in the bookmark store and can reopen a fresh tab in the new session.
1230pub(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
1253/// Chrome-style tab discard: destroy the tab's WebView to free its native
1254/// memory while keeping the tab entry (`current_url` / `title`) so the sidebar
1255/// still shows it. Reactivation recreates the WebView and reloads the URL.
1256/// Refuses to discard the active tab.
1257pub(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        // Unknown or already discarded — nothing to free.
1268        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    // Bump the create token BEFORE destroying the WebView. If the WebView is
1281    // still being created, its in-flight `browser_on_webview_ready` holds the
1282    // old token; once `wait_ready()` errors after the destroy below, its
1283    // `browser_remove_tab_if_token_matches(old)` no longer matches and the
1284    // kept entry survives (otherwise reactivate would hit ResourceNotFound).
1285    if let Some(state) = lock_state().tabs.get_mut(&normalized) {
1286        state.create_token = next_browser_create_token();
1287    }
1288
1289    // Detach from the shared startup bridge if this tab backs it, and drop any
1290    // per-tab internal page — same dance as close_browser_tab.
1291    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        // remove_pages takes instance ids; resolve the tab page's live instance.
1306        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    // Keep the entry; remember where to reload from on reactivation. Preserve
1315    // an in-flight `pending_url` (WebView not yet loaded / mid-navigation);
1316    // only fall back to `current_url` when there is no pending target.
1317    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
1327/// Mark a tab as the active one without touching its WebView. Lets the SDK keep
1328/// the Rust-side active tab in sync when switching to an already-live tab, so
1329/// the discard policy doesn't mistake a backgrounded tab for the active one.
1330pub(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
1340/// Clear browser active state when the platform leaves browser UI entirely.
1341/// With no active browser tab, every tab is eligible for background memory
1342/// reclamation according to the host policy.
1343pub(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
1350/// Recreate a discarded tab's WebView and reload its saved URL, then make it
1351/// the active tab. No-op if the tab is already live.
1352pub(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    // Returns the create params when the tab needs its WebView rebuilt, or
1357    // `None` when it is already live (just needs (re)activating below).
1358    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            // `pending_url` already holds the saved `current_url` from discard.
1371            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/// Session-only restore metadata. Private/aside tabs never enter this stack.
1409#[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        // Restore website tabs only; internal pages require a fresh native trusted load.
1431        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        // Metadata bookkeeping needs no WebView; seed the tab entry directly.
1676        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        // Page B finishes before its title is reported: A's title must not leak.
1725        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}