pub(crate) mod config;
pub(crate) mod definition;
pub(crate) mod runtime;
pub use definition::{register_page_resolver, resolve_page_path};
use crate::bridge::PageBridge;
use crate::lifecycle::PageLifecycleEvent;
use crate::lxapp::{self, LxAppSessionStatus, navbar::NavigationBarState};
use crate::page::config::{OrientationOverride, PageConfig};
use crate::plugin;
use crate::startup::parse_query_string;
use crate::{LxApp, LxAppError, debug, error, info, warn};
use base64::Engine;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use lingxia_log::{LogBuilder, LogLevel as LxLogLevel, LogTag};
use lingxia_platform::traits::app_runtime::{
AnimationType, AppRuntime, OpenUrlRequest, OpenUrlTarget,
};
use lingxia_webview::runtime::destroy_webview_if_matches;
use lingxia_webview::{
IncomingWebMessage, LoadDataRequest, LogLevel, NativeWebViewId, NavigationOutcome,
NavigationPolicy, NewWindowPolicy, WebTag, WebView, WebViewBuilder, WebViewController,
WebViewDelegate,
};
use ring::rand::{SecureRandom, SystemRandom};
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::watch;
const PAGE_SVC_READY_TIMEOUT: Duration = Duration::from_secs(10);
static HOME_FIRST_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn notify_home_first_ready_once(appid: &str) {
if lingxia_app_context::home_app_id() != Some(appid) {
return;
}
if HOME_FIRST_READY.swap(true, std::sync::atomic::Ordering::Relaxed) {
return;
}
let remaining = lingxia_app_context::splash_visible_for()
.map(|visible| lingxia_app_context::splash_min_duration().saturating_sub(visible))
.unwrap_or_default();
if remaining.is_zero() {
signal_home_first_ready();
return;
}
std::mem::drop(crate::executor::spawn(async move {
tokio::time::sleep(remaining).await;
signal_home_first_ready();
}));
}
fn signal_home_first_ready() {
let Some(platform) = lxapp::runtime_registry::get_platform() else {
return;
};
use lingxia_platform::traits::ui::UIUpdate;
match lingxia_app_context::take_pending_campaign() {
Some((image_path, duration_ms)) => platform.show_splash_campaign(image_path, duration_ms),
None => platform.notify_home_first_ready(),
}
}
type WebviewReadyReceiver = Arc<Mutex<watch::Receiver<Option<Result<(), String>>>>>;
const DEFAULT_VIEW_CALL_TIMEOUT: Duration = Duration::from_secs(15);
pub(crate) struct PageInstanceInner {
id: PageInstanceId,
appid: String,
path: String,
webtag: WebTag,
isolated: bool,
webview: Arc<Mutex<Option<Arc<WebView>>>>,
last_active_time: Arc<Mutex<Instant>>,
state: Arc<Mutex<PageState>>,
bridge_nonce: Arc<Mutex<Option<String>>>,
bridge: PageBridge,
webview_ready_tx: watch::Sender<Option<Result<(), String>>>,
webview_ready_rx: WebviewReadyReceiver,
page_svc_ready_tx: watch::Sender<bool>,
page_svc_ready_rx: Arc<Mutex<watch::Receiver<bool>>>,
document_start_scripts: Vec<Arc<str>>,
page_scripts: Vec<Arc<str>>,
loaded_tx: watch::Sender<u64>,
navigation_progress: Arc<std::sync::Mutex<lingxia_webview::NavigationProgress>>,
reset_transition: Arc<std::sync::Mutex<()>>,
discarded: AtomicBool,
webview_create_in_flight: AtomicBool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EntryPhase {
Idle,
LoadOwed,
Loaded,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PageReset {
None,
Pending,
AwaitingEntry,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Visibility {
Hidden,
ShowOwed,
Shown,
}
#[derive(Clone, Debug)]
pub struct PageState {
render_status: PageRenderStatus,
event: Option<PageLifecycleEvent>,
entry: EntryPhase,
visibility: Visibility,
bridge_ready: bool,
requires_bridge_ready: bool,
ready_dispatched: bool,
reset: PageReset,
parked: bool,
renderer_recovery_pending: bool,
pub(crate) navbar_state: NavigationBarState,
config_load_error: Option<String>,
pub(crate) enable_pull_down_refresh: bool,
pub(crate) orientation_override: OrientationOverride,
pub(crate) query: serde_json::Value,
}
impl PageState {
fn document_is_departing(&self) -> bool {
self.parked
|| self.reset != PageReset::None
|| self.event == Some(PageLifecycleEvent::OnUnload)
}
fn accepts_view_state_patches(&self) -> bool {
self.bridge_ready && !self.document_is_departing()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct PageAutomationState {
pub webview_attached: bool,
pub webview_ready: bool,
pub webview_error: Option<String>,
pub bridge_ready: bool,
pub render_state: &'static str,
pub lifecycle: &'static str,
pub ready: bool,
pub query: Value,
}
#[derive(Copy, Clone, PartialEq, Debug)]
enum PageRenderStatus {
Unstarted,
Started,
Finished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationType {
Launch = 0,
Forward = 1,
Backward = 2,
Replace = 3,
SwitchTab = 4,
}
impl NavigationType {
pub fn to_animation(self) -> AnimationType {
match self {
NavigationType::Forward => AnimationType::Forward,
NavigationType::Backward => AnimationType::Backward,
_ => AnimationType::None,
}
}
}
#[derive(Clone)]
pub struct PageInstance {
inner: Arc<PageInstanceInner>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PageInstanceId(String);
impl PageInstanceId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parse(raw: impl Into<String>) -> Option<Self> {
let value = raw.into();
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
uuid::Uuid::parse_str(trimmed)
.ok()
.map(|id| Self(id.hyphenated().to_string()))
}
}
impl Default for PageInstanceId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PageInstanceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViewCallOptions {
timeout: Duration,
}
impl Default for ViewCallOptions {
fn default() -> Self {
Self {
timeout: DEFAULT_VIEW_CALL_TIMEOUT,
}
}
}
impl ViewCallOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn timeout(self) -> Duration {
self.timeout
}
}
fn serialize_view_call_params<P>(params: &P) -> Result<Option<Value>, LxAppError>
where
P: Serialize + ?Sized,
{
let value = serde_json::to_value(params)?;
if value.is_null() {
return Ok(None);
}
Ok(Some(value))
}
fn decode_view_call_result<R>(method: &str, value: Value) -> Result<R, LxAppError>
where
R: DeserializeOwned,
{
serde_json::from_value(value).map_err(|err| {
LxAppError::Bridge(format!(
"Failed to decode view response for '{}': {}",
method, err
))
})
}
impl PageInstance {
pub(crate) fn from_inner(inner: Arc<PageInstanceInner>) -> Self {
Self { inner }
}
fn generate_bridge_nonce() -> String {
let rng = SystemRandom::new();
let mut bytes = [0u8; 16];
if rng.fill(&mut bytes).is_err() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes();
bytes.copy_from_slice(&nanos[..16]);
}
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn build_page_state(lxapp: &lxapp::LxApp, path: &str) -> PageState {
let (page_config, config_load_error) = match PageConfig::from_json(lxapp, path) {
Ok(config) => (config, None),
Err(error) => {
error!("Page config load failed for {}: {}", path, error)
.with_appid(lxapp.appid.clone())
.with_path(path.to_string());
(PageConfig::default(), Some(error.to_string()))
}
};
PageState {
event: None,
render_status: PageRenderStatus::Unstarted,
entry: EntryPhase::Idle,
visibility: Visibility::Hidden,
bridge_ready: false,
requires_bridge_ready: lxapp.logic_enabled(),
ready_dispatched: false,
reset: PageReset::None,
parked: false,
renderer_recovery_pending: false,
navbar_state: page_config.create_navbar_state(),
config_load_error,
enable_pull_down_refresh: page_config.is_pull_down_refresh_enabled(),
orientation_override: page_config.get_orientation_override(),
query: serde_json::json!({}),
}
}
pub(crate) fn new<F, Fut>(appid: String, path: String, lxapp: &LxApp, setup_callback: F) -> Self
where
F: Fn(&PageInstance) -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<(), String>> + Send + 'static,
{
Self::new_with_isolation(appid, path, lxapp, false, setup_callback)
}
pub(crate) fn new_with_isolation<F, Fut>(
appid: String,
path: String,
lxapp: &LxApp,
isolated: bool,
setup_callback: F,
) -> Self
where
F: Fn(&PageInstance) -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<(), String>> + Send + 'static,
{
let page_state = Self::build_page_state(lxapp, &path);
let id = PageInstanceId::new();
let webtag = WebTag::new(
&appid,
&format!("{path}#{}", id.as_str()),
Some(lxapp.session.id),
);
let bridge_nonce = Self::generate_bridge_nonce();
let lxapp_arc = lxapp.clone_arc();
let (ready_tx, ready_rx) = watch::channel(None);
let (page_svc_ready_tx, page_svc_ready_rx) = watch::channel(false);
let (loaded_tx, _) = watch::channel(0u64);
let inner = Arc::new(PageInstanceInner {
navigation_progress: Arc::new(std::sync::Mutex::new(Default::default())),
reset_transition: Arc::new(std::sync::Mutex::new(())),
id,
appid: appid.clone(),
path: path.clone(),
webtag: webtag.clone(),
isolated,
last_active_time: Arc::new(Mutex::new(Instant::now())),
state: Arc::new(Mutex::new(page_state)),
webview: Arc::new(Mutex::new(None)),
bridge_nonce: Arc::new(Mutex::new(Some(bridge_nonce))),
bridge: PageBridge::new(lxapp_arc.clone(), lxapp_arc.executor.clone()),
webview_ready_tx: ready_tx,
webview_ready_rx: Arc::new(Mutex::new(ready_rx)),
page_svc_ready_tx,
page_svc_ready_rx: Arc::new(Mutex::new(page_svc_ready_rx)),
document_start_scripts: lxapp.document_start_scripts_snapshot(),
page_scripts: lxapp.page_scripts_snapshot(),
loaded_tx,
discarded: AtomicBool::new(false),
webview_create_in_flight: AtomicBool::new(true),
});
let page = Self { inner };
page.spawn_strict_webview(setup_callback);
page
}
pub(crate) fn new_headless(appid: String, path: String, lxapp: &LxApp) -> Self {
let page_state = Self::build_page_state(lxapp, &path);
let id = PageInstanceId::new();
let bridge_nonce = Self::generate_bridge_nonce();
let webtag = WebTag::new(&appid, &path, Some(lxapp.session.id));
let lxapp_arc = lxapp.clone_arc();
let (ready_tx, ready_rx) = watch::channel(None);
let (page_svc_ready_tx, page_svc_ready_rx) = watch::channel(false);
let (loaded_tx, _) = watch::channel(0u64);
let inner = Arc::new(PageInstanceInner {
navigation_progress: Arc::new(std::sync::Mutex::new(Default::default())),
reset_transition: Arc::new(std::sync::Mutex::new(())),
id,
appid,
path,
webtag,
isolated: false,
last_active_time: Arc::new(Mutex::new(Instant::now())),
state: Arc::new(Mutex::new(page_state)),
webview: Arc::new(Mutex::new(None)),
bridge_nonce: Arc::new(Mutex::new(Some(bridge_nonce))),
bridge: PageBridge::new(lxapp_arc.clone(), lxapp_arc.executor.clone()),
webview_ready_tx: ready_tx,
webview_ready_rx: Arc::new(Mutex::new(ready_rx)),
page_svc_ready_tx,
page_svc_ready_rx: Arc::new(Mutex::new(page_svc_ready_rx)),
document_start_scripts: lxapp.document_start_scripts_snapshot(),
page_scripts: lxapp.page_scripts_snapshot(),
loaded_tx,
discarded: AtomicBool::new(false),
webview_create_in_flight: AtomicBool::new(false),
});
Self { inner }
}
pub fn bridge_nonce(&self) -> Option<String> {
self.inner.bridge_nonce.lock().ok().and_then(|v| v.clone())
}
pub fn instance_id(&self) -> PageInstanceId {
self.inner.id.clone()
}
pub fn instance_id_string(&self) -> String {
self.inner.id.to_string()
}
pub(crate) fn is_isolated(&self) -> bool {
self.inner.isolated
}
pub(crate) fn is_discarded(&self) -> bool {
self.inner.discarded.load(Ordering::SeqCst)
}
pub(crate) fn has_live_webview(&self) -> bool {
self.webview().is_some() && !self.is_discarded()
}
pub(crate) fn discard_webview(&self) -> Result<(), LxAppError> {
if self.inner.isolated {
return Ok(());
}
if self.inner.webview_create_in_flight.load(Ordering::SeqCst) {
return Ok(());
}
let lxapp = self.owning_lxapp();
if lxapp.is_shown() || lxapp.get_page_stack().contains(&self.instance_id_string()) {
return Ok(());
}
let Some(webview) = self.webview() else {
self.inner.discarded.store(true, Ordering::SeqCst);
return Ok(());
};
self.cancel_bridge_work();
if let Ok(mut webview_guard) = self.inner.webview.lock() {
let _ = webview_guard.take();
}
destroy_webview_if_matches(&self.webtag(), &webview);
if let Ok(mut state) = self.inner.state.lock() {
Self::invalidate_document_state(&mut state);
}
let _ = self.inner.webview_ready_tx.send(None);
self.inner.discarded.store(true, Ordering::SeqCst);
Ok(())
}
pub(crate) fn ensure_live_webview(&self) {
if self.has_live_webview() {
return;
}
if !self.is_discarded() {
return;
}
if self
.inner
.webview_create_in_flight
.swap(true, Ordering::SeqCst)
{
return;
}
self.inner.discarded.store(false, Ordering::SeqCst);
let _ = self.inner.webview_ready_tx.send(None);
self.spawn_strict_webview(|page| {
let page = page.clone();
async move {
let result = page.load_html().map_err(|err| err.to_string());
if let Err(ref err) = result {
let attached = page.inner.webview.lock().ok().and_then(|mut v| v.take());
if let Some(webview) = attached {
destroy_webview_if_matches(&page.webtag(), &webview);
}
page.inner.discarded.store(true, Ordering::SeqCst);
crate::warn!("failed to reload discarded page WebView: {err}")
.with_appid(page.appid())
.with_path(page.path());
}
result
}
});
}
fn spawn_strict_webview<F, Fut>(&self, setup_callback: F)
where
F: Fn(&PageInstance) -> Fut + Send + 'static,
Fut: Future<Output = Result<(), String>> + Send + 'static,
{
let page_weak_for_lx = Arc::downgrade(&self.inner);
let lxapp = self.owning_lxapp();
let runtime_for_nav = lxapp.runtime.clone();
let appid_for_nav = self.appid();
let session_id_for_nav = lxapp.session_id();
let runtime_for_new_window = lxapp.runtime.clone();
let appid_for_new_window = self.appid();
let session_id_for_new_window = lxapp.session_id();
let isolated = self.inner.isolated;
let webtag = self.webtag();
let session = WebViewBuilder::strict(webtag)
.surface_owned(isolated)
.delegate(Arc::new(self.clone()))
.on_scheme("lx", move |req| {
let page_weak_for_lx = page_weak_for_lx.clone();
async move {
let Some(inner) = page_weak_for_lx.upgrade() else {
return None.into();
};
let page = PageInstance::from_inner(inner);
let lxapp = page.owning_lxapp();
if lxapp.status() == LxAppSessionStatus::Closed {
return None.into();
}
lxapp.handle_lingxia_request(&page, req).into()
}
})
.on_navigation(move |request| {
let url = request.url.as_str();
let scheme = url.split(':').next().unwrap_or("");
match scheme {
"lx" | "data" | "blob" => NavigationPolicy::Allow,
_ => {
if scheme != "about" {
let _ = runtime_for_nav.open_url(OpenUrlRequest {
owner_appid: appid_for_nav.clone(),
owner_session_id: session_id_for_nav,
url: url.to_string(),
target: OpenUrlTarget::External,
want_tab_id: false,
});
}
NavigationPolicy::Cancel
}
}
})
.on_new_window(move |url| {
let _ = runtime_for_new_window.open_url(OpenUrlRequest {
owner_appid: appid_for_new_window.clone(),
owner_session_id: session_id_for_new_window,
url: url.to_string(),
target: OpenUrlTarget::SelfTarget,
want_tab_id: false,
});
NewWindowPolicy::Cancel
})
.create();
let page_for_task = self.clone();
let appid_clone = self.appid();
let path_clone = self.path();
crate::executor::spawn(async move {
let result = match session.wait_ready().await {
Ok(webview_controller) => {
page_for_task.attach_webview(webview_controller.clone());
setup_callback(&page_for_task).await
}
Err(e) => {
let still_registered = page_for_task
.owning_lxapp()
.get_page_by_instance_id(&page_for_task.instance_id())
.is_some();
if page_for_task.document_is_departing() || !still_registered {
debug!("Cancelled WebView creation for departed page: {}", e)
.with_appid(appid_clone)
.with_path(path_clone);
} else {
error!("Failed to create WebView: {}", e)
.with_appid(appid_clone)
.with_path(path_clone);
}
Err(e.to_string())
}
};
page_for_task
.inner
.webview_create_in_flight
.store(false, Ordering::SeqCst);
if result.is_err() {
page_for_task.inner.discarded.store(true, Ordering::SeqCst);
}
page_for_task.mark_webview_ready(result);
});
}
pub fn webtag(&self) -> WebTag {
self.inner.webtag.clone()
}
pub(crate) fn bridge(&self) -> PageBridge {
self.inner.bridge.clone()
}
#[doc(hidden)]
pub fn bind_required_v3_document(
&self,
native_authority: &crate::NativeControlPlaneAuthority,
context: &lingxia_webview::WebMessageContext,
pending: &dyn crate::RequiredV3DocumentGate,
) -> Result<(), LxAppError> {
self.inner
.bridge
.bind_required_v3_document(native_authority, self, context, pending)
}
#[doc(hidden)]
pub fn bind_required_v3_authority(
&self,
native_authority: &crate::NativeControlPlaneAuthority,
context: &lingxia_webview::WebMessageContext,
authority: crate::ControlDocumentAuthority,
outbound_gate: std::sync::Arc<dyn lingxia_webview::DocumentOutboundGate>,
) -> Result<crate::DeferredRequiredV3Cancellation, LxAppError> {
self.inner.bridge.bind_required_v3_authority(
native_authority,
self,
context,
authority,
outbound_gate,
)
}
#[doc(hidden)]
pub fn promote_active_browser_document(
&self,
native_authority: &crate::NativeControlPlaneAuthority,
authority: crate::ControlDocumentAuthority,
) -> bool {
self.inner
.bridge
.promote_active_browser_document(native_authority, authority)
}
#[doc(hidden)]
pub fn revoke_required_v3_document(
&self,
native_authority: &crate::NativeControlPlaneAuthority,
authority: crate::ControlDocumentAuthority,
) -> bool {
self.inner
.bridge
.revoke_required_v3_document(native_authority, self, authority)
}
pub(crate) fn owning_lxapp(&self) -> Arc<LxApp> {
self.inner.bridge.lxapp()
}
pub(crate) fn cancel_bridge_work(&self) {
self.inner.bridge.cancel_page_work(self);
}
pub(crate) fn mark_reset_pending(&self) {
if let Ok(mut state) = self.inner.state.lock() {
state.reset = PageReset::Pending;
}
}
pub(crate) fn take_reset_pending(&self) -> bool {
let Ok(mut state) = self.inner.state.lock() else {
return false;
};
if state.reset == PageReset::Pending {
state.reset = PageReset::AwaitingEntry;
return true;
}
false
}
pub(crate) fn take_reset_awaiting_entry(&self) -> bool {
let Ok(mut state) = self.inner.state.lock() else {
return false;
};
if state.reset == PageReset::AwaitingEntry {
state.reset = PageReset::None;
return true;
}
false
}
pub(crate) fn reset_transition_guard(&self) -> std::sync::MutexGuard<'_, ()> {
self.inner
.reset_transition
.lock()
.unwrap_or_else(|e| e.into_inner())
}
pub(crate) fn accepts_view_state_patches(&self) -> bool {
self.inner
.state
.lock()
.is_ok_and(|state| state.accepts_view_state_patches())
}
pub(crate) fn document_is_departing(&self) -> bool {
self.inner
.state
.lock()
.is_ok_and(|state| state.document_is_departing())
}
pub(crate) fn park_view(&self) {
if let Ok(mut state) = self.inner.state.lock() {
state.parked = true;
}
const PARKED_DOCUMENT: &str = "<!DOCTYPE html><html><head><meta charset=\"utf-8\">\
<style>html,body{background:transparent}</style></head><body></body></html>";
if let Some(controller) = self.webview_controller()
&& let Err(err) =
controller.load_data(LoadDataRequest::new(PARKED_DOCUMENT, &self.base_url()))
{
warn!("Failed to park document for {}: {}", self.path(), err)
.with_appid(self.appid())
.with_path(self.path());
}
}
pub(crate) fn prepare_for_service_restart(&self) {
self.cancel_bridge_work();
if let Ok(mut state) = self.inner.state.lock() {
let parked = state.parked;
Self::reset_webview_lifecycle_state(&mut state);
state.parked = parked;
}
}
pub(crate) fn needs_renderer_recovery(&self) -> bool {
self.inner
.state
.lock()
.is_ok_and(|state| state.renderer_recovery_pending)
}
#[cfg(target_vendor = "apple")]
pub(crate) fn recover_renderer(&self) -> Result<(), LxAppError> {
let _transition = self.reset_transition_guard();
{
let mut state = self.inner.state.lock().unwrap();
if !std::mem::take(&mut state.renderer_recovery_pending) {
return Ok(());
}
if state.document_is_departing() {
return Ok(());
}
}
if self.webview().is_none() {
return Ok(());
}
if let Err(error) = self.load_html() {
self.inner.state.lock().unwrap().renderer_recovery_pending = true;
return Err(error);
}
Ok(())
}
fn invalidate_document_state(state: &mut PageState) {
state.bridge_ready = false;
state.ready_dispatched = false;
state.render_status = PageRenderStatus::Unstarted;
}
fn invalidate_renderer_state(state: &mut PageState) {
Self::invalidate_document_state(state);
state.renderer_recovery_pending = true;
}
pub(crate) fn apply_reloaded_page_json(&self, lxapp: &LxApp) {
let (page_config, config_load_error) = match PageConfig::from_json(lxapp, &self.path()) {
Ok(config) => (config, None),
Err(error) => {
error!("Page config load failed for {}: {}", self.path(), error)
.with_appid(lxapp.appid.clone())
.with_path(self.path().to_string());
(PageConfig::default(), Some(error.to_string()))
}
};
if let Ok(mut state) = self.inner.state.lock() {
state.navbar_state = page_config.create_navbar_state();
state.config_load_error = config_load_error;
state.enable_pull_down_refresh = page_config.is_pull_down_refresh_enabled();
state.orientation_override = page_config.get_orientation_override();
}
}
pub fn attach_webview(&self, webview: Arc<WebView>) {
let mut should_reset_lifecycle = false;
if let Ok(mut webview_guard) = self.inner.webview.lock() {
let current_webview_is_same = webview_guard
.as_ref()
.map(|current| Arc::ptr_eq(current, &webview));
should_reset_lifecycle =
Self::should_reset_lifecycle_on_attach(current_webview_is_same);
*webview_guard = Some(webview);
}
if should_reset_lifecycle && let Ok(mut state) = self.inner.state.lock() {
Self::reset_webview_lifecycle_state(&mut state);
}
}
pub fn handle_incoming_web_message(
&self,
message: IncomingWebMessage,
) -> Result<(), LxAppError> {
self.inner.bridge.handle_incoming(self, message)
}
#[doc(hidden)]
pub fn prepare_required_v3_incoming(
&self,
native_authority: &crate::NativeControlPlaneAuthority,
message: IncomingWebMessage,
authority: crate::ControlDocumentAuthority,
execution_gate: crate::RequiredV3ExecutionGate,
) -> Result<crate::PreparedRequiredV3Incoming, LxAppError> {
self.inner.bridge.prepare_required_v3_incoming(
native_authority,
self,
message,
authority,
execution_gate,
)
}
#[doc(hidden)]
pub fn execute_prepared_required_v3_incoming(
&self,
prepared: crate::PreparedRequiredV3Incoming,
) -> Result<(), LxAppError> {
self.inner
.bridge
.execute_prepared_required_v3_incoming(self, prepared)
}
pub fn get_page_state(&self) -> Option<PageState> {
self.inner.state.lock().ok().map(|state| state.clone())
}
fn request_on_load(state: &mut PageState) {
state.entry = EntryPhase::LoadOwed;
state.reset = PageReset::None;
if state.visibility == Visibility::Shown {
state.visibility = Visibility::ShowOwed;
}
}
fn handshake_should_request_on_load(state: &PageState, isolated: bool) -> bool {
state.entry == EntryPhase::Idle
&& state.reset == PageReset::None
&& (isolated || state.visibility != Visibility::Hidden)
}
fn switch_tab_should_request_on_load(entry: EntryPhase) -> bool {
entry == EntryPhase::Idle
}
pub(crate) fn switch_tab_owes_on_load(&self) -> bool {
self.inner
.state
.lock()
.map(|state| Self::switch_tab_should_request_on_load(state.entry))
.unwrap_or(true)
}
fn should_reset_lifecycle_on_attach(current_webview_is_same: Option<bool>) -> bool {
matches!(current_webview_is_same, Some(false))
}
fn reset_webview_lifecycle_state(state: &mut PageState) {
let is_currently_visible = state.event == Some(PageLifecycleEvent::OnShow);
state.event = None;
state.parked = false;
state.render_status = PageRenderStatus::Unstarted;
state.visibility = if is_currently_visible {
Visibility::ShowOwed
} else {
Visibility::Hidden
};
state.entry = EntryPhase::Idle;
state.bridge_ready = false;
state.ready_dispatched = false;
}
fn collect_ready_lifecycle_events(
state: &mut PageState,
events_to_fire: &mut Vec<(PageLifecycleEvent, Option<String>)>,
) {
if state.entry == EntryPhase::LoadOwed
&& (!state.requires_bridge_ready || state.bridge_ready)
&& !matches!(state.render_status, PageRenderStatus::Unstarted)
{
let query = serde_json::to_string(&state.query).ok();
events_to_fire.push((PageLifecycleEvent::OnLoad, query));
state.entry = EntryPhase::Loaded;
}
if state.entry == EntryPhase::Loaded && state.visibility == Visibility::ShowOwed {
events_to_fire.push((PageLifecycleEvent::OnShow, None));
state.visibility = Visibility::Shown;
state.event = Some(PageLifecycleEvent::OnShow);
}
if state.entry == EntryPhase::Loaded
&& (!state.requires_bridge_ready || state.bridge_ready)
&& state.render_status == PageRenderStatus::Finished
&& !state.ready_dispatched
&& state.visibility == Visibility::Shown
{
events_to_fire.push((PageLifecycleEvent::OnReady, None));
state.ready_dispatched = true;
}
}
fn collect_hidden_or_unloaded_lifecycle_event(
state: &mut PageState,
event: PageLifecycleEvent,
events_to_fire: &mut Vec<(PageLifecycleEvent, Option<String>)>,
) {
debug_assert!(matches!(
event,
PageLifecycleEvent::OnHide | PageLifecycleEvent::OnUnload
));
state.visibility = Visibility::Hidden;
if event == PageLifecycleEvent::OnUnload && state.entry == EntryPhase::LoadOwed {
state.entry = EntryPhase::Idle;
}
if state.event != Some(event) {
events_to_fire.push((event, None));
state.event = Some(event);
}
}
fn lifecycle_cancels_bridge_work(event: PageLifecycleEvent) -> bool {
event == PageLifecycleEvent::OnUnload
}
fn fire_lifecycle_events(&self, events_to_fire: Vec<(PageLifecycleEvent, Option<String>)>) {
if events_to_fire.is_empty() {
return;
}
let lxapp = self.owning_lxapp();
let appid = self.appid();
let path = self.path();
if events_to_fire
.iter()
.any(|(event, _)| *event == PageLifecycleEvent::OnReady)
{
notify_home_first_ready_once(&appid);
}
for (event, query) in events_to_fire {
match event {
PageLifecycleEvent::OnShow => {
crate::native_component::notify_page_visibility(self.webtag().key(), true);
}
PageLifecycleEvent::OnHide | PageLifecycleEvent::OnUnload => {
crate::native_component::notify_page_visibility(self.webtag().key(), false);
}
_ => {}
}
if let Err(e) = lxapp.executor.call_page_service_event(
lxapp.clone(),
path.clone(),
Some(self.instance_id_string()),
event,
query,
) {
error!("Failed to call {}: {}", String::from(event), e)
.with_appid(appid.clone())
.with_path(path.clone());
}
}
}
pub(crate) fn notify_bridge_ready(&self) {
let mut events_to_fire: Vec<(PageLifecycleEvent, Option<String>)> = Vec::new();
{
let mut state = self.inner.state.lock().unwrap();
if state.bridge_ready {
return;
}
state.bridge_ready = true;
if Self::handshake_should_request_on_load(&state, self.inner.isolated) {
Self::request_on_load(&mut state);
}
Self::collect_ready_lifecycle_events(&mut state, &mut events_to_fire);
}
self.fire_lifecycle_events(events_to_fire);
}
fn notify_render_started_inner(&self) {
let mut events_to_fire: Vec<(PageLifecycleEvent, Option<String>)> = Vec::new();
{
let mut state = self.inner.state.lock().unwrap();
state.render_status = PageRenderStatus::Started;
Self::collect_ready_lifecycle_events(&mut state, &mut events_to_fire);
}
self.fire_lifecycle_events(events_to_fire);
}
fn notify_render_finished_after_scripts(&self) {
let mut events_to_fire: Vec<(PageLifecycleEvent, Option<String>)> = Vec::new();
{
let mut state = self.inner.state.lock().unwrap();
state.render_status = PageRenderStatus::Finished;
Self::collect_ready_lifecycle_events(&mut state, &mut events_to_fire);
}
self.fire_lifecycle_events(events_to_fire);
}
pub(crate) fn dispatch_lifecycle_event(&self, event: PageLifecycleEvent) {
let _reset_transition =
(event == PageLifecycleEvent::OnUnload).then(|| self.reset_transition_guard());
if Self::lifecycle_cancels_bridge_work(event) {
self.cancel_bridge_work();
}
if event == PageLifecycleEvent::OnLoad {
self.owning_lxapp().flush_page_reset(self);
}
let mut events_to_fire: Vec<(PageLifecycleEvent, Option<String>)> = Vec::new();
{
let mut state = self.inner.state.lock().unwrap();
if event == PageLifecycleEvent::OnPullDownRefresh {
events_to_fire.push((event, None));
}
else if event == PageLifecycleEvent::OnHide || event == PageLifecycleEvent::OnUnload {
Self::collect_hidden_or_unloaded_lifecycle_event(
&mut state,
event,
&mut events_to_fire,
);
} else {
if event == PageLifecycleEvent::OnShow && state.visibility != Visibility::Shown {
state.visibility = Visibility::ShowOwed;
}
if event == PageLifecycleEvent::OnLoad {
Self::request_on_load(&mut state);
}
Self::collect_ready_lifecycle_events(&mut state, &mut events_to_fire);
}
}
self.fire_lifecycle_events(events_to_fire);
}
pub fn get_navbar_state(&self) -> Option<NavigationBarState> {
self.inner
.state
.lock()
.ok()
.map(|state| state.navbar_state.clone())
}
pub fn get_navbar_state_mut<F, R>(&self, f: F) -> Option<R>
where
F: FnOnce(&mut NavigationBarState) -> R,
{
self.inner
.state
.lock()
.ok()
.map(|mut state| f(&mut state.navbar_state))
}
pub fn get_orientation_override(&self) -> Option<OrientationOverride> {
self.inner
.state
.lock()
.ok()
.map(|state| state.orientation_override)
}
pub fn webview(&self) -> Option<Arc<WebView>> {
if let Ok(webview_guard) = self.inner.webview.lock() {
webview_guard.clone()
} else {
None
}
}
pub fn automation_state(&self) -> PageAutomationState {
let webview_attached = self.webview().is_some();
let webview_result = self
.inner
.webview_ready_rx
.lock()
.ok()
.and_then(|rx| rx.borrow().clone());
let (webview_ready, webview_error) = match webview_result {
Some(Ok(())) => (true, None),
Some(Err(error)) => (false, Some(error)),
None => (false, None),
};
let state = self.inner.state.lock().ok();
let bridge_ready = state.as_ref().is_some_and(|state| state.bridge_ready);
let render_state = match state.as_ref().map(|state| state.render_status) {
Some(PageRenderStatus::Started) => "loading",
Some(PageRenderStatus::Finished) => "finished",
_ => "unstarted",
};
let lifecycle = state
.as_ref()
.and_then(|state| state.event.map(|event| event.as_str()))
.unwrap_or("unknown");
let ready = state.as_ref().is_some_and(|state| state.ready_dispatched);
let query = state
.as_ref()
.map(|state| state.query.clone())
.unwrap_or_else(|| serde_json::json!({}));
PageAutomationState {
webview_attached,
webview_ready,
webview_error,
bridge_ready,
render_state,
lifecycle,
ready,
query,
}
}
pub(crate) fn mark_webview_ready(&self, result: Result<(), String>) {
let ok = result.is_ok();
let _ = self.inner.webview_ready_tx.send(Some(result));
if ok {
crate::lxapp::page_discard::enforce_page_webview_budget();
}
}
pub(crate) fn mark_page_svc_ready(&self) {
let _ = self.inner.page_svc_ready_tx.send(true);
}
pub(crate) async fn wait_page_svc_ready(&self) -> Result<(), String> {
let rx = self
.inner
.page_svc_ready_rx
.lock()
.map(|r| r.clone())
.map_err(|_| "page svc ready receiver poisoned".to_string())?;
if *rx.borrow() {
return Ok(());
}
let mut rx = rx;
let marked = async {
while rx.changed().await.is_ok() {
if *rx.borrow() {
return Ok(());
}
}
Err("page svc ready channel closed before the opener created it".to_string())
};
tokio::time::timeout(PAGE_SVC_READY_TIMEOUT, marked)
.await
.unwrap_or_else(|_| {
Err(format!(
"no opener created this isolated page's Logic service within {}s; \
an isolated instance must be opened through lx.surface",
PAGE_SVC_READY_TIMEOUT.as_secs()
))
})
}
pub fn notify_page_started(&self) {
if !self.inner.document_start_scripts.is_empty()
&& let Some(webview) = self.webview()
{
for js in &self.inner.document_start_scripts {
if let Err(error) = webview.exec_js(js) {
crate::error!("document-start script injection failed: {}", error)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
}
}
self.owning_lxapp().republish_page_scheme(self);
}
self.notify_render_started_inner();
}
pub async fn wait_webview_ready(&self) -> Result<(), String> {
if self.is_discarded() {
self.ensure_live_webview();
}
let rx = {
self.inner
.webview_ready_rx
.lock()
.map(|r| r.clone())
.map_err(|_| "webview ready receiver poisoned".to_string())?
};
if let Some(res) = rx.borrow().clone() {
return res;
}
let mut rx = rx;
while rx.changed().await.is_ok() {
if let Some(res) = rx.borrow().clone() {
return res;
}
}
Err("webview ready channel closed before result".to_string())
}
async fn handle_loaded_async(&self) {
if !self.inner.page_scripts.is_empty()
&& let Some(webview) = self.webview()
{
for js in &self.inner.page_scripts {
if let Err(e) = webview.exec_js(js) {
crate::error!("page script injection failed: {}", e)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
}
}
}
if self.inner.isolated {
let lxapp = self.owning_lxapp();
let revision = lxapp.next_page_chrome_revision();
let appearance = lxapp.appearance_state().resolved;
if let Err(err) = lxapp
.publish_realized_page_chrome(self, revision, appearance)
.await
{
crate::warn!("Failed to publish isolated page chrome: {}", err)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
}
}
self.notify_render_finished_after_scripts();
self.inner.loaded_tx.send_modify(|v| *v = v.wrapping_add(1));
}
pub fn handle_loaded(&self) {
let page = self.clone();
std::mem::drop(crate::executor::spawn(async move {
page.handle_loaded_async().await;
}));
}
pub fn subscribe_loaded(&self) -> watch::Receiver<u64> {
self.inner.loaded_tx.subscribe()
}
pub fn detach_webview(&self) {
self.cancel_bridge_work();
if let Ok(mut webview_guard) = self.inner.webview.lock() {
let _ = webview_guard.take();
}
if let Ok(mut state) = self.inner.state.lock() {
Self::reset_webview_lifecycle_state(&mut state);
}
}
pub(crate) fn webview_controller(&self) -> Option<Arc<dyn WebViewController>> {
if let Some(webview) = self.webview() {
Some(webview as Arc<dyn WebViewController>)
} else {
None
}
}
pub(crate) fn load_html(&self) -> Result<(), LxAppError> {
if let Ok(mut state) = self.inner.state.lock() {
state.parked = false;
#[cfg(target_vendor = "apple")]
{
state.renderer_recovery_pending = false;
}
}
let lxapp = self.owning_lxapp();
let path = self.path();
let config_load_error = self
.inner
.state
.lock()
.ok()
.and_then(|state| state.config_load_error.clone());
let html_string = if let Some(message) = config_load_error {
lingxia_webview::render_load_error_page(lingxia_webview::LoadErrorPage {
title: "Page configuration error",
message: &message,
retry_label: "Retry",
retry_url: &self.base_url(),
})
} else {
String::from_utf8_lossy(
&lxapp.generate_page_html(&path, self.bridge_nonce().as_deref()),
)
.into_owned()
};
let base_url = self.base_url();
if let Some(controller) = self.webview_controller() {
controller
.load_data(LoadDataRequest::new(&html_string, &base_url))
.map_err(LxAppError::from)
} else {
Err(LxAppError::WebView("WebView not ready".to_string()))
}
}
pub fn path(&self) -> String {
self.inner.path.clone()
}
pub fn appid(&self) -> String {
self.inner.appid.clone()
}
pub fn base_url(&self) -> String {
if let Some((plugin_name, page_path)) = plugin::parse_plugin_page_path(&self.path()) {
if page_path.is_empty() {
return format!("lx://plugin/{}", plugin_name);
}
return format!("lx://plugin/{}/{}", plugin_name, page_path);
}
format!("lx://lxapp/{}/{}", self.appid(), self.path())
}
pub(crate) fn mark_active(&self) {
if let Ok(mut time) = self.inner.last_active_time.lock() {
*time = Instant::now();
}
}
pub(crate) fn get_last_active_time(&self) -> Option<Instant> {
self.inner.last_active_time.lock().ok().map(|time| *time)
}
pub fn is_pull_down_refresh_enabled(&self) -> bool {
self.inner
.state
.lock()
.ok()
.map(|state| state.enable_pull_down_refresh)
.unwrap_or(false)
}
pub fn is_tabbar_page(&self) -> bool {
let lxapp = self.owning_lxapp();
match lxapp.get_tabbar() {
Some(tab_bar) => tab_bar.is_tabbar_page(&self.inner.path),
None => false,
}
}
pub fn navigate_to(
&self,
target_page: PageInstance,
nav_type: NavigationType,
) -> Result<PageInstance, LxAppError> {
let target_url =
crate::append_page_query(target_page.path(), &target_page.automation_state().query)
.map_err(LxAppError::InvalidParameter)?;
self.navigate_to_url(&target_url, nav_type)
}
pub fn navigate_to_url(
&self,
target_url: &str,
nav_type: NavigationType,
) -> Result<PageInstance, LxAppError> {
let lxapp = self.owning_lxapp();
lxapp.validate_navigation_entry(target_url, nav_type)?;
let target_page = if nav_type == NavigationType::Replace
&& lxapp.resolve_entry_path(target_url) == self.path()
{
lxapp.get_or_create_page(target_url)
} else {
lxapp.create_page_for_entry(target_url)
};
self.navigate_to_internal(target_page, nav_type, &lxapp)
}
fn navigate_to_internal(
&self,
target_page: PageInstance,
nav_type: NavigationType,
lxapp: &Arc<LxApp>,
) -> Result<PageInstance, LxAppError> {
let path = target_page.path();
let target_url =
crate::append_page_query(path.clone(), &target_page.automation_state().query)
.map_err(LxAppError::InvalidParameter)?;
let mut target_page = target_page;
lxapp.validate_navigation_entry(&target_url, nav_type)?;
if target_page.is_discarded() {
target_page.ensure_live_webview();
}
let is_tabbar_page = lxapp
.get_tabbar()
.is_some_and(|tabbar| tabbar.is_tabbar_page(&path));
let is_tab_switch = nav_type == NavigationType::SwitchTab
|| (nav_type == NavigationType::Launch && is_tabbar_page);
let is_initial_route = path == lxapp.config().get_initial_route();
match nav_type {
NavigationType::Launch | NavigationType::SwitchTab => {
if nav_type == NavigationType::Launch {
let stack_pages = lxapp.get_page_stack_pages();
for page in &stack_pages {
page.dispatch_lifecycle_event(PageLifecycleEvent::OnUnload);
let webview = page.webview();
page.detach_webview();
if let Some(webview) = webview {
destroy_webview_if_matches(&page.webtag(), &webview);
}
}
let stack_ids: Vec<String> = stack_pages
.iter()
.map(|page| page.instance_id_string())
.collect();
lxapp.remove_pages(&stack_ids);
target_page = lxapp.get_or_create_page(&target_url);
}
if nav_type == NavigationType::SwitchTab {
for page in lxapp.get_page_stack_pages() {
if page.is_tabbar_page() {
continue;
}
page.dispatch_lifecycle_event(PageLifecycleEvent::OnUnload);
lxapp.schedule_page_reset(&page);
}
}
lxapp.clear_page_stack()?;
}
NavigationType::Replace => {
lxapp.pop_from_page_stack();
}
NavigationType::Forward => {
if lxapp
.get_page_stack()
.iter()
.any(|id| id == &target_page.instance_id_string())
{
return Err(LxAppError::InvalidParameter(format!(
"navigateTo target '{path}' is already on the page stack; \
use lx.switchTab or lx.navigateBack to return to it."
)));
}
}
NavigationType::Backward => {
return Err(LxAppError::UnsupportedOperation(
"should use navigate_back".to_string(),
));
}
}
lxapp.with_tabbar_mut(|t| t.set_visible(is_tab_switch));
if is_tab_switch
&& let Some(Some(index)) = lxapp.with_tabbar_mut(|t| t.find_index_by_path(&path))
{
lxapp.with_tabbar_mut(|t| {
t.set_selected_index(index);
});
} else if !is_tabbar_page {
lxapp.with_tabbar_mut(|t| {
t.clear_selected_index();
});
}
lxapp.push_to_page_stack(&target_page)?;
let stack_size = lxapp.get_page_stack_size();
let show_back_button = stack_size > 1;
let show_home_button = stack_size <= 1 && !is_tabbar_page && !is_initial_route;
target_page.get_navbar_state_mut(|navbar| {
let allow_buttons = navbar.show_navbar;
navbar.set_back_button_visibility(show_back_button && allow_buttons);
navbar.set_home_button_visibility(show_home_button && allow_buttons);
});
lxapp.sync_host_ui();
match nav_type {
NavigationType::Replace => {
if !Self::replace_reuses_document(&self.inner.id, &target_page.inner.id) {
self.dispatch_lifecycle_event(PageLifecycleEvent::OnUnload);
lxapp.schedule_page_reset(self);
}
}
NavigationType::Launch => {}
_ => {
let unloaded_by_tab_switch = nav_type == NavigationType::SwitchTab
&& !lxapp
.get_tabbar()
.is_some_and(|tabbar| tabbar.is_tabbar_page(&self.path()));
if !unloaded_by_tab_switch {
self.dispatch_lifecycle_event(PageLifecycleEvent::OnHide);
}
}
}
if nav_type != NavigationType::SwitchTab || target_page.switch_tab_owes_on_load() {
target_page.dispatch_lifecycle_event(PageLifecycleEvent::OnLoad);
}
(*lxapp.runtime)
.navigate(
self.appid(),
path,
target_page.webtag().key().to_string(),
nav_type.to_animation(),
)
.map_err(LxAppError::from)?;
lxapp.sync_host_ui();
Ok(target_page)
}
fn replace_reuses_document(source: &PageInstanceId, target: &PageInstanceId) -> bool {
source == target
}
pub fn navigate_back(&self, delta: u32) -> Result<(), LxAppError> {
let lxapp = self.owning_lxapp();
let stack_size = lxapp.get_page_stack_size();
if stack_size <= 1 {
return Ok(());
}
let mut pages_to_pop = delta;
if pages_to_pop as usize >= stack_size {
pages_to_pop = (stack_size - 1) as u32;
}
if pages_to_pop == 0 {
return Ok(());
}
for _ in 0..pages_to_pop {
if let Some(page) = lxapp.pop_from_page_stack() {
page.dispatch_lifecycle_event(PageLifecycleEvent::OnUnload);
lxapp.schedule_page_reset(&page);
}
}
if let Ok(dest) = lxapp.current_page() {
if dest.is_discarded() {
dest.ensure_live_webview();
}
let path = dest.path();
let is_tabbar_page = lxapp
.with_tabbar_mut(|tabbar| restore_tabbar_after_back(tabbar, &path))
.unwrap_or(false);
let new_stack_size = lxapp.get_page_stack_size();
{
let dest_page = dest.clone();
let is_initial_route = path == lxapp.config().get_initial_route();
let show_home_button = new_stack_size <= 1 && !is_tabbar_page && !is_initial_route;
dest_page.get_navbar_state_mut(|navbar| {
let allow_buttons = navbar.show_navbar;
navbar.set_back_button_visibility(new_stack_size > 1 && allow_buttons);
navbar.set_home_button_visibility(show_home_button && allow_buttons);
});
}
(*lxapp.runtime).navigate(
self.appid(),
path.clone(),
dest.webtag().key().to_string(),
NavigationType::Backward.to_animation(),
)?;
dest.dispatch_lifecycle_event(PageLifecycleEvent::OnShow);
dest.mark_active();
lxapp.sync_host_ui();
Ok(())
} else {
Err(LxAppError::UnsupportedOperation(
"PageInstance stack is empty after pop".to_string(),
))
}
}
pub(crate) fn set_query(&self, query_str: String) {
if let Ok(query_value) = parse_query_string(&query_str) {
self.inner.state.lock().unwrap().query = query_value;
}
}
pub fn call_js(&self, name: String, arg: String) -> Result<(), LxAppError> {
let lxapp = self.owning_lxapp();
lxapp.executor.call_page_service(
lxapp.clone(),
self.path(),
Some(self.instance_id_string()),
name,
Some(arg),
)
}
pub async fn call_view<R>(&self, method: &str) -> Result<R, LxAppError>
where
R: DeserializeOwned,
{
self.call_view_in(method, ViewCallOptions::default()).await
}
pub async fn call_view_in<R>(
&self,
method: &str,
options: ViewCallOptions,
) -> Result<R, LxAppError>
where
R: DeserializeOwned,
{
let value = self.call_view_json_in(method, options).await?;
decode_view_call_result(method, value)
}
pub async fn call_view_with<P, R>(&self, method: &str, params: &P) -> Result<R, LxAppError>
where
P: Serialize + ?Sized,
R: DeserializeOwned,
{
self.call_view_with_in(method, params, ViewCallOptions::default())
.await
}
pub async fn call_view_with_in<P, R>(
&self,
method: &str,
params: &P,
options: ViewCallOptions,
) -> Result<R, LxAppError>
where
P: Serialize + ?Sized,
R: DeserializeOwned,
{
let value = self.call_view_json_with_in(method, params, options).await?;
decode_view_call_result(method, value)
}
pub async fn call_view_json(&self, method: &str) -> Result<Value, LxAppError> {
self.call_view_json_in(method, ViewCallOptions::default())
.await
}
pub async fn call_view_json_in(
&self,
method: &str,
options: ViewCallOptions,
) -> Result<Value, LxAppError> {
self.call_view_json_value(method, None, options).await
}
pub async fn call_view_json_with<P>(
&self,
method: &str,
params: &P,
) -> Result<Value, LxAppError>
where
P: Serialize + ?Sized,
{
self.call_view_json_with_in(method, params, ViewCallOptions::default())
.await
}
pub async fn call_view_json_with_in<P>(
&self,
method: &str,
params: &P,
options: ViewCallOptions,
) -> Result<Value, LxAppError>
where
P: Serialize + ?Sized,
{
self.call_view_json_value(method, serialize_view_call_params(params)?, options)
.await
}
async fn call_view_json_value(
&self,
method: &str,
params: Option<Value>,
options: ViewCallOptions,
) -> Result<Value, LxAppError> {
let pending = crate::view_call::call_view(self, method, params)?;
crate::view_call::await_pending_view_call(pending, options.timeout()).await
}
}
enum ClassifiedNavigation {
Started,
Loaded,
Failed { description: String, kind: String },
Superseded,
}
impl WebViewDelegate for PageInstance {
fn on_web_content_process_terminated(&self, native_view: NativeWebViewId) {
if self
.webview()
.is_none_or(|view| view.native_view_id() != native_view)
{
return;
}
{
let _transition = self.reset_transition_guard();
Self::invalidate_renderer_state(&mut self.inner.state.lock().unwrap());
self.cancel_bridge_work();
}
warn!("Page renderer terminated; scheduling recovery")
.with_appid(self.appid())
.with_path(self.path());
self.owning_lxapp().recover_terminated_renderers();
}
fn on_navigation_event(&self, event: lingxia_webview::NavigationEvent) {
if self.inner.state.lock().is_ok_and(|state| state.parked) {
return;
}
let outcome = {
let mut progress = self
.inner
.navigation_progress
.lock()
.unwrap_or_else(|e| e.into_inner());
match progress.classify(&event) {
NavigationOutcome::Started { .. } => ClassifiedNavigation::Started,
NavigationOutcome::Loaded { .. } => ClassifiedNavigation::Loaded,
NavigationOutcome::Failed { error } => ClassifiedNavigation::Failed {
description: error.description.clone(),
kind: format!("{:?}", error.kind),
},
NavigationOutcome::Superseded => ClassifiedNavigation::Superseded,
}
};
match outcome {
ClassifiedNavigation::Started => self.notify_page_started(),
ClassifiedNavigation::Loaded => self.handle_loaded(),
ClassifiedNavigation::Failed { description, kind } => {
error!("page load failed: {} ({})", description, kind)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
}
ClassifiedNavigation::Superseded => {}
}
}
fn handle_post_message(&self, message: IncomingWebMessage) {
if let Err(e) = self.inner.bridge.admit_incoming(self, message.context()) {
error!("Rejected postMessage: {}", e)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
return;
}
let msg = message.body();
if let Some((level, message)) = decode_console_envelope(msg) {
self.log(level, &message);
return;
}
if let Some(component_payload) = decode_native_component_envelope(msg) {
self.handle_native_component_message(component_payload);
return;
}
if let Err(e) = self.handle_incoming_web_message(message) {
if self.document_is_departing() || self.webview().is_none() {
debug!("Dropping view message after page unload")
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
} else {
error!("Invalid postMessage: {}", e)
.with_appid(self.inner.appid.clone())
.with_path(self.inner.path.clone());
}
}
}
fn handle_native_component_message(&self, message_json: String) {
crate::native_component::dispatch_component_message(self, &message_json);
}
fn log(&self, level: LogLevel, message: &str) {
let log_level = match level {
LogLevel::Error => LxLogLevel::Error,
LogLevel::Warn => LxLogLevel::Warn,
LogLevel::Info => LxLogLevel::Info,
LogLevel::Debug => LxLogLevel::Debug,
LogLevel::Verbose => LxLogLevel::Debug, };
LogBuilder::new(LogTag::WebViewConsole, message)
.with_level(log_level)
.with_path(&self.inner.path)
.with_appid(self.inner.appid.clone());
}
}
fn decode_console_envelope(msg: &str) -> Option<(LogLevel, String)> {
let json = serde_json::from_str::<Value>(msg).ok()?;
json.get("__lingxia_console__")
.and_then(Value::as_bool)
.filter(|enabled| *enabled)?;
let level = match json.get("level").and_then(Value::as_str) {
Some("error") => LogLevel::Error,
Some("warn") => LogLevel::Warn,
Some("debug") => LogLevel::Debug,
Some("info") => LogLevel::Info,
Some("verbose") => LogLevel::Verbose,
_ => LogLevel::Info,
};
let message = json.get("message").and_then(Value::as_str)?.to_string();
Some((level, message))
}
fn decode_native_component_envelope(msg: &str) -> Option<String> {
let json = serde_json::from_str::<Value>(msg).ok()?;
json.get("__lingxia_native_component__")
.and_then(Value::as_bool)
.filter(|enabled| *enabled)?;
json.get("payload")
.and_then(Value::as_str)
.map(str::to_string)
}
impl Drop for PageInstanceInner {
fn drop(&mut self) {
crate::native_component::notify_page_destroyed(self.webtag.key());
if let Ok(mut webview) = self.webview.lock()
&& let Some(_webview_controller) = webview.take()
{
info!("WebView destroyed for page")
.with_appid(self.appid.clone())
.with_path(self.path.clone());
}
}
}
fn restore_tabbar_after_back(tabbar: &mut crate::lxapp::tabbar::TabBar, path: &str) -> bool {
let index = tabbar.find_index_by_path(path);
tabbar.set_visible(index.is_some());
if let Some(index) = index {
tabbar.set_selected_index(index);
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, serde::Deserialize)]
struct ViewReply {
ok: bool,
}
fn test_page_state() -> PageState {
PageState {
event: None,
render_status: PageRenderStatus::Unstarted,
entry: EntryPhase::Idle,
visibility: Visibility::Hidden,
bridge_ready: false,
requires_bridge_ready: true,
ready_dispatched: false,
reset: PageReset::None,
parked: false,
renderer_recovery_pending: false,
navbar_state: NavigationBarState::default(),
config_load_error: None,
enable_pull_down_refresh: false,
orientation_override: OrientationOverride::default(),
query: serde_json::json!({}),
}
}
#[test]
fn document_discard_preserves_logic_entry_query_and_visibility() {
let mut state = test_page_state();
state.entry = EntryPhase::Loaded;
state.visibility = Visibility::Hidden;
state.event = Some(PageLifecycleEvent::OnHide);
state.query = serde_json::json!({"step": "login"});
state.bridge_ready = true;
state.ready_dispatched = true;
state.render_status = PageRenderStatus::Finished;
PageInstance::invalidate_document_state(&mut state);
assert!(!state.renderer_recovery_pending);
assert!(!state.accepts_view_state_patches());
assert_eq!(state.entry, EntryPhase::Loaded);
assert_eq!(state.visibility, Visibility::Hidden);
assert_eq!(state.query, serde_json::json!({"step": "login"}));
assert!(!PageInstance::handshake_should_request_on_load(
&state, false
));
}
#[test]
fn renderer_recovery_preserves_logic_entry_query_and_visibility() {
let mut state = test_page_state();
state.entry = EntryPhase::Loaded;
state.visibility = Visibility::Shown;
state.event = Some(PageLifecycleEvent::OnShow);
state.query = serde_json::json!({"step": "login"});
state.bridge_ready = true;
state.ready_dispatched = true;
state.render_status = PageRenderStatus::Finished;
PageInstance::invalidate_renderer_state(&mut state);
assert!(state.renderer_recovery_pending);
assert!(!state.accepts_view_state_patches());
assert_eq!(state.entry, EntryPhase::Loaded);
assert_eq!(state.visibility, Visibility::Shown);
assert_eq!(state.query, serde_json::json!({"step": "login"}));
assert!(!PageInstance::handshake_should_request_on_load(
&state, false
));
let mut events = Vec::new();
state.requires_bridge_ready = true;
state.render_status = PageRenderStatus::Finished;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert!(events.is_empty());
state.bridge_ready = true;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(events, vec![(PageLifecycleEvent::OnReady, None)]);
}
#[test]
fn renderer_recovery_does_not_reenter_hidden_or_parked_pages() {
for parked in [false, true] {
let mut state = test_page_state();
state.parked = parked;
if parked {
state.reset = PageReset::AwaitingEntry;
}
PageInstance::invalidate_renderer_state(&mut state);
assert_eq!(state.parked, parked);
assert!(!PageInstance::handshake_should_request_on_load(
&state, false
));
let mut events = Vec::new();
state.bridge_ready = true;
state.render_status = PageRenderStatus::Finished;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert!(events.is_empty());
}
}
#[test]
fn view_state_patches_drop_once_the_document_is_departing() {
let mut state = test_page_state();
assert!(!state.accepts_view_state_patches());
state.bridge_ready = true;
assert!(state.accepts_view_state_patches());
state.parked = true;
assert!(!state.accepts_view_state_patches());
state.parked = false;
state.reset = PageReset::Pending;
assert!(!state.accepts_view_state_patches());
state.reset = PageReset::AwaitingEntry;
assert!(!state.accepts_view_state_patches());
state.reset = PageReset::None;
state.event = Some(PageLifecycleEvent::OnUnload);
assert!(!state.accepts_view_state_patches());
}
#[test]
fn rebuilding_clears_the_departed_document_event() {
let mut state = test_page_state();
state.event = Some(PageLifecycleEvent::OnUnload);
state.reset = PageReset::AwaitingEntry;
state.parked = true;
PageInstance::reset_webview_lifecycle_state(&mut state);
state.reset = PageReset::None;
assert_eq!(state.event, None);
assert!(!state.document_is_departing());
}
#[test]
fn same_instance_replace_keeps_the_document_live() {
let source = PageInstanceId::new();
let replacement = PageInstanceId::new();
assert!(PageInstance::replace_reuses_document(&source, &source));
assert!(!PageInstance::replace_reuses_document(
&source,
&replacement
));
let mut state = test_page_state();
state.bridge_ready = true;
state.render_status = PageRenderStatus::Finished;
state.entry = EntryPhase::Loaded;
state.visibility = Visibility::Shown;
state.event = Some(PageLifecycleEvent::OnShow);
PageInstance::request_on_load(&mut state);
let mut events = Vec::new();
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(events[0].0, PageLifecycleEvent::OnLoad);
assert!(state.accepts_view_state_patches());
assert!(!state.document_is_departing());
}
#[test]
fn serialize_view_call_params_skips_null() {
assert_eq!(serialize_view_call_params(&()).unwrap(), None);
assert_eq!(
serialize_view_call_params(&serde_json::json!({ "topic": "status" })).unwrap(),
Some(serde_json::json!({ "topic": "status" }))
);
}
#[test]
fn decode_view_call_result_deserializes_typed_payload() {
let reply: ViewReply =
decode_view_call_result("example.echo", serde_json::json!({ "ok": true })).unwrap();
assert!(reply.ok);
}
#[test]
fn decode_view_call_result_reports_method_name() {
let err = decode_view_call_result::<ViewReply>("example.echo", serde_json::json!({}))
.unwrap_err();
match err {
LxAppError::Bridge(message) => {
assert!(message.contains("example.echo"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn view_call_options_default_timeout_is_positive() {
assert!(ViewCallOptions::default().timeout() > Duration::ZERO);
}
#[test]
fn back_to_tab_page_restores_the_selected_item() {
let mut tabbar: crate::lxapp::tabbar::TabBar = serde_json::from_value(serde_json::json!({
"items": [
{ "page": "home" },
{ "page": "api" }
]
}))
.unwrap();
tabbar
.validate(&[("home", "pages/home/index"), ("api", "pages/api/index")])
.unwrap();
tabbar.clear_selected_index();
tabbar.set_visible(false);
assert!(restore_tabbar_after_back(&mut tabbar, "pages/api/index"));
assert!(tabbar.is_effectively_visible());
assert_eq!(tabbar.get_selected_index(), 1);
}
#[test]
fn lifecycle_cascade_waits_for_bridge_ready_before_on_load() {
let mut state = test_page_state();
let mut events = Vec::new();
PageInstance::request_on_load(&mut state);
state.render_status = PageRenderStatus::Started;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert!(events.is_empty());
assert_eq!(state.entry, EntryPhase::LoadOwed);
assert_ne!(state.entry, EntryPhase::Loaded);
state.bridge_ready = true;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![(PageLifecycleEvent::OnLoad, Some("{}".to_string()))]
);
assert_ne!(state.entry, EntryPhase::LoadOwed);
assert_eq!(state.entry, EntryPhase::Loaded);
}
#[test]
fn lifecycle_cascade_allows_on_load_without_bridge_for_native_only_pages() {
let mut state = test_page_state();
let mut events = Vec::new();
state.requires_bridge_ready = false;
PageInstance::request_on_load(&mut state);
state.render_status = PageRenderStatus::Started;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![(PageLifecycleEvent::OnLoad, Some("{}".to_string()))]
);
assert_ne!(state.entry, EntryPhase::LoadOwed);
assert_eq!(state.entry, EntryPhase::Loaded);
}
#[test]
fn lifecycle_cascade_orders_load_show_ready() {
let mut state = test_page_state();
let mut events = Vec::new();
state.entry = EntryPhase::LoadOwed;
state.bridge_ready = true;
state.visibility = Visibility::ShowOwed;
state.render_status = PageRenderStatus::Finished;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![
(PageLifecycleEvent::OnLoad, Some("{}".to_string())),
(PageLifecycleEvent::OnShow, None),
(PageLifecycleEvent::OnReady, None),
]
);
assert_ne!(state.entry, EntryPhase::LoadOwed);
assert_eq!(state.entry, EntryPhase::Loaded);
assert_eq!(state.visibility, Visibility::Shown);
assert!(state.ready_dispatched);
}
#[test]
fn reentering_a_cached_page_does_not_refire_ready_without_a_reload() {
let mut state = test_page_state();
state.bridge_ready = true;
state.visibility = Visibility::ShowOwed;
state.render_status = PageRenderStatus::Finished;
PageInstance::request_on_load(&mut state);
PageInstance::collect_ready_lifecycle_events(&mut state, &mut Vec::new());
assert!(state.ready_dispatched);
let mut events = Vec::new();
state.visibility = Visibility::ShowOwed;
PageInstance::request_on_load(&mut state);
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![
(PageLifecycleEvent::OnLoad, Some("{}".to_string())),
(PageLifecycleEvent::OnShow, None),
]
);
}
#[test]
fn a_reset_page_fires_ready_again_for_the_new_document() {
let mut state = test_page_state();
state.bridge_ready = true;
state.visibility = Visibility::ShowOwed;
state.render_status = PageRenderStatus::Finished;
PageInstance::request_on_load(&mut state);
PageInstance::collect_ready_lifecycle_events(&mut state, &mut Vec::new());
PageInstance::reset_webview_lifecycle_state(&mut state);
state.bridge_ready = true;
state.visibility = Visibility::ShowOwed;
state.render_status = PageRenderStatus::Finished;
PageInstance::request_on_load(&mut state);
let mut events = Vec::new();
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert!(events.contains(&(PageLifecycleEvent::OnReady, None)));
}
#[test]
fn a_torn_down_page_does_not_boot_from_a_stray_handshake() {
let mut state = test_page_state();
state.reset = PageReset::AwaitingEntry;
state.entry = EntryPhase::Idle;
assert!(!PageInstance::handshake_should_request_on_load(
&state, false
));
assert!(!PageInstance::handshake_should_request_on_load(
&state, true
));
PageInstance::request_on_load(&mut state);
assert_eq!(state.reset, PageReset::None);
assert_eq!(state.entry, EntryPhase::LoadOwed);
}
#[test]
fn handshake_does_not_boot_a_hidden_idle_page() {
let state = test_page_state();
assert_eq!(state.entry, EntryPhase::Idle);
assert_eq!(state.visibility, Visibility::Hidden);
assert!(!PageInstance::handshake_should_request_on_load(
&state, false
));
}
#[test]
fn handshake_boots_a_visible_or_isolated_idle_page() {
let mut shown = test_page_state();
shown.visibility = Visibility::ShowOwed;
assert!(PageInstance::handshake_should_request_on_load(
&shown, false
));
let hidden_surface = test_page_state();
assert!(PageInstance::handshake_should_request_on_load(
&hidden_surface,
true
));
}
#[test]
fn hidden_warmup_defers_ready_until_shown() {
let mut state = test_page_state();
let mut events = Vec::new();
state.bridge_ready = true;
PageInstance::request_on_load(&mut state);
state.render_status = PageRenderStatus::Finished;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![(PageLifecycleEvent::OnLoad, Some("{}".to_string()))]
);
assert!(!state.ready_dispatched);
events.clear();
state.visibility = Visibility::ShowOwed;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![
(PageLifecycleEvent::OnShow, None),
(PageLifecycleEvent::OnReady, None),
]
);
assert!(state.ready_dispatched);
}
#[test]
fn switch_tab_requests_on_load_only_for_a_fresh_entry() {
assert!(PageInstance::switch_tab_should_request_on_load(
EntryPhase::Idle
));
assert!(!PageInstance::switch_tab_should_request_on_load(
EntryPhase::LoadOwed
));
assert!(!PageInstance::switch_tab_should_request_on_load(
EntryPhase::Loaded
));
}
#[test]
fn a_lifecycle_reset_unparks_the_document() {
let mut state = test_page_state();
state.parked = true;
PageInstance::reset_webview_lifecycle_state(&mut state);
assert!(!state.parked);
}
#[test]
fn unload_cancels_a_pending_entry_but_not_a_delivered_one() {
let mut owed = test_page_state();
owed.entry = EntryPhase::LoadOwed;
PageInstance::collect_hidden_or_unloaded_lifecycle_event(
&mut owed,
PageLifecycleEvent::OnUnload,
&mut Vec::new(),
);
assert_eq!(owed.entry, EntryPhase::Idle);
let mut delivered = test_page_state();
delivered.entry = EntryPhase::Loaded;
PageInstance::collect_hidden_or_unloaded_lifecycle_event(
&mut delivered,
PageLifecycleEvent::OnUnload,
&mut Vec::new(),
);
assert_eq!(delivered.entry, EntryPhase::Loaded);
}
#[test]
fn hiding_a_shown_page_owes_on_show_again_on_re_entry() {
let mut state = test_page_state();
state.bridge_ready = true;
state.visibility = Visibility::ShowOwed;
state.render_status = PageRenderStatus::Finished;
PageInstance::request_on_load(&mut state);
PageInstance::collect_ready_lifecycle_events(&mut state, &mut Vec::new());
assert_eq!(state.visibility, Visibility::Shown);
let mut events = Vec::new();
PageInstance::collect_hidden_or_unloaded_lifecycle_event(
&mut state,
PageLifecycleEvent::OnHide,
&mut events,
);
assert_eq!(state.visibility, Visibility::Hidden);
state.visibility = Visibility::ShowOwed;
let mut events = Vec::new();
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(events, vec![(PageLifecycleEvent::OnShow, None)]);
}
#[test]
fn reset_webview_lifecycle_state_does_not_keep_stale_hidden_show_request() {
let mut state = test_page_state();
state.event = Some(PageLifecycleEvent::OnHide);
state.visibility = Visibility::ShowOwed;
state.entry = EntryPhase::LoadOwed;
state.bridge_ready = true;
state.entry = EntryPhase::Loaded;
state.visibility = Visibility::Shown;
state.ready_dispatched = true;
state.render_status = PageRenderStatus::Finished;
PageInstance::reset_webview_lifecycle_state(&mut state);
assert_eq!(state.visibility, Visibility::Hidden);
assert_ne!(state.entry, EntryPhase::LoadOwed);
assert!(!state.bridge_ready);
assert_ne!(state.entry, EntryPhase::Loaded);
assert_ne!(state.visibility, Visibility::Shown);
assert!(!state.ready_dispatched);
assert_eq!(state.render_status, PageRenderStatus::Unstarted);
}
#[test]
fn reset_webview_lifecycle_state_preserves_current_visible_intent() {
let mut state = test_page_state();
state.event = Some(PageLifecycleEvent::OnShow);
PageInstance::reset_webview_lifecycle_state(&mut state);
assert_ne!(state.visibility, Visibility::Hidden);
}
#[test]
fn on_unload_preserves_bridge_ready_for_reusable_page_instance() {
let mut state = test_page_state();
let mut events = Vec::new();
state.visibility = Visibility::ShowOwed;
state.entry = EntryPhase::LoadOwed;
state.bridge_ready = true;
PageInstance::collect_hidden_or_unloaded_lifecycle_event(
&mut state,
PageLifecycleEvent::OnUnload,
&mut events,
);
assert_eq!(events, vec![(PageLifecycleEvent::OnUnload, None)]);
assert_eq!(state.visibility, Visibility::Hidden);
assert_ne!(state.entry, EntryPhase::LoadOwed);
assert!(state.bridge_ready);
}
#[test]
fn only_on_unload_cancels_page_bridge_work() {
assert!(!PageInstance::lifecycle_cancels_bridge_work(
PageLifecycleEvent::OnHide
));
assert!(PageInstance::lifecycle_cancels_bridge_work(
PageLifecycleEvent::OnUnload
));
}
#[test]
fn attach_reset_boundary_only_resets_real_replacement() {
assert!(!PageInstance::should_reset_lifecycle_on_attach(None));
assert!(!PageInstance::should_reset_lifecycle_on_attach(Some(true)));
assert!(PageInstance::should_reset_lifecycle_on_attach(Some(false)));
}
#[test]
fn first_attach_must_not_drop_bridge_ready_load_request() {
let mut state = test_page_state();
let mut events = Vec::new();
state.bridge_ready = true;
PageInstance::request_on_load(&mut state);
state.render_status = PageRenderStatus::Started;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![(PageLifecycleEvent::OnLoad, Some("{}".to_string()))]
);
assert_eq!(state.entry, EntryPhase::Loaded);
}
#[test]
fn first_attach_must_not_drop_pending_show_request() {
let mut state = test_page_state();
let mut events = Vec::new();
state.visibility = Visibility::ShowOwed;
state.bridge_ready = true;
PageInstance::request_on_load(&mut state);
state.render_status = PageRenderStatus::Started;
PageInstance::collect_ready_lifecycle_events(&mut state, &mut events);
assert_eq!(
events,
vec![
(PageLifecycleEvent::OnLoad, Some("{}".to_string())),
(PageLifecycleEvent::OnShow, None),
]
);
assert_eq!(state.entry, EntryPhase::Loaded);
assert_eq!(state.visibility, Visibility::Shown);
}
}