use crate::BUILTIN_BROWSER_APPID;
use crate::internal_pages::registered_control_page_route;
use crate::policy::{is_lingxia_startup_url, normalize_browser_target_url};
use crate::types::{BrowserAutomationError, BrowserTabInfo, TrustedControlPageNavigation};
use crate::webview::{
browser_create_webview, browser_destroy_webview_if_matches, browser_find_webview,
browser_load_url,
};
use lingxia_platform::traits::app_runtime::AppRuntime;
use lingxia_webview::{WebView, WebViewDataMode};
use lxapp::{LxApp, LxAppError};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
pub(crate) const INTERNAL_TAB_PATH_PREFIX: &str = "/tabs/";
#[derive(Clone)]
pub(crate) struct BrowserTabState {
pub(crate) session_id: u64,
pub(crate) created_order: u64,
pub(crate) create_token: u64,
pub(crate) create_in_flight: bool,
pub(crate) pending_url: Option<String>,
pub(crate) initial_url: Option<String>,
pub(crate) current_url: Option<String>,
pub(crate) title: Option<String>,
pub(crate) title_url: Option<String>,
pub(crate) favicon_png: Option<Arc<Vec<u8>>>,
pub(crate) can_go_back: bool,
pub(crate) can_go_forward: bool,
pub(crate) discarded: bool,
pub(crate) data_mode: WebViewDataMode,
pub(crate) url_callback: Arc<AtomicBool>,
pub(crate) standalone: bool,
pub(crate) aside: bool,
pub(crate) owner_appid: Option<String>,
pub(crate) owner_session_id: Option<u64>,
}
fn tab_generation_matches(tab: &BrowserTabState, session_id: u64, create_token: u64) -> bool {
tab.session_id == session_id && tab.create_token == create_token
}
pub(crate) fn browser_tab_generation_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
lock_state()
.tabs
.get(&normalized)
.is_some_and(|tab| tab_generation_matches(tab, session_id, create_token))
}
pub(crate) fn browser_internal_url_if_token_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
) -> Option<String> {
let normalized = normalize_runtime_tab_id(tab_id)?;
let url = {
let state = lock_state();
let tab = state.tabs.get(&normalized)?;
tab_generation_matches(tab, session_id, create_token)
.then(|| tab.pending_url.clone().or_else(|| tab.current_url.clone()))
.flatten()?
};
(crate::policy::extract_url_scheme(&url).as_deref() == Some(crate::policy::LINGXIA_SCHEME))
.then_some(url)
}
fn browser_find_webview_for_generation(
tab_id: &str,
tab_path: &str,
session_id: u64,
create_token: u64,
) -> Option<Arc<WebView>> {
let current_generation = lock_state()
.tabs
.get(tab_id)
.is_some_and(|tab| tab_generation_matches(tab, session_id, create_token));
current_generation
.then(|| browser_find_webview(tab_path, session_id).ok())
.flatten()
}
pub(crate) struct BrowserState {
pub(crate) tabs: HashMap<String, BrowserTabState>,
recently_closed: Vec<ClosedBrowserTab>,
pub(crate) user_agent_override: Option<String>,
}
static BROWSER_STATE: OnceLock<Mutex<BrowserState>> = OnceLock::new();
static BROWSER_TAB_COUNTER: AtomicU64 = AtomicU64::new(1);
static BROWSER_CREATE_TOKEN: AtomicU64 = AtomicU64::new(1);
static BROWSER_CREATED_ORDER: AtomicU64 = AtomicU64::new(1);
static BROWSER_LOAD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
static BROWSER_ACTIVE_TAB_ID: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static BROWSER_AUTOMATION_TAB_ID: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static BROWSER_TABS_CHANGED_HANDLER: OnceLock<Mutex<Option<TabsChangedHandler>>> = OnceLock::new();
static BROWSER_TAB_PRESENT_HANDLER: OnceLock<Mutex<Option<TabPresentHandler>>> = OnceLock::new();
static BROWSER_NAVIGATION_FINISHED_HANDLER: OnceLock<Mutex<Option<NavigationFinishedHandler>>> =
OnceLock::new();
static BROWSER_TITLE_CHANGED_HANDLER: OnceLock<Mutex<Option<TitleChangedHandler>>> =
OnceLock::new();
type TabsChangedHandler = Arc<dyn Fn() + Send + Sync>;
type TabPresentHandler = Arc<dyn Fn(&str) + Send + Sync>;
type NavigationFinishedHandler = Arc<dyn Fn(&str, &str) + Send + Sync>;
type TitleChangedHandler = Arc<dyn Fn(&str, &str) + Send + Sync>;
pub(crate) fn set_tabs_changed_handler(handler: TabsChangedHandler) {
let slot = BROWSER_TABS_CHANGED_HANDLER.get_or_init(|| Mutex::new(None));
if let Ok(mut slot) = slot.lock() {
*slot = Some(handler);
}
}
pub(crate) fn set_tab_present_handler(handler: TabPresentHandler) {
let slot = BROWSER_TAB_PRESENT_HANDLER.get_or_init(|| Mutex::new(None));
if let Ok(mut slot) = slot.lock() {
*slot = Some(handler);
}
}
pub(crate) fn set_navigation_finished_handler(handler: NavigationFinishedHandler) {
let slot = BROWSER_NAVIGATION_FINISHED_HANDLER.get_or_init(|| Mutex::new(None));
if let Ok(mut slot) = slot.lock() {
*slot = Some(handler);
}
}
pub(crate) fn set_title_changed_handler(handler: TitleChangedHandler) {
let slot = BROWSER_TITLE_CHANGED_HANDLER.get_or_init(|| Mutex::new(None));
if let Ok(mut slot) = slot.lock() {
*slot = Some(handler);
}
}
fn records_browser_history(tab: &BrowserTabState) -> bool {
tab.data_mode != WebViewDataMode::Ephemeral
&& !tab.url_callback.load(Ordering::Acquire)
&& !tab.standalone
}
fn validate_reused_tab_policy(
tab: &BrowserTabState,
data_mode: WebViewDataMode,
standalone: bool,
) -> Result<(), LxAppError> {
if tab.data_mode != data_mode || tab.standalone != standalone {
return Err(LxAppError::InvalidParameter(
"an existing browser tab cannot change data mode or standalone status".to_string(),
));
}
Ok(())
}
pub(crate) fn notify_navigation_finished(
tab_id: &str,
session_id: u64,
create_token: u64,
url: &str,
) {
let handler = BROWSER_NAVIGATION_FINISHED_HANDLER
.get()
.and_then(|slot| slot.lock().ok())
.and_then(|slot| slot.clone());
if let Some(handler) = handler {
let title = {
let state = lock_state();
normalize_runtime_tab_id(tab_id)
.and_then(|normalized| state.tabs.get(&normalized))
.filter(|tab| tab.session_id == session_id && tab.create_token == create_token)
.filter(|tab| records_browser_history(tab))
.map(|tab| {
(tab.title_url.as_deref() == Some(url))
.then(|| tab.title.clone())
.flatten()
.unwrap_or_default()
})
};
if let Some(title) = title {
handler(url, &title);
}
}
}
fn notify_title_changed(url: &str, title: &str) {
let handler = BROWSER_TITLE_CHANGED_HANDLER
.get()
.and_then(|slot| slot.lock().ok())
.and_then(|slot| slot.clone());
if let Some(handler) = handler {
handler(url, title);
}
}
pub(crate) fn notify_tabs_changed() {
let handler = BROWSER_TABS_CHANGED_HANDLER
.get()
.and_then(|slot| slot.lock().ok())
.and_then(|slot| slot.clone());
if let Some(handler) = handler {
handler();
}
}
fn notify_tab_present_requested(tab_id: &str) {
let handler = BROWSER_TAB_PRESENT_HANDLER
.get()
.and_then(|slot| slot.lock().ok())
.and_then(|slot| slot.clone());
if let Some(handler) = handler {
handler(tab_id);
}
}
pub(crate) fn lock_state() -> MutexGuard<'static, BrowserState> {
BROWSER_STATE
.get_or_init(|| {
Mutex::new(BrowserState {
tabs: HashMap::new(),
recently_closed: Vec::new(),
user_agent_override: None,
})
})
.lock()
.unwrap_or_else(|e| {
lxapp::warn!("[InternalBrowser] recovered poisoned browser state mutex");
e.into_inner()
})
}
fn lock_active_tab() -> MutexGuard<'static, Option<String>> {
BROWSER_ACTIVE_TAB_ID
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn lock_automation_tab() -> MutexGuard<'static, Option<String>> {
BROWSER_AUTOMATION_TAB_ID
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn set_active_browser_tab(tab_id: &str) -> bool {
if is_standalone_tab(tab_id) {
return false;
}
let mut active = lock_active_tab();
if active.as_deref() == Some(tab_id) {
return false;
}
*active = Some(tab_id.to_string());
true
}
#[derive(Clone, Copy)]
pub(crate) enum BrowserTabScope<'a> {
Global,
OwnerSession {
owner_appid: &'a str,
owner_session_id: u64,
},
}
fn generate_tab_id() -> String {
loop {
let candidate = format!(
"tab-{}",
BROWSER_TAB_COUNTER.fetch_add(1, Ordering::Relaxed)
);
if !lock_state().tabs.contains_key(&candidate) {
return candidate;
}
}
}
fn validate_requested_tab_key(input: &str) -> Result<String, LxAppError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(LxAppError::InvalidParameter(
"tab_id is required".to_string(),
));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(LxAppError::InvalidParameter(
"tab_id must contain only ASCII letters, digits, '-' or '_'".to_string(),
));
}
Ok(trimmed.to_ascii_lowercase())
}
pub(crate) fn normalize_runtime_tab_id(input: &str) -> Option<String> {
validate_requested_tab_key(input).ok()
}
fn resolve_tab_scope_seed(scope: BrowserTabScope<'_>, stable_tab_key: &str) -> String {
match scope {
BrowserTabScope::Global => format!("global:{stable_tab_key}"),
BrowserTabScope::OwnerSession {
owner_appid,
owner_session_id,
} => format!("owner:{owner_appid}:{owner_session_id}:{stable_tab_key}"),
}
}
fn deterministic_tab_suffix(seed: &str) -> String {
const FNV_OFFSET_A: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
fn fnv1a64(bytes: &[u8], offset: u64, prime: u64) -> u64 {
let mut hash = offset;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(prime);
}
hash
}
format!(
"{:08x}",
fnv1a64(seed.as_bytes(), FNV_OFFSET_A, FNV_PRIME) as u32
)
}
fn resolve_browser_tab_id(
requested_tab_key: Option<&str>,
scope: BrowserTabScope<'_>,
) -> Result<String, LxAppError> {
match requested_tab_key {
Some(tab_key) => {
let stable_tab_key = validate_requested_tab_key(tab_key)?;
match scope {
BrowserTabScope::Global => Ok(stable_tab_key),
BrowserTabScope::OwnerSession { .. } => {
let seed = resolve_tab_scope_seed(scope, &stable_tab_key);
Ok(format!(
"{}-{}",
stable_tab_key,
deterministic_tab_suffix(&seed)
))
}
}
}
None => Ok(generate_tab_id()),
}
}
fn next_browser_create_token() -> u64 {
BROWSER_CREATE_TOKEN.fetch_add(1, Ordering::Relaxed)
}
fn next_browser_created_order() -> u64 {
BROWSER_CREATED_ORDER.fetch_add(1, Ordering::Relaxed)
}
fn resolve_owner_lxapp(owner_appid: &str, owner_session_id: u64) -> Result<Arc<LxApp>, LxAppError> {
let owner_appid = owner_appid.trim();
if owner_appid.is_empty() || owner_session_id == 0 {
return Err(LxAppError::InvalidParameter(
"owner_appid and owner_session_id are required".to_string(),
));
}
let owner = lxapp::try_get(owner_appid).ok_or_else(|| {
LxAppError::ResourceNotFound(format!(
"owner lxapp not found for browser tab operation: {}",
owner_appid
))
})?;
if owner.session_id() != owner_session_id {
return Err(LxAppError::InvalidParameter(format!(
"owner session mismatch for {}: expected {}, got {}",
owner_appid,
owner.session_id(),
owner_session_id
)));
}
Ok(owner)
}
pub(crate) fn register_builtin_browser_host() {
lxapp::register_synthetic_lxapp(BUILTIN_BROWSER_APPID);
}
pub(crate) fn ensure_browser_lxapp() -> Result<Arc<LxApp>, LxAppError> {
let _load_guard = BROWSER_LOAD_MUTEX
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(browser) = lxapp::try_get(BUILTIN_BROWSER_APPID) {
return Ok(browser);
}
lxapp::ensure_builtin_lxapp(BUILTIN_BROWSER_APPID)
}
pub(crate) fn browser_tab_path_for_runtime_id(tab_id: &str) -> String {
format!("{INTERNAL_TAB_PATH_PREFIX}{tab_id}")
}
pub(crate) fn browser_tab_path_for_id(tab_id: &str) -> String {
normalize_runtime_tab_id(tab_id)
.map(|tab_id| browser_tab_path_for_runtime_id(&tab_id))
.unwrap_or_else(|| INTERNAL_TAB_PATH_PREFIX.to_string())
}
pub(crate) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
let text = value.unwrap_or_default().trim();
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
fn build_tab_info(tab_id: &str, state: &BrowserTabState) -> BrowserTabInfo {
BrowserTabInfo {
tab_id: tab_id.to_string(),
path: browser_tab_path_for_runtime_id(tab_id),
session_id: state.session_id,
current_url: state.current_url.clone(),
title: state.title.clone(),
can_go_back: state.can_go_back,
can_go_forward: state.can_go_forward,
}
}
pub fn browser_tab_info(tab_id: &str) -> Option<BrowserTabInfo> {
let normalized = normalize_runtime_tab_id(tab_id)?;
let state = lock_state();
state
.tabs
.get(&normalized)
.map(|tab| build_tab_info(&normalized, tab))
}
pub fn browser_tabs() -> Vec<BrowserTabInfo> {
let state = lock_state();
let mut tabs: Vec<(u64, BrowserTabInfo)> = state
.tabs
.iter()
.map(|(tab_id, tab)| (tab.created_order, build_tab_info(tab_id, tab)))
.collect();
tabs.sort_by_key(|(created_order, _)| *created_order);
tabs.into_iter().map(|(_, tab)| tab).collect()
}
pub fn browser_current_tab() -> Option<BrowserTabInfo> {
let active_tab_id = lock_active_tab().clone();
let state = lock_state();
if let Some(tab_id) = active_tab_id
&& let Some(tab) = state.tabs.get(&tab_id)
&& !tab.standalone
{
return Some(build_tab_info(&tab_id, tab));
}
state
.tabs
.iter()
.filter(|(_, tab)| !tab.standalone)
.min_by_key(|(_, tab)| tab.created_order)
.map(|(tab_id, tab)| build_tab_info(tab_id, tab))
}
pub fn browser_automation_current_tab() -> Option<BrowserTabInfo> {
if let Some(tab_id) = lock_automation_tab().clone()
&& let Some(info) = browser_tab_info(&tab_id)
{
return Some(info);
}
browser_current_tab().or_else(|| browser_tabs().into_iter().next())
}
pub fn browser_activate_tab(tab_id: &str) -> Result<BrowserTabInfo, BrowserAutomationError> {
let normalized_tab_id = normalize_runtime_tab_id(tab_id)
.ok_or_else(|| BrowserAutomationError::TabNotFound(tab_id.to_string()))?;
let (info, standalone) = {
let state = lock_state();
let tab = state
.tabs
.get(&normalized_tab_id)
.ok_or_else(|| BrowserAutomationError::TabNotFound(tab_id.to_string()))?;
(build_tab_info(&normalized_tab_id, tab), tab.standalone)
};
*lock_automation_tab() = Some(normalized_tab_id.clone());
if !standalone && set_active_browser_tab(&normalized_tab_id) {
notify_tabs_changed();
}
Ok(info)
}
pub fn browser_present_tab(tab_id: &str) -> Result<BrowserTabInfo, BrowserAutomationError> {
let info = browser_activate_tab(tab_id)?;
notify_tab_present_requested(&info.tab_id);
Ok(info)
}
fn browser_update_tab_info_inner(
tab_id: &str,
expected_generation: Option<(u64, u64)>,
current_url: Option<&str>,
title: Option<&str>,
) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
let (changed, changed_title) = {
let mut state = lock_state();
let Some(tab) = state.tabs.get_mut(&normalized) else {
return false;
};
if expected_generation.is_some_and(|(session_id, create_token)| {
tab.session_id != session_id || tab.create_token != create_token
}) {
return false;
}
let mut changed = false;
let mut changed_title = None;
if current_url.is_some() {
let value = normalize_optional_string(current_url);
if tab.current_url != value {
tab.current_url = value;
changed = true;
}
}
if let Some(value) = normalize_optional_string(title) {
tab.title_url = tab.current_url.clone();
if tab.title.as_deref() != Some(value.as_str()) {
tab.title = Some(value.clone());
if records_browser_history(tab)
&& let Some(url) = tab.current_url.clone()
{
changed_title = Some((url, value));
}
changed = true;
}
}
(changed, changed_title)
};
if changed {
notify_tabs_changed();
}
if let Some((url, title)) = changed_title {
notify_title_changed(&url, &title);
}
true
}
pub(crate) fn browser_update_tab_info(
tab_id: &str,
current_url: Option<&str>,
title: Option<&str>,
) -> bool {
browser_update_tab_info_inner(tab_id, None, current_url, title)
}
pub(crate) fn browser_update_tab_info_if_token_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
current_url: Option<&str>,
title: Option<&str>,
) -> bool {
browser_update_tab_info_inner(tab_id, Some((session_id, create_token)), current_url, title)
}
pub(crate) fn browser_update_tab_nav_state(
tab_id: &str,
can_go_back: bool,
can_go_forward: bool,
) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
let changed = {
let mut state = lock_state();
let Some(tab) = state.tabs.get_mut(&normalized) else {
return false;
};
let changed = tab.can_go_back != can_go_back || tab.can_go_forward != can_go_forward;
tab.can_go_back = can_go_back;
tab.can_go_forward = can_go_forward;
changed
};
if changed {
notify_tabs_changed();
}
true
}
pub(crate) fn browser_update_tab_favicon(tab_id: &str, png_bytes: Vec<u8>) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
let value = if png_bytes.is_empty() {
None
} else {
Some(Arc::new(png_bytes))
};
let cache_bytes = value.clone();
let (changed, url) = {
let mut state = lock_state();
let Some(tab) = state.tabs.get_mut(&normalized) else {
return false;
};
let same = match (&tab.favicon_png, &value) {
(None, None) => true,
(Some(old), Some(new)) => old.as_ref() == new.as_ref(),
_ => false,
};
if !same {
tab.favicon_png = value;
}
(
!same,
(tab.data_mode != WebViewDataMode::Ephemeral)
.then(|| tab.current_url.clone())
.flatten(),
)
};
if changed {
if let (Some(url), Some(bytes), Some(runtime)) = (url, cache_bytes, lxapp::get_platform()) {
lingxia_service::favicon::store_for_url(&runtime.app_cache_dir(), &url, &bytes);
}
notify_tabs_changed();
}
true
}
pub(crate) fn browser_tab_favicon(tab_id: &str) -> Option<Arc<Vec<u8>>> {
let normalized = normalize_runtime_tab_id(tab_id)?;
lock_state()
.tabs
.get(&normalized)
.and_then(|tab| tab.favicon_png.clone())
}
#[derive(Debug)]
pub(crate) enum TabCreateState {
Active {
pending_url: Option<String>,
user_agent_override: Option<String>,
},
Missing,
Stale,
}
pub(crate) fn browser_tab_create_state(
tab_id: &str,
session_id: u64,
create_token: u64,
) -> TabCreateState {
let mut state = lock_state();
let user_agent_override = state.user_agent_override.clone();
match state.tabs.get_mut(tab_id) {
Some(tab) if tab.session_id == session_id && tab.create_token == create_token => {
tab.create_in_flight = false;
TabCreateState::Active {
pending_url: tab.pending_url.clone(),
user_agent_override,
}
}
Some(_) => TabCreateState::Stale,
None => TabCreateState::Missing,
}
}
pub(crate) fn browser_remove_tab_if_token_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
) {
let removed = {
let mut state = lock_state();
let should_remove = state
.tabs
.get(tab_id)
.map(|tab| tab.session_id == session_id && tab.create_token == create_token)
.unwrap_or(false);
if should_remove {
state.tabs.remove(tab_id);
}
should_remove
};
if removed {
notify_tabs_changed();
}
}
pub(crate) fn browser_clear_pending_if_token_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
) {
let mut state = lock_state();
if let Some(tab) = state.tabs.get_mut(tab_id)
&& tab.session_id == session_id
&& tab.create_token == create_token
{
tab.pending_url = None;
}
}
pub(crate) fn browser_commit_navigation_if_token_matches(
tab_id: &str,
session_id: u64,
create_token: u64,
current_url: Option<&str>,
) {
let committed = {
let mut state = lock_state();
if let Some(tab) = state.tabs.get_mut(tab_id)
&& tab.session_id == session_id
&& tab.create_token == create_token
{
tab.pending_url = None;
tab.current_url = normalize_optional_string(current_url);
true
} else {
false
}
};
if committed {
notify_tabs_changed();
}
}
fn browser_clear_pending_url(tab_id: &str) {
let mut state = lock_state();
if let Some(tab) = state.tabs.get_mut(tab_id) {
tab.pending_url = None;
}
}
fn open_internal_browser_tab_with_scope(
url: &str,
requested_tab_key: Option<&str>,
scope: BrowserTabScope<'_>,
standalone: bool,
aside: bool,
data_mode: WebViewDataMode,
url_callback: bool,
) -> Result<String, LxAppError> {
let browser = ensure_browser_lxapp()?;
let browser_session_id = browser.session_id();
let raw_url = url.trim();
let effective_url: String = match is_lingxia_startup_url(raw_url) {
Some(true) => String::new(),
_ => raw_url.to_string(),
};
let target_url = effective_url.as_str();
let normalized_target_url = normalize_browser_target_url(target_url);
let has_target_url = !normalized_target_url.is_empty();
let (owner_appid, owner_session_id) = match scope {
BrowserTabScope::Global => (None, None),
BrowserTabScope::OwnerSession {
owner_appid,
owner_session_id,
} => (Some(owner_appid.to_string()), Some(owner_session_id)),
};
let tab_id = resolve_browser_tab_id(requested_tab_key, scope)?;
let path = browser_tab_path_for_runtime_id(&tab_id);
let session_id = browser_session_id;
let mut create_token: Option<u64> = None;
let mut is_new_tab = false;
let url_callback_policy = {
let mut state = lock_state();
if let Some(existing) = state.tabs.get_mut(&tab_id) {
validate_reused_tab_policy(existing, data_mode, standalone)?;
if existing.session_id != session_id {
existing.create_in_flight = false;
}
existing.session_id = session_id;
existing.url_callback.store(url_callback, Ordering::Release);
if has_target_url {
existing.pending_url = Some(normalized_target_url.clone());
}
existing.url_callback.clone()
} else {
is_new_tab = true;
let token = next_browser_create_token();
create_token = Some(token);
let url_callback_policy = Arc::new(AtomicBool::new(url_callback));
state.tabs.insert(
tab_id.clone(),
BrowserTabState {
session_id,
created_order: next_browser_created_order(),
create_token: token,
create_in_flight: true,
pending_url: if has_target_url {
Some(normalized_target_url.clone())
} else {
None
},
initial_url: has_target_url.then(|| {
lxapp::lingxia_surface::normalize_initial_url(&normalized_target_url)
}),
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode,
url_callback: url_callback_policy.clone(),
standalone,
aside,
owner_appid,
owner_session_id,
},
);
url_callback_policy
}
};
if is_new_tab {
let token = create_token.expect("create_token must exist for new tab");
if let Err(e) = browser_create_webview(
&path,
session_id,
&tab_id,
token,
data_mode,
url_callback_policy.clone(),
standalone,
) {
lock_state().tabs.remove(&tab_id);
return Err(e);
}
if !standalone {
let _ = set_active_browser_tab(&tab_id);
}
notify_tabs_changed();
return Ok(tab_id);
}
if has_target_url {
let create_token = lock_state()
.tabs
.get(&tab_id)
.map(|tab| tab.create_token)
.ok_or_else(|| {
LxAppError::ResourceNotFound(format!("browser tab not found: {tab_id}"))
})?;
match browser_load_url(&path, session_id, create_token, &normalized_target_url) {
Ok(()) => {
if let Some(s) = lock_state().tabs.get_mut(&tab_id) {
s.pending_url = None;
s.current_url = Some(normalized_target_url.clone());
}
}
Err(LxAppError::ResourceNotFound(_)) => {
let retry_token = {
let mut state = lock_state();
match state.tabs.get_mut(&tab_id) {
Some(tab) if !tab.create_in_flight => {
let token = next_browser_create_token();
tab.create_token = token;
tab.create_in_flight = true;
tab.discarded = false;
Some(token)
}
_ => None,
}
};
if let Some(token) = retry_token
&& let Err(e) = browser_create_webview(
&path,
session_id,
&tab_id,
token,
data_mode,
url_callback_policy.clone(),
standalone,
)
{
lock_state().tabs.remove(&tab_id);
return Err(e);
}
}
Err(e) => {
browser_clear_pending_url(&tab_id);
return Err(e);
}
}
}
if !standalone {
let _ = set_active_browser_tab(&tab_id);
}
notify_tabs_changed();
Ok(tab_id)
}
pub(crate) fn open_internal_browser_tab(
url: &str,
tab_id: Option<&str>,
) -> Result<String, LxAppError> {
open_internal_browser_tab_with_scope(
url,
tab_id,
BrowserTabScope::Global,
false,
false,
WebViewDataMode::ProfileDefault,
false,
)
}
pub(crate) fn navigate_trusted_control_page(
url: &str,
) -> Result<TrustedControlPageNavigation, LxAppError> {
let route = registered_control_page_route(url).ok_or_else(|| {
LxAppError::InvalidParameter(format!(
"trusted browser control route is not registered: {url}"
))
})?;
let tab_id = open_internal_browser_tab_with_scope(
url,
Some(&route),
BrowserTabScope::Global,
false,
false,
WebViewDataMode::ProfileDefault,
false,
)?;
let identity = browser_tab_info(&tab_id).ok_or_else(|| {
LxAppError::ResourceNotFound(format!("trusted browser control tab disappeared: {tab_id}"))
})?;
Ok(TrustedControlPageNavigation {
tab_id,
browser_session_id: identity.session_id,
})
}
pub(crate) fn open_internal_browser_tab_for_owner(
owner_appid: &str,
owner_session_id: u64,
url: &str,
tab_id: Option<&str>,
standalone: bool,
aside: bool,
data_mode: WebViewDataMode,
url_callback: bool,
) -> Result<String, LxAppError> {
let _owner = resolve_owner_lxapp(owner_appid, owner_session_id)?;
if aside && tab_id.is_none() {
let normalized_target = normalize_browser_target_url(url);
let initial_url = lxapp::lingxia_surface::normalize_initial_url(&normalized_target);
let reusable = {
let state = lock_state();
state.tabs.iter().find_map(|(tab_id, tab)| {
(tab.aside
&& tab.owner_appid.as_deref() == Some(owner_appid)
&& tab.owner_session_id == Some(owner_session_id)
&& tab.initial_url.as_deref() == Some(initial_url.as_str()))
.then(|| tab_id.clone())
})
};
if let Some(tab_id) = reusable {
let _ = set_active_browser_tab(&tab_id);
notify_tabs_changed();
return Ok(tab_id);
}
}
open_internal_browser_tab_with_scope(
url,
tab_id,
BrowserTabScope::OwnerSession {
owner_appid,
owner_session_id,
},
standalone,
aside,
data_mode,
url_callback,
)
}
pub(crate) fn tab_owner_appid(tab_id: &str) -> Option<String> {
let normalized = normalize_runtime_tab_id(tab_id)?;
lock_state()
.tabs
.get(&normalized)
.and_then(|tab| tab.owner_appid.clone())
}
pub(crate) fn is_aside_tab(tab_id: &str) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
lock_state()
.tabs
.get(&normalized)
.map(|tab| tab.aside)
.unwrap_or(false)
}
pub(crate) fn is_standalone_tab(tab_id: &str) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
lock_state()
.tabs
.get(&normalized)
.map(|tab| tab.standalone)
.unwrap_or(false)
}
pub fn browser_tab_exists(tab_id: &str) -> bool {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return false;
};
lock_state().tabs.contains_key(&normalized)
}
pub(crate) fn close_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
close_browser_tab_inner(tab_id, true)
}
fn close_browser_tab_inner(tab_id: &str, remember: bool) -> Result<(), LxAppError> {
let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
})?;
let tab_path = browser_tab_path_for_runtime_id(&normalized);
let generation = {
let state = lock_state();
state
.tabs
.get(&normalized)
.map(|tab| (tab.session_id, tab.create_token))
};
let closing_webview = generation.and_then(|(session_id, create_token)| {
browser_find_webview_for_generation(&normalized, &tab_path, session_id, create_token)
});
let removed = {
let mut state = lock_state();
let removed = state.tabs.remove(&normalized);
if remember
&& let Some(tab) = removed.as_ref()
&& let Some(entry) = ClosedBrowserTab::from_tab(tab)
{
state.recently_closed.push(entry);
if state.recently_closed.len() > 25 {
state.recently_closed.remove(0);
}
}
removed
};
let removed_any = removed.is_some();
if let Some(tab) = removed {
if let Ok(browser) = ensure_browser_lxapp() {
let startup_path = browser.initial_route();
if let Some(page) = browser.get_page(&startup_path) {
let startup_webview = page.webview();
let closing_tab_webview = closing_webview.clone();
if let (Some(startup_webview), Some(closing_tab_webview)) =
(startup_webview, closing_tab_webview)
&& Arc::ptr_eq(&startup_webview, &closing_tab_webview)
{
page.detach_webview();
}
}
if let Some(page) = browser.get_page(&tab_path) {
page.detach_webview();
}
if let Some(page) = browser.get_page(&tab_path) {
browser.remove_pages(std::slice::from_ref(&page.instance_id_string()));
}
}
if let Some(webview) = closing_webview {
browser_destroy_webview_if_matches(&tab_path, tab.session_id, &webview);
}
}
let active_matches_closed = lock_active_tab().as_deref() == Some(normalized.as_str());
if active_matches_closed {
let next = browser_current_tab().map(|tab| tab.tab_id);
*lock_active_tab() = next;
}
if lock_automation_tab().as_deref() == Some(normalized.as_str()) {
lock_automation_tab().take();
}
if removed_any || active_matches_closed {
notify_tabs_changed();
}
Ok(())
}
pub(crate) fn prune_stale_owner_tabs(owner_appid: &str, current_session_id: u64) -> usize {
let stale_ids = {
let state = lock_state();
state
.tabs
.iter()
.filter(|(_, tab)| {
tab.owner_appid.as_deref() == Some(owner_appid)
&& tab.owner_session_id != Some(current_session_id)
})
.map(|(tab_id, _)| tab_id.clone())
.collect::<Vec<_>>()
};
for tab_id in &stale_ids {
let _ = close_browser_tab_inner(tab_id, false);
}
lock_state().recently_closed.retain(|entry| {
entry.owner_appid.as_deref() != Some(owner_appid)
|| entry.owner_session_id == Some(current_session_id)
});
stale_ids.len()
}
pub(crate) fn discard_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
})?;
if lock_active_tab().as_deref() == Some(normalized.as_str()) {
return Err(LxAppError::InvalidParameter(
"cannot discard the active browser tab".to_string(),
));
}
let tab = match lock_state().tabs.get(&normalized).cloned() {
Some(tab) if !tab.discarded => tab,
_ => return Ok(()),
};
let tab_path = browser_tab_path_for_runtime_id(&normalized);
let webview = browser_find_webview_for_generation(
&normalized,
&tab_path,
tab.session_id,
tab.create_token,
);
if let Some(state) = lock_state().tabs.get_mut(&normalized) {
state.create_token = next_browser_create_token();
}
if let Ok(browser) = ensure_browser_lxapp() {
let startup_path = browser.initial_route();
if let Some(page) = browser.get_page(&startup_path) {
let startup_webview = page.webview();
let tab_webview = webview.clone();
if let (Some(startup_webview), Some(tab_webview)) = (startup_webview, tab_webview)
&& Arc::ptr_eq(&startup_webview, &tab_webview)
{
page.detach_webview();
}
}
if let Some(page) = browser.get_page(&tab_path) {
page.detach_webview();
}
if let Some(page) = browser.get_page(&tab_path) {
browser.remove_pages(std::slice::from_ref(&page.instance_id_string()));
}
}
if let Some(webview) = webview {
browser_destroy_webview_if_matches(&tab_path, tab.session_id, &webview);
}
if let Some(state) = lock_state().tabs.get_mut(&normalized) {
state.discarded = true;
state.create_in_flight = false;
if state.pending_url.is_none() {
state.pending_url = state.current_url.clone();
}
}
Ok(())
}
pub(crate) fn mark_browser_tab_active(tab_id: &str) {
let Some(normalized) = normalize_runtime_tab_id(tab_id) else {
return;
};
let exists = lock_state().tabs.contains_key(&normalized);
if exists && set_active_browser_tab(&normalized) {
notify_tabs_changed();
}
}
pub(crate) fn clear_active_browser_tab() {
let changed = lock_active_tab().take().is_some();
if changed {
notify_tabs_changed();
}
}
pub(crate) fn reactivate_browser_tab(tab_id: &str) -> Result<(), LxAppError> {
let normalized = normalize_runtime_tab_id(tab_id).ok_or_else(|| {
LxAppError::InvalidParameter("tab_id must be a valid runtime browser tab id".to_string())
})?;
let recreate = {
let mut state = lock_state();
let Some(tab) = state.tabs.get_mut(&normalized) else {
return Err(LxAppError::ResourceNotFound(
"browser tab not found".to_string(),
));
};
if tab.discarded {
let token = next_browser_create_token();
tab.create_token = token;
tab.discarded = false;
tab.create_in_flight = true;
Some((
tab.session_id,
token,
tab.data_mode,
tab.url_callback.clone(),
tab.standalone,
))
} else {
None
}
};
if let Some((session_id, token, data_mode, url_callback, standalone)) = recreate {
let path = browser_tab_path_for_runtime_id(&normalized);
if let Err(error) = browser_create_webview(
&path,
session_id,
&normalized,
token,
data_mode,
url_callback,
standalone,
) {
if let Some(tab) = lock_state().tabs.get_mut(&normalized) {
tab.discarded = true;
tab.create_in_flight = false;
}
return Err(error);
}
}
if set_active_browser_tab(&normalized) {
notify_tabs_changed();
}
Ok(())
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClosedBrowserTab {
pub id: String,
pub url: String,
pub title: String,
#[serde(skip)]
owner_appid: Option<String>,
#[serde(skip)]
owner_session_id: Option<u64>,
}
impl ClosedBrowserTab {
fn from_tab(tab: &BrowserTabState) -> Option<Self> {
if tab.standalone || tab.aside || tab.data_mode == WebViewDataMode::Ephemeral {
return None;
}
let url = tab
.current_url
.clone()
.or_else(|| tab.pending_url.clone())?;
if !matches!(
crate::policy::extract_url_scheme(&url).as_deref(),
Some("http" | "https")
) {
return None;
}
Some(Self {
id: uuid::Uuid::new_v4().to_string(),
url,
title: tab.title.clone().unwrap_or_default(),
owner_appid: tab.owner_appid.clone(),
owner_session_id: tab.owner_session_id,
})
}
}
pub fn recently_closed() -> Vec<ClosedBrowserTab> {
lock_state().recently_closed.iter().rev().cloned().collect()
}
pub fn reopen_closed(id: Option<&str>) -> Result<String, LxAppError> {
let entry = {
let mut state = lock_state();
let index = state
.recently_closed
.iter()
.rposition(|entry| id.is_none_or(|id| entry.id == id))
.ok_or_else(|| LxAppError::ResourceNotFound("no recently closed tab".to_string()))?;
state.recently_closed.remove(index)
};
let result = match (&entry.owner_appid, entry.owner_session_id) {
(Some(appid), Some(session_id)) => crate::open_for_app(appid, session_id, &entry.url, None),
_ => crate::open(&entry.url, None),
};
match result {
Ok(tab_id) => {
browser_update_tab_info(&tab_id, None, Some(&entry.title));
let _ = crate::present(&tab_id);
Ok(tab_id)
}
Err(error) => {
let mut state = lock_state();
state.recently_closed.push(entry);
if state.recently_closed.len() > 25 {
state.recently_closed.remove(0);
}
Err(error)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recently_closed_only_records_normal_website_tabs_with_owner() {
let mut tab = BrowserTabState {
session_id: 1,
created_order: 1,
create_token: 1,
create_in_flight: false,
pending_url: None,
initial_url: None,
current_url: Some("https://example.test/".into()),
title: Some("Example".into()),
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: Some("owner".into()),
owner_session_id: Some(42),
};
let entry = ClosedBrowserTab::from_tab(&tab).unwrap();
assert_eq!(entry.url, "https://example.test/");
assert_eq!(entry.owner_session_id, Some(42));
assert_eq!(entry.title, "Example");
tab.data_mode = WebViewDataMode::Ephemeral;
assert!(ClosedBrowserTab::from_tab(&tab).is_none());
tab.data_mode = WebViewDataMode::ProfileDefault;
tab.aside = true;
assert!(ClosedBrowserTab::from_tab(&tab).is_none());
tab.aside = false;
tab.standalone = true;
assert!(ClosedBrowserTab::from_tab(&tab).is_none());
tab.standalone = false;
tab.current_url = Some("lingxia://settings".into());
assert!(ClosedBrowserTab::from_tab(&tab).is_none());
}
#[test]
fn webview_creation_receives_the_browser_user_agent_override() {
let tab_id = generate_tab_id();
let previous_user_agent = {
let mut state = lock_state();
let previous = state
.user_agent_override
.replace("TestAgent/1.0".to_string());
state.tabs.insert(
tab_id.clone(),
BrowserTabState {
session_id: 42,
created_order: next_browser_created_order(),
create_token: 7,
create_in_flight: true,
pending_url: Some("https://example.test/".to_string()),
initial_url: Some("https://example.test/".to_string()),
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: None,
owner_session_id: None,
},
);
previous
};
let state = browser_tab_create_state(&tab_id, 42, 7);
assert!(matches!(
state,
TabCreateState::Active {
pending_url: Some(url),
user_agent_override: Some(user_agent),
} if url == "https://example.test/" && user_agent == "TestAgent/1.0"
));
assert!(!lock_state().tabs[&tab_id].create_in_flight);
let mut state = lock_state();
state.tabs.remove(&tab_id);
state.user_agent_override = previous_user_agent;
}
#[test]
fn tab_generation_match_rejects_a_recreated_tab() {
let tab = BrowserTabState {
session_id: 42,
created_order: 1,
create_token: 8,
create_in_flight: false,
pending_url: None,
initial_url: None,
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: None,
owner_session_id: None,
};
assert!(tab_generation_matches(&tab, 42, 8));
assert!(!tab_generation_matches(&tab, 42, 7));
assert!(!tab_generation_matches(&tab, 41, 8));
}
#[test]
fn discarded_or_recreated_tab_rejects_stale_internal_reload() {
let tab_id = generate_tab_id();
lock_state().tabs.insert(
tab_id.clone(),
BrowserTabState {
session_id: 42,
created_order: next_browser_created_order(),
create_token: 8,
create_in_flight: false,
pending_url: None,
initial_url: Some("lingxia://settings".to_string()),
current_url: Some("lingxia://settings".to_string()),
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: None,
owner_session_id: None,
},
);
assert_eq!(
browser_internal_url_if_token_matches(&tab_id, 42, 8).as_deref(),
Some("lingxia://settings")
);
lock_state().tabs.get_mut(&tab_id).unwrap().create_token = 9;
assert_eq!(browser_internal_url_if_token_matches(&tab_id, 42, 8), None);
lock_state().tabs.remove(&tab_id);
}
#[test]
fn stable_browser_tab_ids_are_deterministic_per_scope() {
let global_a = resolve_browser_tab_id(Some("settings"), BrowserTabScope::Global).unwrap();
let global_b = resolve_browser_tab_id(Some("settings"), BrowserTabScope::Global).unwrap();
let owner_a = resolve_browser_tab_id(
Some("settings"),
BrowserTabScope::OwnerSession {
owner_appid: "app.demo",
owner_session_id: 1,
},
)
.unwrap();
let owner_b = resolve_browser_tab_id(
Some("settings"),
BrowserTabScope::OwnerSession {
owner_appid: "app.demo",
owner_session_id: 2,
},
)
.unwrap();
assert_eq!(global_a, global_b);
assert_ne!(global_a, owner_a);
assert_ne!(owner_a, owner_b);
}
#[test]
fn stable_browser_tab_ids_reject_invalid_keys() {
let result = resolve_browser_tab_id(Some("settings/main"), BrowserTabScope::Global);
assert!(matches!(result, Err(LxAppError::InvalidParameter(_))));
}
#[test]
fn navigation_history_respects_title_privacy_and_generation() {
let tab_id = "navfinishtitletest";
lock_state().tabs.insert(
tab_id.to_string(),
BrowserTabState {
session_id: 1,
created_order: next_browser_created_order(),
create_token: 1,
create_in_flight: false,
pending_url: None,
initial_url: None,
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: None,
owner_session_id: None,
},
);
assert!(browser_update_tab_info(
tab_id,
Some("https://a.test/"),
Some("Page A")
));
let visits: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let visit_sink = visits.clone();
set_navigation_finished_handler(Arc::new(move |url, title| {
visit_sink
.lock()
.unwrap()
.push((url.to_string(), title.to_string()));
}));
let titles: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let title_sink = titles.clone();
set_title_changed_handler(Arc::new(move |url, title| {
title_sink
.lock()
.unwrap()
.push((url.to_string(), title.to_string()));
}));
notify_navigation_finished(tab_id, 1, 1, "https://b.test/");
notify_navigation_finished(tab_id, 1, 1, "https://a.test/");
assert_eq!(
*visits.lock().unwrap(),
vec![
("https://b.test/".to_string(), String::new()),
("https://a.test/".to_string(), "Page A".to_string()),
]
);
assert!(browser_update_tab_info_if_token_matches(
tab_id,
1,
1,
Some("https://normal.test/"),
Some("Normal")
));
assert_eq!(
*titles.lock().unwrap(),
vec![("https://normal.test/".to_string(), "Normal".to_string())]
);
let assert_private_policy =
|data_mode: WebViewDataMode, url_callback: bool, standalone: bool, suffix: &str| {
{
let mut state = lock_state();
let tab = state.tabs.get_mut(tab_id).unwrap();
tab.data_mode = data_mode;
tab.url_callback.store(url_callback, Ordering::Release);
tab.standalone = standalone;
}
let url = format!("https://auth.test/callback?code={suffix}");
notify_navigation_finished(tab_id, 1, 1, &url);
assert!(browser_update_tab_info_if_token_matches(
tab_id,
1,
1,
Some(&url),
Some(&format!("Secret {suffix}"))
));
assert_eq!(visits.lock().unwrap().len(), 2);
assert_eq!(titles.lock().unwrap().len(), 1);
};
assert_private_policy(WebViewDataMode::Ephemeral, false, false, "ephemeral");
assert_private_policy(WebViewDataMode::ProfileDefault, true, false, "callback");
assert_private_policy(WebViewDataMode::ProfileDefault, false, true, "standalone");
{
let mut state = lock_state();
let tab = state.tabs.get_mut(tab_id).unwrap();
tab.data_mode = WebViewDataMode::ProfileDefault;
tab.standalone = false;
tab.create_token = 2;
}
assert!(!browser_update_tab_info_if_token_matches(
tab_id,
1,
1,
Some("https://auth.test/callback?code=stale"),
Some("Stale secret")
));
notify_navigation_finished(tab_id, 1, 1, "https://auth.test/callback?code=stale");
assert_eq!(visits.lock().unwrap().len(), 2);
assert_eq!(titles.lock().unwrap().len(), 1);
lock_state().tabs.remove(tab_id);
}
#[test]
fn stable_tab_reuse_rejects_privacy_policy_changes() {
let tab = BrowserTabState {
session_id: 1,
created_order: next_browser_created_order(),
create_token: 1,
create_in_flight: false,
pending_url: None,
initial_url: None,
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone: false,
aside: false,
owner_appid: None,
owner_session_id: None,
};
assert!(validate_reused_tab_policy(&tab, WebViewDataMode::ProfileDefault, false).is_ok());
assert!(validate_reused_tab_policy(&tab, WebViewDataMode::Ephemeral, false).is_err());
assert!(validate_reused_tab_policy(&tab, WebViewDataMode::ProfileDefault, true).is_err());
}
#[test]
fn runtime_tab_id_lookup_normalizes_stable_keys() {
assert_eq!(
normalize_runtime_tab_id("settings"),
Some("settings".to_string())
);
assert_eq!(
normalize_runtime_tab_id("SeTtings"),
Some("settings".to_string())
);
assert!(normalize_runtime_tab_id("settings/main").is_none());
}
#[test]
fn standalone_surface_tabs_remain_in_automation_inventory() {
let tab_id = generate_tab_id();
lock_state().tabs.insert(
tab_id.clone(),
BrowserTabState {
session_id: 7,
created_order: next_browser_created_order(),
create_token: 1,
create_in_flight: false,
pending_url: None,
initial_url: Some("https://auth.example.test/".to_string()),
current_url: Some("https://auth.example.test/".to_string()),
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::Ephemeral,
url_callback: Arc::new(AtomicBool::new(true)),
standalone: true,
aside: false,
owner_appid: Some("app.demo".to_string()),
owner_session_id: Some(3),
},
);
let info = browser_tabs()
.into_iter()
.find(|tab| tab.tab_id == tab_id)
.expect("standalone URL surface should be visible to devtools");
assert_eq!(
info.current_url.as_deref(),
Some("https://auth.example.test/")
);
lock_state().tabs.remove(&tab_id);
}
#[test]
fn activating_standalone_tab_does_not_replace_product_active_tab() {
let product_tab_id = generate_tab_id();
let standalone_tab_id = generate_tab_id();
let make_tab = |standalone| BrowserTabState {
session_id: 7,
created_order: next_browser_created_order(),
create_token: 1,
create_in_flight: false,
pending_url: None,
initial_url: None,
current_url: None,
title: None,
title_url: None,
favicon_png: None,
can_go_back: false,
can_go_forward: false,
discarded: false,
data_mode: WebViewDataMode::ProfileDefault,
url_callback: Arc::new(AtomicBool::new(false)),
standalone,
aside: false,
owner_appid: None,
owner_session_id: None,
};
lock_state()
.tabs
.insert(product_tab_id.clone(), make_tab(false));
lock_state()
.tabs
.insert(standalone_tab_id.clone(), make_tab(true));
assert!(set_active_browser_tab(&product_tab_id));
let activated = browser_activate_tab(&standalone_tab_id).unwrap();
assert_eq!(activated.tab_id, standalone_tab_id);
assert_eq!(
browser_current_tab().map(|tab| tab.tab_id),
Some(product_tab_id.clone())
);
assert_eq!(
browser_automation_current_tab().map(|tab| tab.tab_id),
Some(standalone_tab_id.clone())
);
lock_state().tabs.remove(&standalone_tab_id);
lock_state().tabs.remove(&product_tab_id);
lock_active_tab().take();
lock_automation_tab().take();
}
}