use crate::BUILTIN_BROWSER_APPID;
use crate::policy::{is_lingxia_startup_url, normalize_browser_target_url};
use crate::types::{BrowserAutomationError, BrowserTabInfo};
use crate::webview::{
browser_create_webview, browser_destroy_webview, browser_find_webview, browser_load_url,
};
use lingxia_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) 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>,
}
pub(crate) struct BrowserState {
pub(crate) tabs: HashMap<String, BrowserTabState>,
}
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_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);
}
}
pub(crate) fn browser_tab_document_is_internal(tab_id: &str) -> bool {
let document_url = {
let state = lock_state();
normalize_runtime_tab_id(tab_id)
.and_then(|normalized| state.tabs.get(&normalized))
.and_then(|tab| tab.pending_url.clone().or_else(|| tab.current_url.clone()))
};
document_url
.as_deref()
.and_then(crate::policy::extract_url_scheme)
.as_deref()
== Some(crate::policy::LINGXIA_SCHEME)
}
pub(crate) fn notify_navigation_finished(tab_id: &str, 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.title_url.as_deref() == Some(url))
.and_then(|tab| tab.title.clone())
.unwrap_or_default()
};
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(),
})
})
.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 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<BrowserTabInfo> = state
.tabs
.iter()
.map(|(tab_id, tab)| build_tab_info(tab_id, tab))
.collect();
tabs.sort_by(|a, b| a.tab_id.cmp(&b.tab_id));
tabs
}
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));
}
let mut product_tabs = state
.tabs
.iter()
.filter(|(_, tab)| !tab.standalone)
.map(|(tab_id, tab)| build_tab_info(tab_id, tab))
.collect::<Vec<_>>();
product_tabs.sort_by(|a, b| a.tab_id.cmp(&b.tab_id));
product_tabs.into_iter().next()
}
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)
}
pub(crate) fn browser_update_tab_info(
tab_id: &str,
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;
};
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 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_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 changed = {
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
};
if changed {
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> },
Missing,
Stale,
}
pub(crate) fn browser_tab_create_state(
tab_id: &str,
session_id: u64,
create_token: u64,
) -> TabCreateState {
let mut state = lock_state();
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(),
}
}
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) {
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,
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(),
) {
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 {
match browser_load_url(&path, session_id, &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;
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(),
)
{
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 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> {
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 removed = {
let mut state = lock_state();
state.tabs.remove(&normalized)
};
let removed_any = removed.is_some();
if let Some(tab) = removed {
let tab_path = browser_tab_path_for_runtime_id(&normalized);
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 = browser_find_webview(&tab_path, tab.session_id).ok();
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();
}
browser.remove_pages(std::slice::from_ref(&tab_path));
}
browser_destroy_webview(&tab_path, tab.session_id);
}
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(tab_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);
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 = browser_find_webview(&tab_path, tab.session_id).ok();
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();
}
browser.remove_pages(std::slice::from_ref(&tab_path));
}
browser_destroy_webview(&tab_path, tab.session_id);
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(),
))
} else {
None
}
};
if let Some((session_id, token, data_mode, url_callback)) = 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,
) {
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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_finish_drops_title_from_previous_page() {
let tab_id = "navfinishtitletest";
lock_state().tabs.insert(
tab_id.to_string(),
BrowserTabState {
session_id: 1,
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 captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
set_navigation_finished_handler(Arc::new(move |url, title| {
sink.lock()
.unwrap()
.push((url.to_string(), title.to_string()));
}));
notify_navigation_finished(tab_id, "https://b.test/");
notify_navigation_finished(tab_id, "https://a.test/");
assert_eq!(
*captured.lock().unwrap(),
vec![
("https://b.test/".to_string(), String::new()),
("https://a.test/".to_string(), "Page A".to_string()),
]
);
lock_state().tabs.remove(tab_id);
}
#[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,
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,
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();
}
}