use dashmap::DashMap;
use http::Uri as HttpUri;
use lingxia_platform::Platform;
use lingxia_platform::traits::app_runtime::AppRuntime;
use lingxia_platform::traits::ui::UIUpdate;
#[cfg(feature = "js-appservice")]
use rong::{JSContext, JSResult, Source, error::HostError};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use tokio::sync::oneshot;
use tokio::time;
use uuid::Uuid;
use self::navbar::NavigationBarState;
use self::page_chrome::{
AppearancePreference, EffectivePageChromeLayout, LxAppAppearanceState, TabBarPresentation,
TabBarVisibilityPreference, VisibilityPreference,
};
use crate::appservice::LxAppWorkers;
use crate::error::LxAppError;
use crate::page::config::{OrientationConfig, PageConfig};
use crate::page::{PageInstance, PageInstanceId, ViewCallOptions};
use crate::startup::{LxAppStartupOptions, Scene};
use crate::update::UpdateManager;
use crate::{debug, error, info, warn};
pub mod config;
pub mod host_class;
use config::{LxAppConfig, LxAppLogicEntry, LxAppPageEntry};
mod content;
mod display_language;
pub mod host_appearance;
pub(crate) mod metadata;
pub mod navbar;
pub mod page_chrome;
pub(crate) mod page_discard;
mod page_instance_host;
mod permissions;
pub(crate) mod registry;
mod runtime_bootstrap;
mod runtime_ops;
pub(crate) mod runtime_registry;
mod scheme;
mod shutdown;
pub use shutdown::{
block_lxapp_admission, drain_lxapps, resume_lxapp_admission, shutdown_lxapps_except,
};
pub(crate) mod security;
mod surface;
pub use security::{LxAppSecurityPrivilege, is_public_network_address};
pub mod tabbar;
pub mod uri;
pub(crate) mod version;
use crate::lifecycle::AppServiceEvent;
pub use crate::page::runtime::{
CloseReason, CreatePageInstanceRequest, CreatedPageInstance, PageDefinition, PageInstanceEvent,
PageInstanceRuntimeInfo, PageOwner, PageQueryInput, PageTarget, PresentationKind, ResolvedPage,
SceneId,
};
use crate::page::runtime::{
PageInstanceLifecycleState, PageInstanceRuntimeRecord, transition_page_instance_lifecycle,
};
pub use display_language::{
DisplayLanguageEffectiveSource, DisplayLanguageEffectiveUpdate, DisplayLanguagePreference,
DisplayLanguageSessionOwner, DisplayLanguageState, DisplayLanguageStateUpdate, LanguageTag,
add_display_language_effective_listener, add_display_language_state_listener,
clear_active_display_language_session_override, clear_display_language_session_override,
display_language, display_language_state, display_language_state_update,
initialize_display_language, install_display_language_session_override,
refresh_display_language_system, set_display_language_preference,
set_display_language_preference_in, subscribe_display_language_effective,
subscribe_display_language_state,
};
pub use host_appearance::{
HostAppearanceState, HostAppearanceUpdate, host_appearance_dark, host_appearance_state,
initialize_host_appearance, refresh_host_appearance_system, set_host_appearance_preference,
subscribe_host_appearance,
};
pub use lingxia_platform::traits::ui::{SurfaceKind, SurfacePosition};
pub use lingxia_surface::Role as SurfaceRole;
pub use lingxia_update::Channel;
use lingxia_webview::runtime::destroy_webview_if_matches;
pub use runtime_bootstrap::dev_session_active as is_dev_session;
pub use runtime_bootstrap::init;
pub use runtime_bootstrap::register_runner_host;
pub use runtime_bootstrap::runner_active as is_runner;
pub use runtime_ops::{
close_lxapp, create_page_instance, dispose_page_instance, dispose_page_instance_by_id,
ensure_builtin_lxapp, ensure_control_lxapp, ensure_control_surface_lxapp,
ensure_host_surface_owner, ensure_lxapp, get_current_lxapp, installed_lxapp_path,
is_lxapp_open, is_pull_down_refresh_enabled, list_lxapps, mark_lxapp_active,
notify_lxapp_host_visibility, notify_page_host_visibility, notify_page_instance,
notify_page_instance_by_id, on_low_memory, open_control_lxapp_page, open_lxapp,
refresh_auto_appearances, restart_lxapp, terminate_lxapp, touch_page_instance_by_id,
uninstall_lxapp,
};
pub(crate) use runtime_registry::get_lxapps_manager;
pub use runtime_registry::{find_page_by_instance_id, get_platform, try_get};
pub(crate) use surface::SurfaceRecords;
pub use surface::{
HostMainSurfaceRegistration, HostSurfaceMenuExecution, LxAppRuntimeSurfaceInfo,
ManagedNativeSurface, PageSurface, PageSurfaceRequest, PageSurfaceTarget, UrlCallbackSurface,
UrlCallbackWaitError, register_surface_active_main_observer, register_surface_close_observer,
register_surface_context_observer, register_surface_visibility_observer,
};
use version::Version;
pub(crate) const LINGXIA_DIR: &str = "lingxia";
pub(crate) const LXAPPS_DIR: &str = "lxapps";
pub(crate) const PLUGINS_DIR: &str = "plugins";
pub(crate) const STORAGE_DIR: &str = "storage";
pub(crate) const USER_DATA_DIR: &str = "userdata";
pub(crate) const USER_CACHE_DIR: &str = "usercache";
pub(crate) const TEMP_DIR: &str = "temp";
const LXAPPS_DB_FILE: &str = "lxapps.redb";
type PendingPageServiceRestart = (PageInstance, oneshot::Receiver<Result<(), String>>);
const DEFAULT_VERSION: &str = "0.0.1";
const LXAPP_STACK_MAX: usize = 5;
const PAGE_STACK_MAX: usize = 10;
static NUM_WORKERS: OnceLock<usize> = OnceLock::new();
static LXAPP_SOURCE_OVERRIDES: OnceLock<Mutex<HashMap<String, LxAppBundleSource>>> =
OnceLock::new();
static TRANSIENT_FILE_GRANTS: OnceLock<DashMap<(String, LxAppSessionId, String), PathBuf>> =
OnceLock::new();
static TRANSIENT_FILE_REFERENCE_GRANTS: OnceLock<DashMap<(String, LxAppSessionId, String), ()>> =
OnceLock::new();
#[derive(Debug, Clone, Copy)]
enum TransientPathKind {
File,
Directory,
}
fn normalize_transient_path(path: &Path, kind: TransientPathKind) -> Result<PathBuf, LxAppError> {
let normalized = std::fs::canonicalize(path).map_err(|e| {
LxAppError::ResourceNotFound(format!("transient path {}: {}", path.display(), e))
})?;
let metadata = std::fs::metadata(&normalized)?;
let valid = match kind {
TransientPathKind::File => metadata.is_file(),
TransientPathKind::Directory => metadata.is_dir(),
};
if !valid {
return Err(LxAppError::InvalidParameter(format!(
"invalid transient path kind: {}",
normalized.display()
)));
}
Ok(normalized)
}
fn normalize_transient_file_reference(reference: &str) -> Result<String, LxAppError> {
let normalized = reference.trim();
let scheme = normalized
.split_once(':')
.map(|(scheme, _)| scheme.to_ascii_lowercase());
if normalized.is_empty()
|| normalized.chars().any(char::is_control)
|| !matches!(scheme.as_deref(), Some("content" | "datashare" | "file"))
{
return Err(LxAppError::InvalidParameter(
"invalid transient file reference".to_string(),
));
}
Ok(normalized.to_string())
}
pub fn set_num_workers(n: usize) {
let n = n.max(1);
if NUM_WORKERS.set(n).is_err() {
warn!("set_num_workers: value already set, ignoring");
}
}
fn get_num_workers() -> usize {
NUM_WORKERS.get().copied().unwrap_or(LXAPP_STACK_MAX)
}
pub fn register_builtin_asset_bundle(appid: impl Into<String>) {
register_lxapp_bundle_source(appid, LxAppBundleSource::BuiltinAssets);
}
pub fn bundled_lxapp_asset_available(appid: &str) -> bool {
let Some(runtime) = runtime_registry::get_platform() else {
return false;
};
runtime
.read_asset(&format!("{}/lxapp.json", appid.trim_end_matches('/')))
.is_ok()
}
pub(crate) fn forget_builtin_bundle_source(appid: &str) {
let Some(registry) = LXAPP_SOURCE_OVERRIDES.get() else {
return;
};
let mut guard = registry.lock().unwrap_or_else(|e| e.into_inner());
if matches!(guard.get(appid), Some(LxAppBundleSource::BuiltinAssets)) {
guard.remove(appid);
}
}
pub fn register_synthetic_lxapp(appid: impl Into<String>) {
register_lxapp_bundle_source(appid, LxAppBundleSource::Synthetic);
}
pub const HOST_SURFACE_OWNER_APP_ID: &str = "app.lingxia.host-surface-owner";
pub fn register_dev_bundle_source(appid: impl Into<String>, root: impl Into<PathBuf>) {
register_lxapp_bundle_source(appid, LxAppBundleSource::DevPath { root: root.into() });
}
fn register_lxapp_bundle_source(appid: impl Into<String>, source: LxAppBundleSource) {
let appid = appid.into();
let registry = LXAPP_SOURCE_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = registry.lock().unwrap_or_else(|e| e.into_inner());
guard.insert(appid, source);
}
pub(crate) fn is_ota_managed_appid(appid: &str) -> bool {
!matches!(
lxapp_bundle_source_for(appid),
Some(LxAppBundleSource::DevPath { .. })
)
}
fn lxapp_bundle_source_for(appid: &str) -> Option<LxAppBundleSource> {
LXAPP_SOURCE_OVERRIDES
.get()
.and_then(|registry| registry.lock().ok())
.and_then(|guard| guard.get(appid).cloned())
}
fn control_surface_bundle_source_allowed(source: Option<&LxAppBundleSource>) -> bool {
matches!(
source,
Some(LxAppBundleSource::BuiltinAssets | LxAppBundleSource::DevPath { .. })
)
}
pub struct LxApps {
lxapps: DashMap<String, Arc<LxApp>>,
instances: Mutex<HashMap<LxAppSessionId, Arc<LxApp>>>,
admission: Arc<shutdown::Admission>,
lxapp_stack: Mutex<VecDeque<String>>,
runtime: Arc<Platform>,
pub(crate) executor: Arc<LxAppWorkers>,
pending_destroy: Mutex<HashMap<String, PendingDestroy>>,
next_destroy_generation: AtomicU64,
session_transition_locks: DashMap<String, Arc<Mutex<()>>>,
}
struct PendingDestroy {
generation: u64,
cancel: oneshot::Sender<()>,
}
fn replace_pending_destroy(
pending: &mut HashMap<String, PendingDestroy>,
appid: String,
replacement: PendingDestroy,
) {
if let Some(previous) = pending.insert(appid, replacement) {
let _ = previous.cancel.send(());
}
}
fn claim_pending_destroy(
pending: &mut HashMap<String, PendingDestroy>,
appid: &str,
generation: u64,
) -> bool {
if pending
.get(appid)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(appid);
true
} else {
false
}
}
fn first_evictable_appid(
stack: &[String],
mut is_evictable: impl FnMut(&str) -> bool,
) -> Option<String> {
stack.iter().find(|appid| is_evictable(appid)).cloned()
}
impl LxApps {
fn new(runtime: Platform, executor: Arc<LxAppWorkers>, capacity: usize) -> Self {
info!("LxApps manager initialized with {} workers", capacity);
let runtime = Arc::new(runtime);
Self {
lxapps: DashMap::new(),
instances: Mutex::new(HashMap::new()),
admission: Arc::new(shutdown::Admission::default()),
runtime,
executor,
lxapp_stack: Mutex::new(VecDeque::with_capacity(capacity)),
pending_destroy: Mutex::new(HashMap::new()),
next_destroy_generation: AtomicU64::new(1),
session_transition_locks: DashMap::new(),
}
}
fn session_transition_lock(&self, appid: &str) -> Arc<Mutex<()>> {
self.session_transition_locks
.entry(appid.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn cleanup_session_transition_lock(&self, appid: &str) {
self.session_transition_locks
.remove_if(appid, |_, lock| Arc::strong_count(lock) == 1);
}
fn with_session_transition<T>(&self, appid: &str, operation: impl FnOnce() -> T) -> T {
let transition_lock = self.session_transition_lock(appid);
let result = {
let _transition_guard = transition_lock.lock().unwrap();
operation()
};
drop(transition_lock);
self.cleanup_session_transition_lock(appid);
result
}
pub(crate) fn ensure_lxapp(
&self,
appid: String,
release_type: Channel,
) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(&appid)?;
let transition_appid = appid.clone();
self.with_session_transition(&transition_appid, move || {
let session_class = self.session_class_for(&appid);
self.ensure_lxapp_with_session_class(appid, release_type, session_class)
})
}
fn session_class_for(&self, appid: &str) -> AppSessionClass {
let live = self.lxapps.get(appid).map(|app| app.app_session_class());
Self::session_class_for_identity(appid, lingxia_app_context::home_app_id(), live)
}
fn session_class_for_identity(
appid: &str,
home_app_id: Option<&str>,
live: Option<AppSessionClass>,
) -> AppSessionClass {
if home_app_id == Some(appid) {
return AppSessionClass::ControlApp;
}
live.unwrap_or(AppSessionClass::StandardApp)
}
pub(crate) fn ensure_lxapp_for_native_control(
&self,
appid: String,
release_type: Channel,
) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(&appid)?;
let transition_appid = appid.clone();
self.with_session_transition(&transition_appid, move || {
if lingxia_app_context::home_app_id() != Some(appid.as_str()) {
return Err(LxAppError::InvalidParameter(format!(
"control app identity mismatch: {appid} is not the native-sealed home app"
)));
}
if let Some(app) = self.lxapps.get(&appid) {
if app.is_control_app() {
return Ok(app.clone());
}
drop(app);
self.destroy_lxapp_with_options(&appid, true);
}
self.ensure_lxapp_with_session_class(appid, release_type, AppSessionClass::ControlApp)
})
}
pub(crate) fn ensure_lxapp_for_control_surface(
&self,
appid: String,
release_type: Channel,
) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(&appid)?;
let transition_appid = appid.clone();
self.with_session_transition(&transition_appid, move || {
if lingxia_app_context::home_app_id() == Some(appid.as_str()) {
return Err(LxAppError::InvalidParameter(format!(
"control surface identity mismatch: {appid} is the home app"
)));
}
if !control_surface_bundle_source_allowed(lxapp_bundle_source_for(&appid).as_ref()) {
return Err(LxAppError::InvalidParameter(format!(
"control surface must be a host-bundled lxapp: {appid}"
)));
}
if let Some(app) = self.lxapps.get(&appid) {
if app.app_session_class() == AppSessionClass::ControlSurface {
return Ok(app.clone());
}
drop(app);
self.destroy_lxapp_with_options(&appid, true);
}
self.ensure_lxapp_with_session_class(
appid,
release_type,
AppSessionClass::ControlSurface,
)
})
}
fn ensure_builtin_lxapp(&self, appid: &str) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(appid)?;
self.with_session_transition(appid, || {
if let Some(app) = self.lxapps.get(appid) {
return Ok(app.clone());
}
if !matches!(
lxapp_bundle_source_for(appid),
Some(LxAppBundleSource::BuiltinAssets | LxAppBundleSource::Synthetic)
) {
return Err(LxAppError::ResourceNotFound(format!(
"builtin lxapp source not registered: {appid}"
)));
}
let app = Arc::new(LxApp::new(
appid.to_string(),
self.runtime.clone(),
self.executor.clone(),
Channel::Release,
)?);
self.track_instance(&app);
app.bind_and_seal_resource_grants();
self.lxapps.insert(appid.to_string(), app.clone());
Ok(app)
})
}
fn initialize_home_lxapp(&self, appid: String) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(&appid)?;
let transition_appid = appid.clone();
self.with_session_transition(&transition_appid, move || {
if let Some(app) = self.lxapps.get(&appid) {
if app.is_control_app() {
return Ok(app.clone());
}
drop(app);
self.destroy_lxapp_with_options(&appid, true);
}
let app = Arc::new(LxApp::new_as_home(
appid.clone(),
self.runtime.clone(),
self.executor.clone(),
)?);
self.track_instance(&app);
app.bind_and_seal_resource_grants();
self.lxapps.insert(appid, app.clone());
Ok(app)
})
}
fn ensure_lxapp_with_session_class(
&self,
appid: String,
release_type: Channel,
session_class: AppSessionClass,
) -> Result<Arc<LxApp>, LxAppError> {
let has_pending_update = metadata::downloaded_get(&appid, release_type)
.map(|opt| opt.is_some())
.unwrap_or(false);
if has_pending_update {
if let Some(app_arc) = self.lxapps.get(&appid)
&& app_arc.status() != LxAppSessionStatus::Closed
{
return Ok(app_arc.clone());
}
self.destroy_lxapp(&appid);
if let Err(e) =
UpdateManager::apply_downloaded_update(self.runtime.clone(), &appid, release_type)
{
error!(
"Failed to apply downloaded update before opening app: {}",
e
)
.with_appid(appid.clone());
return Err(e);
}
} else if let Some(app_arc) = self.lxapps.get(&appid) {
return Ok(app_arc.clone());
}
let new_lxapp = Arc::new(match session_class {
AppSessionClass::StandardApp => LxApp::new(
appid.clone(),
self.runtime.clone(),
self.executor.clone(),
release_type,
)?,
AppSessionClass::ControlApp => {
LxApp::new_as_home(appid.clone(), self.runtime.clone(), self.executor.clone())?
}
AppSessionClass::ControlSurface => LxApp::new_control_surface(
appid.clone(),
self.runtime.clone(),
self.executor.clone(),
release_type,
)?,
});
self.track_instance(&new_lxapp);
new_lxapp.bind_and_seal_resource_grants();
match self.lxapps.entry(appid) {
dashmap::mapref::entry::Entry::Occupied(entry) => Ok(entry.get().clone()),
dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(new_lxapp.clone());
Ok(new_lxapp)
}
}
}
pub(crate) fn live_logic_instances(&self) -> Vec<Arc<LxApp>> {
self.instances
.lock()
.unwrap()
.values()
.filter(|app| *app.logic_contexts.borrow() != 0)
.cloned()
.collect()
}
fn track_instance(&self, app: &Arc<LxApp>) {
let _ = app.admission.set(self.admission.clone());
let mut instances = self.instances.lock().unwrap();
instances.retain(|_, old| {
self.lxapps
.get(&old.appid)
.is_some_and(|live| Arc::ptr_eq(live.value(), old))
|| !old.session.is_cancelled()
|| *old.logic_contexts.borrow() != 0
});
instances.insert(app.session_id(), app.clone());
}
pub(crate) fn retire_lxapp(&self, appid: &str) -> Result<Arc<LxApp>, LxAppError> {
self.with_session_transition(appid, || {
let app = self
.lxapps
.get(appid)
.map(|entry| entry.value().clone())
.ok_or_else(|| LxAppError::ResourceNotFound(appid.to_string()))?;
self.retire_locked(&app)?;
Ok(app)
})
}
fn retire_instance(&self, app: &Arc<LxApp>) -> Result<(), LxAppError> {
self.with_session_transition(&app.appid, || self.retire_locked(app))
}
fn retire_locked(&self, app: &Arc<LxApp>) -> Result<(), LxAppError> {
let _open = app
.presentation_open_lock
.lock()
.unwrap_or_else(|e| e.into_inner());
self.instances
.lock()
.unwrap()
.insert(app.session_id(), app.clone());
app.session.retired.store(true, Ordering::SeqCst);
let is_current = self
.lxapps
.get(&app.appid)
.is_some_and(|current| Arc::ptr_eq(current.value(), app));
if is_current {
self.remove_from_stack(&app.appid);
self.cancel_delayed_destroy(&app.appid);
}
app.shutdown()?;
app.complete_programmatic_close(app.session_id());
if is_current {
self.lxapps.remove(&app.appid);
}
Ok(())
}
fn destroy_lxapp_with_options(&self, appid: &str, skip_hide: bool) {
if let Some(app_arc) = self.lxapps.get(appid) {
let _ = app_arc.shutdown_with_options(skip_hide);
}
self.remove_from_stack(appid);
self.lxapps.remove(appid);
}
fn destroy_lxapp(&self, appid: &str) {
self.destroy_lxapp_with_options(appid, false);
}
fn recreate_lxapp(
&self,
appid: String,
release_type: Channel,
) -> Result<Arc<LxApp>, LxAppError> {
let _admission = self.admission.enter(&appid)?;
let transition_appid = appid.clone();
self.with_session_transition(&transition_appid, move || {
let session_class = self.session_class_for(&appid);
self.destroy_lxapp_with_options(&appid, true);
self.ensure_lxapp_with_session_class(appid, release_type, session_class)
})
}
fn evict_lru_lxapp(&self) {
let candidates = {
let Ok(stack) = self.lxapp_stack.lock() else {
return;
};
stack.iter().cloned().collect::<Vec<_>>()
};
let Some(appid_to_destroy) = first_evictable_appid(&candidates, |appid| {
self.lxapps.get(appid).is_some_and(|app| !app.is_home_lxapp)
}) else {
warn!("No non-home lxapp is available for eviction");
return;
};
info!("Evicting least recently used lxapp").with_appid(appid_to_destroy.clone());
self.destroy_lxapp(&appid_to_destroy);
}
pub(crate) fn schedule_delayed_destroy(self: &Arc<Self>, appid: String) {
let generation = self.next_destroy_generation.fetch_add(1, Ordering::Relaxed);
let (cancel, rx) = oneshot::channel();
{
let mut pending = self
.pending_destroy
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
replace_pending_destroy(
&mut pending,
appid.clone(),
PendingDestroy { generation, cancel },
);
}
let mgr_weak = Arc::downgrade(self);
std::mem::drop(crate::executor::spawn(async move {
let sleep = time::sleep(Duration::from_secs(1800));
tokio::pin!(rx);
tokio::pin!(sleep);
tokio::select! {
_ = &mut sleep => {},
_ = &mut rx => return,
}
if let Some(mgr) = mgr_weak.upgrade() {
let should_destroy = {
let mut pending = mgr
.pending_destroy
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
claim_pending_destroy(&mut pending, &appid, generation)
};
if should_destroy {
info!("Delayed destroy triggered after inactivity").with_appid(appid.clone());
mgr.destroy_lxapp(&appid);
}
}
}));
}
pub(crate) fn cancel_delayed_destroy(&self, appid: &str) {
let mut pending = self
.pending_destroy
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(entry) = pending.remove(appid) {
let _ = entry.cancel.send(());
}
}
pub(crate) fn push_lxapp_stack(&self, appid: String) {
let max = get_num_workers();
if let Ok(mut stack) = self.lxapp_stack.lock() {
if stack.len() < max {
stack.push_back(appid);
} else {
warn!(
"LxApp navigation stack is full (capacity: {}). Cannot push app: {}",
max, appid
);
}
}
}
fn peek_lxapp_stack(&self) -> Option<String> {
if let Ok(stack) = self.lxapp_stack.lock() {
stack.back().cloned()
} else {
None
}
}
pub(crate) fn remove_from_stack(&self, appid: &str) {
if let Ok(mut stack) = self.lxapp_stack.lock() {
stack.retain(|id| id != appid);
}
}
pub(crate) fn stack_contains(&self, appid: &str) -> bool {
self.lxapp_stack
.lock()
.map(|stack| stack.iter().any(|id| id == appid))
.unwrap_or(false)
}
fn is_lxapp_stack_full(&self) -> bool {
let max = get_num_workers();
if let Ok(stack) = self.lxapp_stack.lock() {
stack.len() >= max
} else {
true
}
}
}
pub(crate) struct LxAppState {
pub(crate) pages_by_id: Mutex<HashMap<String, PageInstance>>,
pub(crate) path_pins: Mutex<HashMap<String, String>>,
page_instance_runtime: Mutex<HashMap<String, PageInstanceRuntimeRecord>>,
page_instance_dispose_timers: Mutex<HashMap<String, oneshot::Sender<()>>>,
page_reset_timers: Mutex<HashMap<String, oneshot::Sender<()>>>,
pub(crate) page_stack: Mutex<VecDeque<String>>,
pub(crate) last_active_time: Instant,
pub tabbar: Option<tabbar::TabBar>,
pub(crate) appearance: LxAppAppearanceState,
pub(crate) page_chrome_revision: u64,
pub(crate) page_chrome_layouts: HashMap<String, EffectivePageChromeLayout>,
pub(crate) startup_options: LxAppStartupOptions,
open_region: Option<LxAppOpenRegion>,
pub(crate) surfaces: Mutex<SurfaceRecords>,
pub(crate) orientation_override: Option<OrientationConfig>,
more_actions: LxAppMoreActionState,
}
impl LxAppState {
fn new() -> Self {
Self {
pages_by_id: Mutex::new(HashMap::new()),
path_pins: Mutex::new(HashMap::new()),
page_instance_runtime: Mutex::new(HashMap::new()),
page_instance_dispose_timers: Mutex::new(HashMap::new()),
page_reset_timers: Mutex::new(HashMap::new()),
page_stack: Mutex::new(VecDeque::with_capacity(PAGE_STACK_MAX)),
last_active_time: Instant::now(),
tabbar: None,
appearance: LxAppAppearanceState::default(),
page_chrome_revision: 0,
page_chrome_layouts: HashMap::new(),
startup_options: LxAppStartupOptions::default(),
open_region: None,
surfaces: Mutex::new(SurfaceRecords::new()),
orientation_override: None,
more_actions: LxAppMoreActionState::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum LxAppBundleSource {
Installed,
DevPath {
root: PathBuf,
},
BuiltinAssets,
Synthetic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppSessionClass {
StandardApp,
ControlApp,
ControlSurface,
}
pub struct LxApp {
pub appid: String,
pub runtime: Arc<Platform>,
pub lxapp_dir: PathBuf,
pub(crate) bundle_source: LxAppBundleSource,
pub storage_file_path: PathBuf,
pub user_data_dir: PathBuf,
pub user_cache_dir: PathBuf,
pub temp_dir: PathBuf,
temp_cleanup_protection: Option<crate::cache::CleanupProtection>,
usercache_cleanup_protection: Option<crate::cache::CleanupProtection>,
pub fingermark: String,
pub is_home_lxapp: bool,
app_session_class: AppSessionClass,
pub(crate) release_type: Channel,
pub(crate) config: Mutex<LxAppConfig>,
pub(crate) executor: Arc<LxAppWorkers>,
host_permissions: permissions::HostPermissions,
home_update_check_dispatched: AtomicBool,
app_launch_dispatched: AtomicBool,
pending_restart_request: AtomicBool,
shown: AtomicBool,
hidden_since: Mutex<Option<Instant>>,
restart_closing_session: AtomicU64,
logic_feature_snapshots: Mutex<std::collections::BTreeMap<String, Vec<String>>>,
pub(crate) session: LxAppSession,
pub(crate) logic_contexts: tokio::sync::watch::Sender<usize>,
admission: OnceLock<Arc<shutdown::Admission>>,
pub(crate) state: Mutex<LxAppState>,
presentation_open_lock: Mutex<()>,
pub(crate) page_chrome_mutation_lock: tokio::sync::Mutex<()>,
self_weak: OnceLock<Weak<LxApp>>,
resource_grants: OnceLock<HashSet<crate::host::AppResourceGrant>>,
resource_grants_claimed: std::sync::atomic::AtomicBool,
document_start_scripts: Mutex<Vec<Arc<str>>>,
page_scripts: Mutex<Vec<Arc<str>>>,
}
pub(crate) type LxAppSessionId = u64;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum LxAppSessionStatus {
Closed = 0,
Opening = 1,
Opened = 2,
Closing = 3,
Restarting = 4,
}
impl LxAppSessionStatus {
fn as_str(self) -> &'static str {
match self {
Self::Closed => "closed",
Self::Opening => "opening",
Self::Opened => "opened",
Self::Closing => "closing",
Self::Restarting => "restarting",
}
}
}
pub(crate) struct LxAppSession {
pub(crate) id: LxAppSessionId,
status: AtomicU8,
retired: AtomicBool,
shutdown: Mutex<tokio::sync::watch::Sender<bool>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimeInfo {
pub appid: String,
pub app_name: String,
pub version: String,
pub release_type: String,
pub session_id: u64,
pub status: String,
pub in_stack: bool,
pub is_home: bool,
pub current_page: Option<String>,
pub initial_route: String,
pub pages_count: usize,
pub page_entries: Vec<LxAppRuntimePageInfo>,
pub page_stack: Vec<String>,
pub tab_bar: Option<LxAppRuntimeTabBarInfo>,
pub navigation_bar: Option<LxAppRuntimeNavigationBarInfo>,
pub lxapp_dir: String,
pub data_dir: String,
pub cache_dir: String,
pub logic_features: std::collections::BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimeTabBarInfo {
pub presentation: TabBarPresentation,
pub visibility: TabBarVisibilityPreference,
pub route_visible: bool,
pub effective_visible: bool,
pub selected_index: i32,
pub items: Vec<LxAppRuntimeTabBarItemInfo>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimeNavigationBarInfo {
pub title: String,
pub home_button: VisibilityPreference,
pub home_button_visible: bool,
pub runtime_style: LxAppRuntimeNavigationBarStyleInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimeNavigationBarStyleInfo {
pub background_color: Option<String>,
pub foreground_color: Option<String>,
pub divider_color: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimeTabBarItemInfo {
pub index: usize,
pub text: Option<String>,
pub icon_path: Option<String>,
pub badge: Option<String>,
pub red_dot: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct LxAppRuntimePageInfo {
pub name: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LxAppMoreAction {
pub label: String,
pub icon_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LxAppMoreActions {
pub generation: u64,
pub items: Vec<LxAppMoreAction>,
}
pub const LXAPP_MORE_ACTION_LIMIT: usize = 7;
#[derive(Debug, Default)]
struct LxAppMoreActionState {
generation: u64,
items: Vec<LxAppMoreAction>,
}
impl LxAppSession {
pub(crate) fn new() -> Self {
use std::sync::atomic::AtomicU64;
static SESSION_SEQ: AtomicU64 = AtomicU64::new(1);
let id = SESSION_SEQ.fetch_add(1, Ordering::Relaxed);
Self {
id,
status: AtomicU8::new(LxAppSessionStatus::Closed as u8),
retired: AtomicBool::new(false),
shutdown: Mutex::new(tokio::sync::watch::channel(false).0),
}
}
fn shutdown_sender(&self) -> std::sync::MutexGuard<'_, tokio::sync::watch::Sender<bool>> {
self.shutdown.lock().unwrap_or_else(|err| err.into_inner())
}
pub(crate) fn cancel(&self) {
self.shutdown_sender().send_replace(true);
}
pub(crate) fn revive(&self) {
if self.is_retired() {
return;
}
let mut sender = self.shutdown_sender();
if *sender.borrow() {
*sender = tokio::sync::watch::channel(false).0;
}
}
pub(crate) fn is_cancelled(&self) -> bool {
*self.shutdown_sender().borrow()
}
pub(crate) fn is_retired(&self) -> bool {
self.retired.load(Ordering::SeqCst)
}
pub(crate) async fn while_alive<F: std::future::Future>(&self, future: F) -> Option<F::Output> {
let mut shutdown = self.shutdown_sender().subscribe();
tokio::select! {
biased;
_ = shutdown.wait_for(|cancelled| *cancelled) => None,
result = future => (!self.is_cancelled()).then_some(result),
}
}
pub(crate) fn status(&self) -> LxAppSessionStatus {
match self.status.load(Ordering::SeqCst) {
1 => LxAppSessionStatus::Opening,
2 => LxAppSessionStatus::Opened,
3 => LxAppSessionStatus::Closing,
4 => LxAppSessionStatus::Restarting,
_ => LxAppSessionStatus::Closed,
}
}
pub(crate) fn set_status(&self, s: LxAppSessionStatus) {
self.status.store(s as u8, Ordering::SeqCst);
}
pub(crate) fn cas_status(&self, from: LxAppSessionStatus, to: LxAppSessionStatus) -> bool {
self.status
.compare_exchange(from as u8, to as u8, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
}
impl LxApp {
pub(crate) fn clone_arc(&self) -> Arc<LxApp> {
self.self_weak
.get()
.and_then(Weak::upgrade)
.expect("LxApp Arc binding missing")
}
pub(crate) fn bind_arc(self: &Arc<Self>) {
let _ = self.self_weak.set(Arc::downgrade(self));
}
pub(crate) fn bind_and_seal_resource_grants(self: &Arc<Self>) {
self.bind_arc();
crate::host::seal_app_resource_grants(self);
if self.resource_grants_claimed() {
return;
}
let app = Arc::clone(self);
crate::executor::spawn(async move {
app.wait_permissions_ready().await;
});
}
pub(crate) fn status(&self) -> LxAppSessionStatus {
self.session.status()
}
pub fn session_id(&self) -> LxAppSessionId {
self.session.id
}
pub fn app_session_class(&self) -> AppSessionClass {
self.app_session_class
}
pub fn is_control_app(&self) -> bool {
self.app_session_class == AppSessionClass::ControlApp
}
pub fn sync_host_ui(&self) {
let revision = self.next_page_chrome_revision();
if let Err(err) = self.runtime.update_navbar_ui(self.appid.clone()) {
warn!("Failed to update host NavigationBar UI: {}", err).with_appid(self.appid.clone());
}
if let Err(err) = self.runtime.update_tabbar_ui(self.appid.clone()) {
warn!("Failed to update host TabBar UI: {}", err).with_appid(self.appid.clone());
}
if let Ok(page) = self.current_page() {
let appearance = self.appearance_state().resolved;
let app = self.clone_arc();
std::mem::drop(crate::executor::spawn(async move {
if let Err(err) = app
.publish_realized_page_chrome(&page, revision, appearance)
.await
{
warn!("Failed to publish Page Chrome View snapshot: {}", err)
.with_appid(app.appid.clone());
}
}));
}
}
pub fn grant_transient_file_access(&self, path: &Path) -> Result<uri::LxUri, LxAppError> {
self.grant_transient_path_access(path, TransientPathKind::File)
}
pub fn grant_transient_file_reference(&self, reference: &str) -> Result<String, LxAppError> {
let normalized = normalize_transient_file_reference(reference)?;
TRANSIENT_FILE_REFERENCE_GRANTS
.get_or_init(DashMap::new)
.insert(
(self.appid.clone(), self.session_id(), normalized.clone()),
(),
);
Ok(normalized)
}
pub fn has_transient_file_reference(&self, reference: &str) -> bool {
let Ok(normalized) = normalize_transient_file_reference(reference) else {
return false;
};
TRANSIENT_FILE_REFERENCE_GRANTS
.get_or_init(DashMap::new)
.contains_key(&(self.appid.clone(), self.session_id(), normalized))
}
pub fn register_temp_file(&self, path: &Path) -> Result<uri::LxUri, LxAppError> {
self.cleanup_temp_size(Some(path))?;
let uri = self.grant_transient_file_access(path)?;
Ok(uri)
}
pub fn temp_output_path(
&self,
category: &str,
ext: Option<&str>,
) -> Result<PathBuf, LxAppError> {
let category = category
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch,
_ => '_',
})
.collect::<String>();
let dir = self.temp_dir.join(category);
std::fs::create_dir_all(&dir).map_err(|e| {
LxAppError::IoError(format!("Failed to create temp output directory: {}", e))
})?;
let mut name = Uuid::new_v4().simple().to_string();
if let Some(ext) = ext
.map(str::trim)
.map(|value| value.trim_start_matches('.'))
.filter(|value| !value.is_empty())
{
name.push('.');
name.push_str(ext);
}
Ok(dir.join(name))
}
pub fn grant_transient_directory_access(&self, path: &Path) -> Result<uri::LxUri, LxAppError> {
self.grant_transient_path_access(path, TransientPathKind::Directory)
}
fn grant_transient_path_access(
&self,
path: &Path,
kind: TransientPathKind,
) -> Result<uri::LxUri, LxAppError> {
let normalized = normalize_transient_path(path, kind)?;
let token = Uuid::new_v4().simple().to_string();
TRANSIENT_FILE_GRANTS.get_or_init(DashMap::new).insert(
(self.appid.clone(), self.session_id(), token.clone()),
normalized,
);
uri::LxUri::from_str(&format!(
"{}://{}/{}",
uri::LX_SCHEME,
uri::HOST_TEMP,
token
))
.map_err(LxAppError::InvalidParameter)
}
fn resolve_transient_file(&self, token: &str) -> Option<PathBuf> {
TRANSIENT_FILE_GRANTS
.get_or_init(DashMap::new)
.get(&(self.appid.clone(), self.session_id(), token.to_string()))
.map(|entry| entry.value().clone())
}
pub(crate) fn clear_transient_files(&self) {
let appid = self.appid.clone();
let session_id = self.session_id();
if let Some(grants) = TRANSIENT_FILE_GRANTS.get() {
grants.retain(|key, _| key.0 != appid || key.1 != session_id);
}
if let Some(grants) = TRANSIENT_FILE_REFERENCE_GRANTS.get() {
grants.retain(|key, _| key.0 != appid || key.1 != session_id);
}
if !self.temp_dir.as_os_str().is_empty() {
let _ = std::fs::remove_dir_all(&self.temp_dir);
}
}
fn cleanup_temp_size(&self, keep: Option<&Path>) -> Result<(), LxAppError> {
if self.temp_dir.as_os_str().is_empty() {
return Ok(());
}
let Some(keep) = keep else {
return Ok(());
};
let incoming = lingxia_service::storage::path_size(keep);
lingxia_service::storage::ensure_temp_quota(&self.temp_dir, keep, incoming)
.map_err(|err| LxAppError::ResourceExhausted(err.detail().to_string()))
}
fn status_name(&self) -> &'static str {
self.status().as_str()
}
pub fn release_type(&self) -> Channel {
self.release_type
}
pub fn is_host_bundled(&self) -> bool {
matches!(
self.bundle_source,
LxAppBundleSource::BuiltinAssets | LxAppBundleSource::DevPath { .. }
)
}
pub fn process_supported(&self) -> bool {
#[cfg(feature = "process")]
{
let privilege = LxAppSecurityPrivilege::new("process")
.expect("process is a valid security privilege id");
self.is_control_app()
&& lingxia_app_context::process_enabled()
&& self.has_security_privilege(&privilege)
}
#[cfg(not(feature = "process"))]
{
false
}
}
#[doc(hidden)]
pub fn record_logic_feature_snapshot(&self, context: String, features: Vec<String>) {
self.logic_feature_snapshots
.lock()
.unwrap()
.insert(context, features);
}
#[doc(hidden)]
pub fn remove_logic_feature_snapshot(&self, context: &str) {
self.logic_feature_snapshots.lock().unwrap().remove(context);
}
pub fn app_data_dir(&self) -> PathBuf {
self.runtime.app_data_dir()
}
pub(crate) fn config(&self) -> std::sync::MutexGuard<'_, LxAppConfig> {
self.config
.lock()
.unwrap_or_else(|error| error.into_inner())
}
pub fn page_entries(&self) -> Vec<LxAppRuntimePageInfo> {
self.config()
.page_entries()
.into_iter()
.map(|LxAppPageEntry { name, path }| LxAppRuntimePageInfo { name, path })
.collect()
}
pub fn runtime_info(&self) -> LxAppRuntimeInfo {
let info = self.get_lxapp_info();
let page_entries = self.page_entries();
let tab_bar = self.get_tabbar().map(|tabbar| LxAppRuntimeTabBarInfo {
presentation: tabbar.presentation,
visibility: tabbar.visibility,
route_visible: tabbar.route_visible,
effective_visible: tabbar.is_effectively_visible(),
selected_index: tabbar.selected_index,
items: tabbar
.items
.into_iter()
.enumerate()
.map(|(index, item)| LxAppRuntimeTabBarItemInfo {
index,
text: item.text,
icon_path: item.icon_path,
badge: item.badge,
red_dot: item.has_red_dot,
})
.collect(),
});
let navigation_bar = self.peek_current_page_path().map(|path| {
let state = self.get_navbar_state(&path);
LxAppRuntimeNavigationBarInfo {
title: state.title().to_string(),
home_button: state.home_button,
home_button_visible: state.home_button_visible(),
runtime_style: LxAppRuntimeNavigationBarStyleInfo {
background_color: state
.runtime_style
.background_color
.map(|color| color.to_string()),
foreground_color: state
.runtime_style
.foreground_color
.map(|color| color.to_string()),
divider_color: state
.runtime_style
.divider_color
.map(|color| color.to_string()),
},
}
});
let in_stack = crate::lxapp::get_lxapps_manager()
.map(|manager| manager.stack_contains(&self.appid))
.unwrap_or(false);
LxAppRuntimeInfo {
appid: self.appid.clone(),
app_name: info.app_name,
version: info.version,
release_type: info.release_type,
session_id: self.session_id(),
status: self.status_name().to_string(),
in_stack,
is_home: self.is_home_lxapp,
current_page: self.peek_current_page_path(),
initial_route: self.initial_route(),
pages_count: page_entries.len(),
page_entries,
page_stack: self.get_page_stack_paths(),
tab_bar,
navigation_bar,
lxapp_dir: self.lxapp_dir.to_string_lossy().into_owned(),
data_dir: self.user_data_dir.to_string_lossy().into_owned(),
cache_dir: self.user_cache_dir.to_string_lossy().into_owned(),
logic_features: self.logic_feature_snapshots.lock().unwrap().clone(),
}
}
pub fn replace_more_actions(&self, generation: u64, mut items: Vec<LxAppMoreAction>) {
items.truncate(LXAPP_MORE_ACTION_LIMIT);
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
state.more_actions = LxAppMoreActionState { generation, items };
}
pub fn clear_more_actions_if_generation(&self, generation: u64) {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
if state.more_actions.generation == generation {
state.more_actions.generation = generation.saturating_add(1);
state.more_actions.items.clear();
}
}
pub fn more_actions(&self) -> LxAppMoreActions {
let state = self.state.lock().unwrap_or_else(|error| error.into_inner());
LxAppMoreActions {
generation: state.more_actions.generation,
items: state.more_actions.items.clone(),
}
}
pub fn more_actions_json(&self) -> String {
serde_json::to_string(&self.more_actions())
.unwrap_or_else(|_| r#"{"generation":0,"items":[]}"#.to_string())
}
pub fn activate_more_action(&self, generation: u64, index: usize) -> bool {
let valid = {
let state = self.state.lock().unwrap_or_else(|error| error.into_inner());
state.more_actions.generation == generation && index < state.more_actions.items.len()
};
if !valid {
return false;
}
crate::publish_app_event(
&self.appid,
&format!("lx.moreActions:{generation}:{index}"),
None,
)
}
pub async fn eval_logic(&self, script: String) -> Result<serde_json::Value, LxAppError> {
let json = self
.executor
.eval_app_service(self.clone_arc(), script, false)
.await?;
serde_json::from_str(&json).map_err(LxAppError::from)
}
pub async fn eval_logic_capturing_calls(
&self,
script: String,
) -> Result<serde_json::Value, LxAppError> {
let json = self
.executor
.eval_app_service(self.clone_arc(), script, true)
.await?;
serde_json::from_str(&json).map_err(LxAppError::from)
}
pub(crate) fn set_status(&self, s: LxAppSessionStatus) {
self.session.set_status(s);
}
pub(crate) fn cas_status(&self, from: LxAppSessionStatus, to: LxAppSessionStatus) -> bool {
self.session.cas_status(from, to)
}
pub(crate) fn is_ota_managed(&self) -> bool {
!matches!(self.bundle_source, LxAppBundleSource::DevPath { .. })
}
pub(crate) fn trigger_home_update_check_once(&self) {
if !self.is_home_lxapp {
return;
}
if matches!(self.bundle_source, LxAppBundleSource::DevPath { .. }) {
return;
}
if self
.home_update_check_dispatched
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
UpdateManager::spawn_lxapp_update_check(self.appid.clone(), self.release_type);
}
}
pub(crate) fn has_pending_restart_request(&self) -> bool {
self.pending_restart_request.load(Ordering::SeqCst)
}
pub fn is_restart_closing_session(&self, session_id: u64) -> bool {
session_id != 0 && self.restart_closing_session.load(Ordering::SeqCst) == session_id
}
fn cancel_page_instance_dispose_timer(&self, id: &PageInstanceId) {
self.cancel_page_instance_dispose_timer_by_id(id.as_str());
}
fn cancel_page_instance_dispose_timer_by_id(&self, id: &str) {
if let Ok(state) = self.state.lock()
&& let Some(cancel) = state
.page_instance_dispose_timers
.lock()
.unwrap()
.remove(id)
{
let _ = cancel.send(());
}
}
const PAGE_RESET_DELAY: Duration = Duration::from_millis(500);
pub(crate) fn schedule_page_reset(&self, page: &PageInstance) {
let instance_id = page.instance_id_string();
self.cancel_page_reset(&instance_id);
{
let _transition = page.reset_transition_guard();
page.mark_reset_pending();
}
let (tx, rx) = oneshot::channel();
if let Ok(state) = self.state.lock() {
state
.page_reset_timers
.lock()
.unwrap()
.insert(instance_id.clone(), tx);
}
let appid = self.appid.clone();
std::mem::drop(crate::executor::spawn(async move {
let sleep = time::sleep(Self::PAGE_RESET_DELAY);
tokio::pin!(sleep);
tokio::pin!(rx);
tokio::select! {
_ = &mut sleep => {}
_ = &mut rx => return,
}
let Some(app) = crate::lxapp::try_get(&appid) else {
return;
};
app.cancel_page_reset(&instance_id);
if app
.get_page_stack()
.iter()
.any(|entry| entry == &instance_id)
{
return;
}
let Some(page) = app.get_page_by_instance_id_str(&instance_id) else {
return;
};
let _transition = page.reset_transition_guard();
if page.take_reset_pending() {
app.teardown_page(&page);
}
}));
}
pub(crate) fn cancel_page_reset(&self, instance_id: &str) -> bool {
let Ok(state) = self.state.lock() else {
return false;
};
let cancel = state.page_reset_timers.lock().unwrap().remove(instance_id);
match cancel {
Some(cancel) => {
let _ = cancel.send(());
true
}
None => false,
}
}
pub(crate) fn flush_page_reset(&self, page: &PageInstance) {
let _ = self.flush_page_reset_awaited(page);
}
pub(crate) fn flush_page_reset_awaited(
&self,
page: &PageInstance,
) -> Option<oneshot::Receiver<Result<(), String>>> {
let _transition = page.reset_transition_guard();
if page.take_reset_pending() {
self.cancel_page_reset(&page.instance_id_string());
self.teardown_page(page);
}
if page.take_reset_awaiting_entry() {
Some(self.rebuild_page_on_entry(page))
} else {
None
}
}
fn teardown_page(&self, page: &PageInstance) {
debug!(
"Tearing down left page (instance {})",
page.instance_id_string()
)
.with_appid(self.appid.clone())
.with_path(page.path());
page.prepare_for_service_restart();
page.park_view();
if let Err(err) = self.executor.terminate_page_svc(
self.clone_arc(),
page.path().to_string(),
Some(page.instance_id_string()),
) {
warn!(
"Failed to terminate page service for {}: {}",
page.path(),
err
)
.with_appid(self.appid.clone());
}
}
fn rebuild_page_on_entry(&self, page: &PageInstance) -> oneshot::Receiver<Result<(), String>> {
debug!(
"Rebuilding page for entry (instance {})",
page.instance_id_string()
)
.with_appid(self.appid.clone())
.with_path(page.path());
let (done_tx, done_rx) = oneshot::channel::<Result<(), String>>();
let path = page.path().to_string();
let (ack_tx, ack_rx) = oneshot::channel::<Result<(), String>>();
if let Err(err) = self.executor.create_page_svc_with_ack(
self.clone_arc(),
path.clone(),
Some(page.instance_id_string()),
ack_tx,
) {
warn!("Failed to recreate page service for {}: {}", path, err)
.with_appid(self.appid.clone());
let _ = done_tx.send(Err(err.to_string()));
return done_rx;
}
let appid = self.appid.clone();
let page = page.clone();
std::mem::drop(crate::executor::spawn(async move {
match ack_rx.await {
Ok(Ok(())) => {
let result = page.load_html().map_err(|err| {
warn!("Failed to reload {} for re-entry: {}", path, err).with_appid(appid);
err.to_string()
});
let _ = done_tx.send(result);
}
Ok(Err(err)) => {
warn!("Page service rebuild failed for {}: {}", path, err).with_appid(appid);
let _ = done_tx.send(Err(err));
}
Err(_) => {}
}
}));
done_rx
}
fn cancel_all_page_resets(&self) {
if let Ok(state) = self.state.lock() {
let mut timers = state.page_reset_timers.lock().unwrap();
for (_id, cancel) in timers.drain() {
let _ = cancel.send(());
}
}
}
fn cancel_all_page_instance_dispose_timers(&self) {
if let Ok(state) = self.state.lock() {
let mut timers = state.page_instance_dispose_timers.lock().unwrap();
for (_id, cancel) in timers.drain() {
let _ = cancel.send(());
}
}
}
fn schedule_page_instance_dispose_timer(
&self,
id: &PageInstanceId,
dispose_ttl: Duration,
) -> Result<(), LxAppError> {
let reclaim_reason = CloseReason::Reclaimed;
if dispose_ttl.is_zero() {
return self.dispose_page_instance_internal(id, reclaim_reason, false);
}
self.cancel_page_instance_dispose_timer(id);
let (tx, rx) = oneshot::channel();
if let Ok(state) = self.state.lock() {
state
.page_instance_dispose_timers
.lock()
.unwrap()
.insert(id.to_string(), tx);
}
let appid = self.appid.clone();
let page_instance_id = id.to_string();
std::mem::drop(crate::executor::spawn(async move {
let sleep = time::sleep(dispose_ttl);
tokio::pin!(sleep);
tokio::pin!(rx);
tokio::select! {
_ = &mut sleep => {}
_ = &mut rx => return,
}
let Some(app) = crate::lxapp::try_get(&appid) else {
return;
};
let Some(id) = PageInstanceId::parse(page_instance_id.clone()) else {
return;
};
if let Err(err) = app.dispose_page_instance_internal(&id, reclaim_reason, false) {
warn!(
"Delayed dispose failed for page instance {}: {}",
page_instance_id, err
)
.with_appid(appid);
}
}));
Ok(())
}
fn refresh_page_instance_dispose_ttl(&self, id: &PageInstanceId) -> Result<(), LxAppError> {
let (lifecycle, dispose_ttl) = {
let state = self.state.lock().unwrap();
let records = state.page_instance_runtime.lock().unwrap();
let record = records.get(id.as_str()).ok_or_else(|| {
LxAppError::ResourceNotFound(format!("page instance id: {}", id.as_str()))
})?;
(record.lifecycle, record.dispose_ttl)
};
if lifecycle != PageInstanceLifecycleState::Hidden {
self.cancel_page_instance_dispose_timer(id);
return Ok(());
}
if let Some(ttl) = dispose_ttl {
self.schedule_page_instance_dispose_timer(id, ttl)?;
} else {
self.cancel_page_instance_dispose_timer(id);
}
Ok(())
}
pub fn shutdown_with_options(&self, skip_hide: bool) -> Result<(), LxAppError> {
self.session.cancel();
self.set_status(LxAppSessionStatus::Closing);
self.cancel_all_page_bridge_work();
self.clear_transient_files();
self.cancel_all_page_instance_dispose_timers();
self.cancel_all_page_resets();
self.close_all_surfaces(CloseReason::AppClosed);
crate::lifecycle::key_events::clear(&self.appid, self.session.id);
if !skip_hide {
let _ = self
.runtime
.hide_lxapp(self.appid.clone(), self.session.id)
.map_err(LxAppError::from);
}
let pages = {
let state = self.state.lock().unwrap();
state
.pages_by_id
.lock()
.unwrap()
.values()
.cloned()
.collect::<Vec<_>>()
};
let page_webviews = pages
.iter()
.map(|page| (page.webtag(), page.webview()))
.collect::<Vec<_>>();
let page_instance_ids = pages
.iter()
.map(|page| page.instance_id_string())
.collect::<Vec<_>>();
crate::view_call::cancel_view_calls_for_page_instances(
&page_instance_ids,
"PageInstance removed while waiting for view response",
);
for page in pages {
page.detach_webview();
}
if let Ok(mut state) = self.state.lock() {
state.pages_by_id.lock().unwrap().clear();
if let Ok(mut pins) = state.path_pins.lock() {
pins.clear();
}
state.page_instance_runtime.lock().unwrap().clear();
state.page_chrome_layouts.clear();
}
for (webtag, webview) in &page_webviews {
if let Some(webview) = webview {
destroy_webview_if_matches(webtag, webview);
}
}
let _ = self.clear_page_stack();
let _ = self.executor.terminate_app_svc(self.clone_arc());
self.app_launch_dispatched.store(false, Ordering::SeqCst);
self.clear_open_region();
Ok(())
}
pub fn shutdown(&self) -> Result<(), LxAppError> {
self.shutdown_with_options(false)
}
fn _new(
appid: String,
runtime: Arc<Platform>,
executor: Arc<LxAppWorkers>,
release_type: Channel,
app_session_class: AppSessionClass,
) -> Self {
let session = LxAppSession::new();
let bundle_source = lxapp_bundle_source_for(&appid).unwrap_or(LxAppBundleSource::Installed);
let release_type = match bundle_source {
LxAppBundleSource::DevPath { .. } => Channel::Draft,
_ => release_type,
};
Self {
appid,
runtime,
lxapp_dir: PathBuf::new(),
bundle_source,
storage_file_path: PathBuf::new(),
user_data_dir: PathBuf::new(),
user_cache_dir: PathBuf::new(),
temp_dir: PathBuf::new(),
temp_cleanup_protection: None,
usercache_cleanup_protection: None,
fingermark: String::new(),
is_home_lxapp: false,
app_session_class,
release_type,
config: Mutex::new(LxAppConfig::default()),
executor,
host_permissions: permissions::HostPermissions::default(),
home_update_check_dispatched: AtomicBool::new(false),
app_launch_dispatched: AtomicBool::new(false),
pending_restart_request: AtomicBool::new(false),
shown: AtomicBool::new(true),
hidden_since: Mutex::new(None),
restart_closing_session: AtomicU64::new(0),
logic_feature_snapshots: Mutex::new(Default::default()),
session,
logic_contexts: tokio::sync::watch::channel(0).0,
admission: OnceLock::new(),
state: Mutex::new(LxAppState::new()),
presentation_open_lock: Mutex::new(()),
page_chrome_mutation_lock: tokio::sync::Mutex::new(()),
self_weak: OnceLock::new(),
resource_grants: OnceLock::new(),
resource_grants_claimed: std::sync::atomic::AtomicBool::new(false),
document_start_scripts: Mutex::new(Vec::new()),
page_scripts: Mutex::new(Vec::new()),
}
}
pub(crate) fn new(
appid: String,
runtime: Arc<Platform>,
executor: Arc<LxAppWorkers>,
release_type: Channel,
) -> Result<Self, LxAppError> {
let mut app = Self::_new(
appid,
runtime,
executor,
release_type,
AppSessionClass::StandardApp,
);
app.setup().inspect_err(|e| {
error!("Setup failed: {}", e).with_appid(&app.appid);
})?;
Ok(app)
}
fn new_as_home(
appid: String,
runtime: Arc<Platform>,
executor: Arc<LxAppWorkers>,
) -> Result<Self, LxAppError> {
let mut app = Self::_new(
appid,
runtime,
executor,
crate::default_channel(),
AppSessionClass::ControlApp,
);
app.is_home_lxapp = true;
app.setup().inspect_err(|e| {
error!("Setup failed for home app: {}", e).with_appid(&app.appid);
})?;
app.state.lock().unwrap().startup_options.path = app.config().get_initial_route();
Ok(app)
}
fn new_control_surface(
appid: String,
runtime: Arc<Platform>,
executor: Arc<LxAppWorkers>,
release_type: Channel,
) -> Result<Self, LxAppError> {
let mut app = Self::_new(
appid,
runtime,
executor,
release_type,
AppSessionClass::ControlSurface,
);
app.setup().inspect_err(|e| {
error!("Setup failed for control surface: {}", e).with_appid(&app.appid);
})?;
Ok(app)
}
#[cfg(test)]
pub(crate) fn new_with_session_class_for_test(
appid: String,
runtime: Arc<Platform>,
executor: Arc<LxAppWorkers>,
class: AppSessionClass,
) -> Result<Self, LxAppError> {
match class {
AppSessionClass::StandardApp => Self::new(appid, runtime, executor, Channel::Release),
AppSessionClass::ControlApp => Self::new_as_home(appid, runtime, executor),
AppSessionClass::ControlSurface => {
Self::new_control_surface(appid, runtime, executor, Channel::Release)
}
}
}
#[cfg(test)]
pub(crate) fn approve_unrestricted_permissions_for_test(&mut self) {
self.host_permissions =
permissions::HostPermissions::start(&self.appid, self.release_type.into(), true);
}
#[cfg(test)]
pub(crate) fn defer_permissions_for_test(&mut self) -> permissions::DeferredGrant {
let (pending, resolver) = permissions::HostPermissions::deferred();
self.host_permissions = pending;
resolver
}
#[cfg(test)]
pub(crate) fn resource_grants_sealed_for_test(&self) -> bool {
self.resource_grants.get().is_some()
}
fn initialize_paths(&mut self) -> Result<(), LxAppError> {
let meta = metadata::get(&self.appid, self.release_type).ok().flatten();
self.fingermark = meta
.as_ref()
.map(|record| record.fingermark.clone())
.unwrap_or_else(|| lxapp_fingermark(&self.appid, self.release_type));
let dir_name = self.fingermark.clone();
let base_dir = self
.runtime
.app_data_dir()
.join(LINGXIA_DIR)
.join(LXAPPS_DIR);
self.lxapp_dir = base_dir.join(&dir_name);
match &self.bundle_source {
LxAppBundleSource::Installed => {
if let Some(install_path) = meta
.as_ref()
.map(|record| record.install_path.trim())
.filter(|path| !path.is_empty())
{
self.lxapp_dir = PathBuf::from(install_path);
}
}
LxAppBundleSource::DevPath { root } => {
info!("Using dev path for lxapp bundle: {}", root.display())
.with_appid(self.appid.clone());
self.lxapp_dir = root.clone();
}
LxAppBundleSource::BuiltinAssets | LxAppBundleSource::Synthetic => {
let usable_install = meta.as_ref().and_then(|record| {
let path = record.install_path.trim();
if path.is_empty() {
return None;
}
let dir = PathBuf::from(path);
dir.join("lxapp.json").is_file().then_some(dir)
});
if matches!(self.bundle_source, LxAppBundleSource::BuiltinAssets)
&& !bundled_lxapp_asset_available(&self.appid)
&& let Some(install_path) = usable_install
{
self.bundle_source = LxAppBundleSource::Installed;
self.lxapp_dir = install_path;
} else {
self.lxapp_dir = self
.runtime
.app_data_dir()
.join(LINGXIA_DIR)
.join("builtin")
.join(&dir_name);
}
}
}
self.storage_file_path = self
.runtime
.app_data_dir()
.join(LINGXIA_DIR)
.join(STORAGE_DIR)
.join(format!("{}.redb", self.fingermark));
let userdata_base_dir = self
.runtime
.app_data_dir()
.join(LINGXIA_DIR)
.join(USER_DATA_DIR);
self.user_data_dir = userdata_base_dir.join(&dir_name);
if !self.user_data_dir.exists() {
std::fs::create_dir_all(&self.user_data_dir).map_err(|e| {
LxAppError::IoError(format!("Failed to create user data directory: {}", e))
})?;
}
let cache_base_dir = self
.runtime
.app_data_dir()
.join(LINGXIA_DIR)
.join(USER_CACHE_DIR);
self.user_cache_dir = cache_base_dir.join(&dir_name);
self.usercache_cleanup_protection = Some(crate::cache::protect_from_cleanup([self
.user_cache_dir
.clone()]));
if !self.user_cache_dir.exists() {
std::fs::create_dir_all(&self.user_cache_dir).map_err(|e| {
LxAppError::IoError(format!("Failed to create cache directory: {}", e))
})?;
}
let temp_base_dir = self
.runtime
.app_cache_dir()
.join(LINGXIA_DIR)
.join(LXAPPS_DIR)
.join(TEMP_DIR)
.join(&dir_name);
let _ = std::fs::create_dir_all(&temp_base_dir);
if let Ok(entries) = std::fs::read_dir(&temp_base_dir) {
for entry in entries.flatten() {
let path = entry.path();
let stale = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name != self.session_id().to_string());
if stale && path.is_dir() && !crate::cache::is_protected_from_cleanup(&path) {
let _ = std::fs::remove_dir_all(path);
}
}
}
self.temp_dir = temp_base_dir.join(self.session_id().to_string());
self.temp_cleanup_protection =
Some(crate::cache::protect_from_cleanup([self.temp_dir.clone()]));
if !self.temp_dir.exists() {
std::fs::create_dir_all(&self.temp_dir).map_err(|e| {
LxAppError::IoError(format!("Failed to create temp directory: {}", e))
})?;
}
Ok(())
}
pub fn load_config(&mut self) -> Result<(), LxAppError> {
self.apply_lxapp_json(true)
}
pub(crate) fn reload_manifest(&self) -> Result<(), LxAppError> {
if matches!(self.bundle_source, LxAppBundleSource::Synthetic) {
return Ok(());
}
self.apply_lxapp_json(false)
}
fn apply_lxapp_json(&self, first_load: bool) -> Result<(), LxAppError> {
let lxapp_json_path = self.lxapp_dir.join("lxapp.json");
info!(
" [{}] Loading lxapp.json from: {}",
self.appid,
lxapp_json_path.display()
);
let app_json = self.read_json("lxapp.json")?;
let config = LxAppConfig::from_value(app_json)
.map_err(|e| LxAppError::InvalidJsonFile(format!("lxapp.json: {}", e)))?;
if !config.appId.is_empty() && config.appId != self.appid {
return Err(LxAppError::InvalidJsonFile(format!(
"lxapp.json appId '{}' does not match the host-selected application '{}'",
config.appId, self.appid
)));
}
if let Err(error) = config.ensure_runtime_satisfies(&self.appid, crate::SDK_RUNTIME_VERSION)
{
error!("Loading despite runtime floor: {}", error).with_appid(self.appid.clone());
}
let mut tabbar = config
.tabBar
.as_ref()
.map(|tabbar| tabbar.with_absolute_paths(&self.lxapp_dir));
if !first_load && let Some(tabbar) = tabbar.as_mut() {
let previous = self.get_tabbar();
if let Some(path) = self.peek_current_page_path() {
if let Some(index) = tabbar.find_index_by_path(&path) {
tabbar.set_selected_index(index);
} else {
tabbar.clear_selected_index();
}
} else if let Some(previous) = previous.as_ref() {
if previous.selected_index < 0 {
tabbar.clear_selected_index();
} else {
tabbar.set_selected_index(previous.selected_index);
}
}
}
let preference = config.appearance;
let resolved = page_chrome::resolve_appearance(preference);
*self
.config
.lock()
.unwrap_or_else(|error| error.into_inner()) = config;
{
let mut state = self.state.lock().unwrap();
state.tabbar = tabbar;
if first_load {
state.appearance = LxAppAppearanceState {
preference,
resolved,
revision: 0,
};
} else {
state.appearance.preference = preference;
state.appearance.resolved = resolved;
}
}
self.runtime
.apply_lxapp_appearance(&self.appid, resolved.is_dark())?;
if first_load {
self.document_start_scripts.lock().unwrap().push(Arc::from(
page_chrome::bootstrap_script(&EffectivePageChromeLayout::default(), resolved),
));
}
Ok(())
}
fn refresh_live_page_config(&self) {
for page in self.live_page_instances() {
page.apply_reloaded_page_json(self);
}
}
fn setup(&mut self) -> Result<(), LxAppError> {
self.initialize_paths()?;
if matches!(self.bundle_source, LxAppBundleSource::Synthetic) {
self.config().logic = Some(LxAppLogicEntry::Enabled(false));
} else {
self.load_config()?;
self.host_permissions = permissions::HostPermissions::start(
&self.appid,
self.release_type.into(),
self.is_home_lxapp && !is_runner(),
);
}
Ok(())
}
pub fn current_version(&self) -> String {
metadata::get(&self.appid, self.release_type)
.ok()
.flatten()
.map(|record| record.version_string())
.filter(|version| !version.is_empty())
.unwrap_or_else(|| DEFAULT_VERSION.to_string())
}
pub fn logic_enabled(&self) -> bool {
self.config().logic_entry().is_some()
}
#[cfg(feature = "js-appservice")]
pub async fn logic_entry_source(&self, ctx: &JSContext) -> JSResult<Option<Source>> {
let Some(entry) = self.config().logic_entry() else {
return Ok(None);
};
if Path::new(&entry).extension().and_then(|ext| ext.to_str()) != Some("js") {
return Err(HostError::new(
rong::error::E_NOT_SUPPORTED,
format!("lxapp logic entry must be a .js file: {}", entry),
)
.into());
}
match &self.bundle_source {
LxAppBundleSource::Installed | LxAppBundleSource::DevPath { .. } => {
let source_path = self.lxapp_dir.join(&entry);
Source::from_path(ctx, &source_path).await.map(Some)
}
LxAppBundleSource::Synthetic => unreachable!(
"synthetic lxapp {} forces logic=false at setup(); logic_entry() must be None",
self.appid
),
LxAppBundleSource::BuiltinAssets => {
let asset_path = format!(
"{}/{}",
self.appid.trim_end_matches('/'),
entry.trim_start_matches('/')
);
let mut reader = self.runtime.read_asset(&asset_path).map_err(|err| {
HostError::new(
rong::error::E_NOT_FOUND,
format!("builtin lxapp logic not found: {} ({})", asset_path, err),
)
})?;
let mut data = Vec::new();
reader.read_to_end(&mut data).map_err(|err| {
HostError::new(
rong::error::E_IO,
format!(
"failed to read builtin lxapp logic: {} ({})",
asset_path, err
),
)
})?;
Ok(Some(Source::from_bytes(data).with_name(asset_path)))
}
}
}
pub fn get_app_orientation(&self) -> OrientationConfig {
let state = self.state.lock().unwrap();
state.orientation_override.unwrap_or_default()
}
pub fn set_app_orientation(&self, orientation: OrientationConfig) {
let orientation = OrientationConfig::normalize(orientation.mode, orientation.rotation);
let mut state = self.state.lock().unwrap();
state.orientation_override = Some(orientation);
}
pub fn get_page_orientation(&self, path: &str) -> OrientationConfig {
let app_orientation = self.get_app_orientation();
let page_override = self
.get_page(path)
.and_then(|page| page.get_orientation_override())
.unwrap_or_default();
page_override.apply(app_orientation)
}
fn read_bytes(&self, relative_path: &str) -> Result<Vec<u8>, LxAppError> {
if matches!(self.bundle_source, LxAppBundleSource::Synthetic) {
return Err(LxAppError::ResourceNotFound(format!(
"{relative_path}: synthetic lxapp host {} has no on-disk content",
self.appid
)));
}
let plugins = self.config().plugins.clone();
let file_path = match crate::plugin::resolve_plugin_resource_path_from_internal_path(
&self.runtime,
&plugins,
relative_path,
)? {
Some(path) => path,
None => {
if matches!(self.bundle_source, LxAppBundleSource::BuiltinAssets) {
let asset_path = format!(
"{}/{}",
self.appid.trim_end_matches('/'),
relative_path.trim_start_matches('/')
);
let mut reader = self.runtime.read_asset(&asset_path).map_err(|e| {
LxAppError::ResourceNotFound(format!(
"{relative_path}:{e} (asset: {asset_path})"
))
})?;
let mut data = Vec::new();
reader.read_to_end(&mut data).map_err(|e| {
LxAppError::ResourceNotFound(format!(
"{relative_path}:{e} (asset: {asset_path})"
))
})?;
return Ok(data);
}
self.lxapp_dir.join(relative_path)
}
};
fs::read(&file_path).map_err(|e| {
LxAppError::ResourceNotFound(format!(
"{}:{} (resolved: {})",
relative_path,
e,
file_path.display()
))
})
}
pub fn resolve_accessible_path(&self, path: &str) -> Result<PathBuf, LxAppError> {
let path = path.trim();
if path.is_empty() {
return Err(LxAppError::ResourceNotFound("empty path".to_string()));
}
if path.starts_with("lx://") {
let lx_uri = uri::LxUri::from_str(path)
.map_err(|e| LxAppError::InvalidParameter(format!("invalid lx uri: {}", e)))?;
return self.resolve_lx_path_uri(&lx_uri);
}
let path_ref = Path::new(path);
if let Some(scheme) = uri::network_scheme(path) {
return Err(LxAppError::InvalidParameter(format!(
"{scheme} URLs are not supported here: download the file first \
(for example with lx.downloadFile) and pass the returned lx:// path"
)));
}
if path_ref
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
|| (!path_ref.is_absolute() && uri::has_invalid_segment(path))
{
return Err(LxAppError::ResourceNotFound(
"directory traversal not allowed".to_string(),
));
}
if !path_ref.is_absolute() && !path.contains(':') {
let rel = path.trim_start_matches('/');
if matches!(self.bundle_source, LxAppBundleSource::BuiltinAssets) {
return self.materialize_builtin_resource(rel);
}
return Ok(self.lxapp_dir.join(rel));
}
let trusted_roots = [
(&self.lxapp_dir, "app bundle"),
(&self.user_data_dir, "user data"),
(&self.user_cache_dir, "user cache"),
(&self.temp_dir, "temp"),
];
let resolved_target = std::fs::canonicalize(path_ref).ok();
for (root, _name) in trusted_roots {
if root.as_os_str().is_empty() {
continue;
}
if path_ref.starts_with(root) {
if let Some(target) = resolved_target.as_ref()
&& let Ok(canonical_root) = std::fs::canonicalize(root)
{
if target.starts_with(&canonical_root) {
return Ok(target.to_path_buf());
}
continue;
}
return Ok(path_ref.to_path_buf());
}
if let (Some(target), Ok(canonical_root)) =
(resolved_target.as_ref(), std::fs::canonicalize(root))
&& target.starts_with(&canonical_root)
{
return Ok(target.to_path_buf());
}
}
Err(LxAppError::ResourceNotFound(format!(
"Access denied: {}",
path
)))
}
fn materialize_builtin_resource(&self, relative: &str) -> Result<PathBuf, LxAppError> {
let data = self.read_bytes(relative)?;
let destination = self.user_cache_dir.join("native-resources").join(relative);
let parent = destination.parent().ok_or_else(|| {
LxAppError::InvalidParameter(format!(
"native resource has no parent: {}",
destination.display()
))
})?;
fs::create_dir_all(parent).map_err(|err| {
LxAppError::IoError(format!("failed to create {}: {err}", parent.display()))
})?;
fs::write(&destination, data).map_err(|err| {
LxAppError::IoError(format!("failed to write {}: {err}", destination.display()))
})?;
Ok(destination)
}
pub fn to_uri(&self, path: &Path) -> Option<uri::LxUri> {
if !self.temp_dir.as_os_str().is_empty() && path.starts_with(&self.temp_dir) {
return self.register_temp_file(path).ok();
}
uri::try_convert_path_to_uri(path, self)
}
fn resolve_lx_path_uri(&self, lx_uri: &uri::LxUri) -> Result<PathBuf, LxAppError> {
let uri = HttpUri::from_str(lx_uri.as_str())
.map_err(|_| LxAppError::InvalidParameter("invalid lx uri".to_string()))?;
if uri.scheme_str() != Some(uri::LX_SCHEME) {
return Err(LxAppError::InvalidParameter(
"invalid lx uri scheme".to_string(),
));
}
match uri.host() {
Some(uri::HOST_TEMP) => {
if uri.query().is_some() {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
let token = uri.path().trim_matches('/');
if token.is_empty() || token.contains('/') || token.contains('\\') {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
self.resolve_transient_file(token).ok_or_else(|| {
LxAppError::ResourceNotFound(format!(
"temporary file grant not found: {}",
lx_uri.as_str()
))
})
}
Some(uri::HOST_USER_CACHE) | Some(uri::HOST_USER_DATA) => {
let base_dir = match uri.host() {
Some(uri::HOST_USER_CACHE) => &self.user_cache_dir,
Some(uri::HOST_USER_DATA) => &self.user_data_dir,
_ => unreachable!(),
};
let decoded_path = uri::decode_lx_path(uri.path());
let rel = decoded_path.trim_matches('/');
if rel.is_empty() {
return Ok(base_dir.clone());
}
if uri::has_invalid_segment(rel) || rel.contains(':') || rel.contains('\\') {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
Ok(base_dir.join(rel))
}
Some(uri::HOST_LXAPP) => {
let decoded_path = uri::decode_lx_path(uri.path());
let raw = decoded_path.trim_start_matches('/');
let (appid, rest) = raw
.split_once('/')
.ok_or_else(|| LxAppError::ResourceNotFound(lx_uri.as_str().to_string()))?;
if appid != self.appid.as_str() {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
let rel = rest.trim_matches('/');
if rel.is_empty() {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
if uri::has_invalid_segment(rel) || rel.contains(':') || rel.contains('\\') {
return Err(LxAppError::ResourceNotFound(lx_uri.as_str().to_string()));
}
if matches!(self.bundle_source, LxAppBundleSource::BuiltinAssets) {
self.materialize_builtin_resource(rel)
} else {
Ok(self.lxapp_dir.join(rel))
}
}
_ => Err(LxAppError::ResourceNotFound(format!(
"unsupported lx uri host: {}",
lx_uri.as_str()
))),
}
}
fn read_text(&self, relative_path: &str) -> Result<String, LxAppError> {
self.read_bytes(relative_path)
.map(|content| String::from_utf8_lossy(&content).to_string())
}
pub(crate) fn read_json(&self, relative_path: &str) -> Result<serde_json::Value, LxAppError> {
self.read_text(relative_path).and_then(|content| {
serde_json::from_str(&content)
.map_err(|_| LxAppError::InvalidJsonFile(relative_path.to_string()))
})
}
pub fn is_opened(&self) -> bool {
matches!(self.status(), LxAppSessionStatus::Opened)
}
pub(crate) fn is_shown(&self) -> bool {
self.shown.load(Ordering::SeqCst)
}
pub(crate) fn mark_hidden(&self) {
if self.shown.swap(false, Ordering::SeqCst)
&& let Ok(mut since) = self.hidden_since.lock()
{
*since = Some(Instant::now());
}
}
pub(crate) fn hidden_since(&self) -> Option<Instant> {
self.hidden_since.lock().ok().and_then(|since| *since)
}
pub(crate) fn document_start_scripts_snapshot(&self) -> Vec<Arc<str>> {
self.document_start_scripts
.lock()
.map(|scripts| scripts.clone())
.unwrap_or_default()
}
pub(crate) fn page_scripts_snapshot(&self) -> Vec<Arc<str>> {
self.page_scripts
.lock()
.map(|scripts| scripts.clone())
.unwrap_or_default()
}
pub fn trusted_network_domains(&self) -> Vec<String> {
self.host_permissions.domains()
}
pub(crate) fn permissions_ready(&self) -> bool {
self.host_permissions.is_ready()
}
pub async fn wait_permissions_ready(&self) {
self.host_permissions.wait_ready().await;
if let Some(app) = self.self_weak.get().and_then(Weak::upgrade) {
crate::host::seal_app_resource_grants(&app);
}
}
pub fn is_domain_allowed(&self, domain: &str) -> bool {
self.host_permissions
.is_domain_allowed(domain, crate::is_dev_session())
}
pub fn has_security_privilege(&self, privilege: &LxAppSecurityPrivilege) -> bool {
self.host_permissions.allows_privilege(privilege.as_str())
}
pub(crate) fn claim_resource_grant_seal(&self) -> bool {
use std::sync::atomic::Ordering;
self.resource_grants_claimed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub(crate) fn resource_grants_claimed(&self) -> bool {
self.resource_grants_claimed
.load(std::sync::atomic::Ordering::Acquire)
}
pub(crate) fn seal_resource_grants(&self, grants: HashSet<crate::host::AppResourceGrant>) {
let _ = self.resource_grants.set(grants);
}
pub fn has_resource_grant(&self, grant: crate::host::AppResourceGrant) -> bool {
matches!(
self.status(),
LxAppSessionStatus::Opening | LxAppSessionStatus::Opened
) && self
.resource_grants
.get()
.is_some_and(|grants| grants.contains(&grant))
}
pub fn get_page(&self, path: &str) -> Option<PageInstance> {
let state = self.state.lock().ok()?;
let pages_by_id = state.pages_by_id.lock().ok()?;
if let Ok(stack) = state.page_stack.lock() {
for id in stack.iter().rev() {
if let Some(page) = pages_by_id.get(id)
&& page.path() == path
{
return Some(page.clone());
}
}
}
if let Ok(pins) = state.path_pins.lock()
&& let Some(id) = pins.get(path)
&& let Some(page) = pages_by_id.get(id)
{
return Some(page.clone());
}
pages_by_id
.values()
.filter(|page| !page.is_isolated() && page.path() == path)
.max_by_key(|page| page.get_last_active_time())
.cloned()
}
pub(crate) fn has_isolated_page(&self, path: &str) -> bool {
let Ok(state) = self.state.lock() else {
return false;
};
let Ok(pages_by_id) = state.pages_by_id.lock() else {
return false;
};
pages_by_id
.values()
.any(|page| page.is_isolated() && page.path() == path)
}
pub(crate) fn pinned_page(&self, path: &str) -> Option<PageInstance> {
let state = self.state.lock().ok()?;
let id = state.path_pins.lock().ok()?.get(path)?.clone();
state.pages_by_id.lock().ok()?.get(&id).cloned()
}
pub(crate) fn most_recent_off_stack_page(&self, path: &str) -> Option<PageInstance> {
let state = self.state.lock().ok()?;
let stack_ids: std::collections::HashSet<String> =
state.page_stack.lock().ok()?.iter().cloned().collect();
state
.pages_by_id
.lock()
.ok()?
.values()
.filter(|page| {
!page.is_isolated()
&& page.path() == path
&& !stack_ids.contains(&page.instance_id_string())
})
.max_by_key(|page| page.get_last_active_time())
.cloned()
}
pub(crate) fn pin_page_path(&self, page: &PageInstance) {
if let Ok(state) = self.state.lock()
&& let Ok(mut pins) = state.path_pins.lock()
{
pins.insert(page.path(), page.instance_id_string());
}
}
pub fn get_page_by_instance_id(&self, id: &PageInstanceId) -> Option<PageInstance> {
self.get_page_by_instance_id_str(id.as_str())
}
pub fn get_page_by_instance_id_str(&self, id: &str) -> Option<PageInstance> {
self.state
.lock()
.unwrap()
.pages_by_id
.lock()
.unwrap()
.get(id)
.cloned()
}
pub(crate) fn cancel_all_page_bridge_work(&self) {
let pages = {
let state = self.state.lock().unwrap();
state
.pages_by_id
.lock()
.unwrap()
.values()
.cloned()
.collect::<Vec<_>>()
};
for page in pages {
page.cancel_bridge_work();
}
}
pub fn page_instance_id_for_path(&self, path: &str) -> Option<String> {
self.get_page(path).map(|page| page.instance_id_string())
}
pub fn initial_route(&self) -> String {
self.config().get_initial_route()
}
pub fn ensure_app_service_running(&self) -> Result<(), LxAppError> {
self.executor.create_app_svc(self.clone_arc())
}
pub fn ensure_app_launch_dispatched(&self) -> Result<(), LxAppError> {
if self
.app_launch_dispatched
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Ok(());
}
let payload = self
.state
.lock()
.unwrap_or_else(|err| err.into_inner())
.startup_options
.launch_options_json();
if let Err(error) = self.appservice_notify(AppServiceEvent::OnLaunch, Some(payload)) {
self.app_launch_dispatched.store(false, Ordering::SeqCst);
return Err(error);
}
self.consume_app_link_scene();
Ok(())
}
pub fn restart_app_service_in_place(&self) -> Result<(), LxAppError> {
self.executor.restart_app_svc(self.clone_arc())?;
self.app_launch_dispatched.store(false, Ordering::SeqCst);
self.ensure_app_launch_dispatched()
}
pub fn reload_current_page(&self) -> Result<(), LxAppError> {
self.current_page()?
.webview()
.ok_or_else(|| LxAppError::WebView("page WebView is not ready".to_string()))?
.reload()
.map_err(LxAppError::from)
}
pub fn restart_in_place(&self) -> Result<(), LxAppError> {
let pending = self.begin_in_place_restart()?;
let appid = self.appid.clone();
std::mem::drop(crate::executor::spawn(async move {
if let Err(error) = Self::finish_in_place_restart(pending).await {
error!("Failed to finish in-place lxapp restart: {error}").with_appid(appid);
}
}));
Ok(())
}
pub async fn restart_in_place_and_wait(&self) -> Result<(), LxAppError> {
let pending = self.begin_in_place_restart()?;
Self::finish_in_place_restart(pending).await
}
fn begin_in_place_restart(&self) -> Result<Vec<PendingPageServiceRestart>, LxAppError> {
self.restart_app_service_in_place()?;
self.reload_manifest()?;
self.refresh_live_page_config();
self.sync_host_ui();
self.recreate_retained_page_services(false)
}
fn recreate_retained_page_services(
&self,
include_isolated: bool,
) -> Result<Vec<PendingPageServiceRestart>, LxAppError> {
let pages: Vec<PageInstance> = {
let state = self
.state
.lock()
.map_err(|_| LxAppError::Runtime("lxapp state lock poisoned".to_string()))?;
let pages_by_id = state
.pages_by_id
.lock()
.map_err(|_| LxAppError::Runtime("page registry lock poisoned".to_string()))?;
pages_by_id.values().cloned().collect()
};
let mut pending = Vec::with_capacity(pages.len());
for page in pages {
if !include_isolated && page.is_isolated() {
continue;
}
{
let _transition = page.reset_transition_guard();
page.prepare_for_service_restart();
}
let (ack_tx, ack_rx) = oneshot::channel::<Result<(), String>>();
self.executor.create_page_svc_with_ack(
self.clone_arc(),
page.path().to_string(),
Some(page.instance_id_string()),
ack_tx,
)?;
pending.push((page, ack_rx));
}
Ok(pending)
}
async fn finish_in_place_restart(
pending: Vec<PendingPageServiceRestart>,
) -> Result<(), LxAppError> {
let mut pages = Vec::with_capacity(pending.len());
for (page, ack_rx) in pending {
let result = ack_rx.await;
let app = page.owning_lxapp();
if app.session.is_cancelled()
|| app
.get_page_by_instance_id_str(&page.instance_id_string())
.is_none()
{
continue;
}
result
.map_err(|_| LxAppError::Runtime("page service restart cancelled".to_string()))?
.map_err(LxAppError::Runtime)?;
pages.push(page);
}
for page in pages {
if page
.owning_lxapp()
.get_page_by_instance_id_str(&page.instance_id_string())
.is_none()
|| page.webview_controller().is_none()
|| page.document_is_departing()
{
continue;
}
page.load_html()?;
}
Ok(())
}
pub(crate) async fn quiesce_for_device_change(&self) -> Result<(), LxAppError> {
let mut contexts = self.logic_contexts.subscribe();
self.executor.terminate_app_svc(self.clone_arc())?;
tokio::time::timeout(Duration::from_secs(10), async {
while *contexts.borrow_and_update() != 0 {
contexts
.changed()
.await
.map_err(|_| LxAppError::Runtime("Logic shutdown observer closed".into()))?;
}
Ok::<_, LxAppError>(())
})
.await
.map_err(|_| LxAppError::Runtime("timed out draining Logic for device change".into()))?
}
pub(crate) async fn resume_after_device_change(&self) -> Result<(), LxAppError> {
if self.session.is_cancelled() {
return Ok(());
}
self.ensure_app_service_running()?;
self.app_launch_dispatched.store(false, Ordering::SeqCst);
self.ensure_app_launch_dispatched()?;
let pending = self.recreate_retained_page_services(true)?;
let pages = pending
.iter()
.map(|(page, _)| page.clone())
.collect::<Vec<_>>();
tokio::time::timeout(Duration::from_secs(30), async {
self.executor
.eval_app_service(self.clone_arc(), "return true;".into(), false)
.await?;
Self::finish_in_place_restart(pending).await?;
loop {
if self.session.is_cancelled() {
return Ok(());
}
let mut ready = true;
for page in &pages {
if self
.get_page_by_instance_id_str(&page.instance_id_string())
.is_none()
|| page.webview_controller().is_none()
|| page.document_is_departing()
{
continue;
}
let state = page.automation_state();
if let Some(error) = state.webview_error {
return Err(LxAppError::Runtime(error));
}
ready &= state.webview_ready
&& state.bridge_ready
&& state.render_state == "finished";
}
if ready {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.map_err(|_| LxAppError::Runtime("timed out restoring pages after device change".into()))?
}
pub fn clear_user_cache(&self) -> Result<(), LxAppError> {
if self.user_cache_dir.exists() {
std::fs::remove_dir_all(&self.user_cache_dir).map_err(|err| {
LxAppError::IoError(format!(
"failed to remove {}: {err}",
self.user_cache_dir.display()
))
})?;
}
std::fs::create_dir_all(&self.user_cache_dir).map_err(|err| {
LxAppError::IoError(format!(
"failed to recreate {}: {err}",
self.user_cache_dir.display()
))
})
}
fn remove_failed_page(&self, page: &PageInstance) {
let id = page.instance_id_string();
if let Ok(state) = self.state.lock() {
let _ =
self.executor
.terminate_page_svc(self.clone_arc(), page.path(), Some(id.clone()));
state.pages_by_id.lock().unwrap().remove(id.as_str());
if let Ok(mut stack) = state.page_stack.lock() {
stack.retain(|entry| entry != &id);
}
if let Ok(mut pins) = state.path_pins.lock() {
pins.retain(|_, pinned| pinned != &id);
}
state
.page_instance_runtime
.lock()
.unwrap()
.remove(id.as_str());
if let Some(cancel) = state
.page_instance_dispose_timers
.lock()
.unwrap()
.remove(id.as_str())
{
let _ = cancel.send(());
}
}
page.cancel_bridge_work();
let webview = page.webview();
page.detach_webview();
if let Some(webview) = webview {
destroy_webview_if_matches(&page.webtag(), &webview);
}
}
pub fn ensure_headless_page_service(&self, path: &str) -> Result<PageInstance, LxAppError> {
if let Some(page) = self.get_page(path) {
return Ok(page);
}
let candidate = PageInstance::new_headless(self.appid.clone(), path.to_string(), self);
let page = {
let state = self.state.lock().unwrap();
let mut pages_by_id = state.pages_by_id.lock().unwrap();
let existing = pages_by_id
.values()
.find(|page| !page.is_isolated() && page.path() == path)
.cloned();
if let Some(page) = existing {
page
} else {
pages_by_id.insert(candidate.instance_id_string(), candidate.clone());
candidate
}
};
self.pin_page_path(&page);
let (ack_tx, ack_rx) = oneshot::channel::<Result<(), String>>();
if let Err(err) = self.executor.create_page_svc_with_ack(
self.clone_arc(),
path.to_string(),
Some(page.instance_id_string()),
ack_tx,
) {
page.mark_webview_ready(Err(err.to_string()));
self.remove_failed_page(&page);
return Err(err);
}
let page_clone = page.clone();
let lxapp = self.clone_arc();
crate::executor::spawn(async move {
let result = match ack_rx.await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(err) => Err(err.to_string()),
};
if result.is_err() {
lxapp.remove_failed_page(&page_clone);
}
page_clone.mark_webview_ready(result);
});
Ok(page)
}
pub fn is_pull_down_refresh_enabled(&self, path: &str) -> bool {
self.get_page(path)
.map(|page| page.is_pull_down_refresh_enabled())
.unwrap_or(false)
}
pub fn get_navbar_state(&self, path: &str) -> NavigationBarState {
let resolved_path = self
.find_page_path(
path.split('?')
.next()
.unwrap_or(path)
.split('#')
.next()
.unwrap_or(path),
)
.unwrap_or_else(|| path.to_string());
self.get_page(path)
.or_else(|| self.get_page(&resolved_path))
.and_then(|page| page.get_navbar_state())
.unwrap_or_default()
}
pub(crate) fn open(&self, options: LxAppStartupOptions) -> Result<(), LxAppError> {
let _admission = self
.admission
.get()
.map(|gate| gate.enter(&self.appid))
.transpose()?;
let _open_guard = self
.presentation_open_lock
.lock()
.unwrap_or_else(|err| err.into_inner());
if self.session.is_retired() {
return Err(LxAppError::Runtime(
"LxApp instance has been terminated".into(),
));
}
self.adopt_host_appearance();
let requested_region = LxAppOpenRegion::from(options.open_mode);
let claimed = self.claim_open_region(requested_region)?;
if !claimed && options.path.is_empty() && options.page.is_none() {
return self.reenter_from_link(options);
}
let began_opening =
self.cas_status(LxAppSessionStatus::Closed, LxAppSessionStatus::Opening);
if self.session.is_cancelled() {
self.session.revive();
}
let result = self.open_claimed(options);
if result.is_err() {
if began_opening {
let _ = self.cas_status(LxAppSessionStatus::Opening, LxAppSessionStatus::Closed);
}
if claimed {
let _ = self.runtime.hide_lxapp(self.appid.clone(), self.session.id);
self.release_open_region(requested_region);
}
}
result
}
fn reenter_from_link(&self, options: LxAppStartupOptions) -> Result<(), LxAppError> {
let current_path = self
.peek_current_page_path()
.unwrap_or_else(|| self.initial_route());
{
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
state.startup_options.query = options.query;
state.startup_options.scene = options.scene;
state.startup_options.link_url = options.link_url;
if state.startup_options.path.is_empty() {
state.startup_options.path = current_path.clone();
}
}
let (current_appid, _, _) = get_current_lxapp();
if current_appid != self.appid {
let page = self.get_or_create_page(¤t_path);
let title = self.listing_name();
let stored = self
.state
.lock()
.unwrap_or_else(|err| err.into_inner())
.startup_options
.clone();
#[cfg(target_os = "windows")]
if !matches!(
stored.open_mode,
lingxia_platform::traits::app_runtime::LxAppOpenMode::Panel
) {
self.set_active_main();
}
self.runtime.show_lxapp(
self.appid.clone(),
title,
current_path,
page.webtag().key().to_string(),
self.session.id,
stored.open_mode,
stored.panel_id.clone(),
)?;
} else {
self.runtime.request_lxapp_main_activation(&self.appid);
self.emit_app_show(true);
}
Ok(())
}
fn emit_app_show(&self, already_open: bool) {
let options = self
.state
.lock()
.unwrap_or_else(|err| err.into_inner())
.startup_options
.clone();
let mut args = options.launch_options_value();
if let serde_json::Value::Object(map) = &mut args {
map.insert(
"source".to_string(),
serde_json::to_value(crate::lifecycle::AppServiceEventSource::Lxapp)
.unwrap_or_else(|_| serde_json::Value::String("lxapp".to_string())),
);
map.insert(
"reason".to_string(),
serde_json::to_value(if already_open {
crate::lifecycle::AppServiceEventReason::SwitchBack
} else {
crate::lifecycle::AppServiceEventReason::Open
})
.unwrap_or_else(|_| serde_json::Value::String("unknown".to_string())),
);
}
let _ = self.appservice_notify(AppServiceEvent::OnShow, Some(args.to_string()));
self.consume_app_link_scene();
}
pub(crate) fn consume_app_link_scene(&self) {
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
if state.startup_options.scene == Scene::AppLink {
state.startup_options.scene = Scene::System;
state.startup_options.link_url.clear();
}
}
fn open_claimed(&self, options: LxAppStartupOptions) -> Result<(), LxAppError> {
if self.logic_enabled() && !crate::js_appservice_supported() {
return Err(LxAppError::UnsupportedOperation(
"this host app was built without JS AppService runtime".to_string(),
));
}
let mut startup_options = options;
let raw_url = startup_options.resolved_url(self)?;
startup_options.page = None;
let resolved = crate::route::resolve_route(self, &raw_url).unwrap_or_else(|e| {
error!("Failed to resolve startup url '{}': {}", raw_url, e)
.with_appid(self.appid.clone());
crate::route::ResolvedRoute {
original: raw_url.clone(),
query: None,
target: crate::route::RouteTarget::Normal {
path: raw_url.clone(),
},
}
});
startup_options.path = resolved.internal_path();
if startup_options.query.is_empty()
&& let Some(query) = resolved.query.clone()
{
startup_options.query = query;
}
self.state.lock().unwrap().startup_options = startup_options.clone();
self.executor.create_app_svc(self.clone_arc())?;
let page = self.get_or_create_page(&startup_options.path);
page.set_query(startup_options.query.clone());
let title = self.listing_name();
#[cfg(target_os = "windows")]
let is_panel = matches!(
startup_options.open_mode,
lingxia_platform::traits::app_runtime::LxAppOpenMode::Panel
);
#[cfg(target_os = "windows")]
{
let surface = if is_panel {
PresentationKind::Panel
} else {
PresentationKind::Window
};
let query = (!startup_options.query.is_empty())
.then(|| PageQueryInput::Raw(startup_options.query.clone()));
self.create_page_instance(
PageOwner::Scene(SceneId("system".to_string())),
PageTarget::Path(startup_options.path.clone()),
query,
surface,
None,
)?;
if !is_panel {
self.set_active_main();
}
}
self.runtime.show_lxapp(
self.appid.clone(),
title,
startup_options.path.clone(),
page.webtag().key().to_string(),
self.session.id,
startup_options.open_mode,
startup_options.panel_id.clone(),
)?;
#[cfg(target_os = "windows")]
if !is_panel {
self.sync_host_ui();
}
Ok(())
}
fn claim_open_region(&self, requested: LxAppOpenRegion) -> Result<bool, LxAppError> {
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
match state.open_region {
None => {
state.open_region = Some(requested);
Ok(true)
}
Some(current) if current == requested => Ok(false),
Some(current) => Err(LxAppError::SurfaceConflict(format!(
"lxapp '{}' is already open as {}; close it before opening as {}",
self.appid,
current.as_str(),
requested.as_str()
))),
}
}
fn release_open_region(&self, expected: LxAppOpenRegion) {
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
if state.open_region == Some(expected) {
state.open_region = None;
}
}
pub(crate) fn clear_open_region(&self) {
self.state
.lock()
.unwrap_or_else(|err| err.into_inner())
.open_region = None;
}
fn current_open_region(&self) -> Option<LxAppOpenRegion> {
self.state
.lock()
.unwrap_or_else(|err| err.into_inner())
.open_region
}
pub fn open_panel_id(&self) -> Option<String> {
let state = self.state.lock().unwrap_or_else(|error| error.into_inner());
(state.open_region == Some(LxAppOpenRegion::Aside))
.then(|| state.startup_options.panel_id.trim().to_string())
.filter(|panel_id| !panel_id.is_empty())
}
pub fn navigate_to(
&self,
appid: String,
options: LxAppStartupOptions,
) -> Result<(), LxAppError> {
if let Some(manager) = get_lxapps_manager() {
manager.cancel_delayed_destroy(&appid);
if manager.is_lxapp_stack_full() {
warn!(
"LxApp navigation stack is full (capacity: {}). Cannot navigate to app: {}",
get_num_workers(),
appid
);
return Ok(());
}
let app = manager.ensure_lxapp(appid.clone(), options.release_type)?;
app.open(options)?;
}
Ok(())
}
pub fn navigate_back(&self) -> Result<(), LxAppError> {
self.runtime
.hide_lxapp(self.appid.clone(), self.session.id)?;
Ok(())
}
pub fn restart(&self) -> Result<(), LxAppError> {
let _admission = self
.admission
.get()
.map(|gate| gate.enter(&self.appid))
.transpose()?;
let from_session = self.session.id;
let current_status = self.status();
match current_status {
LxAppSessionStatus::Opening
| LxAppSessionStatus::Closed
| LxAppSessionStatus::Closing => {
self.pending_restart_request.store(true, Ordering::SeqCst);
return Ok(());
}
LxAppSessionStatus::Opened => {}
LxAppSessionStatus::Restarting => return Ok(()),
}
if !self.cas_status(LxAppSessionStatus::Opened, LxAppSessionStatus::Restarting) {
let current = self.status();
if current == LxAppSessionStatus::Opening {
self.pending_restart_request.store(true, Ordering::SeqCst);
}
return Ok(());
}
self.pending_restart_request.store(false, Ordering::SeqCst);
self.restart_closing_session
.store(from_session, Ordering::SeqCst);
if let Err(e) = self.runtime.hide_lxapp(self.appid.clone(), from_session) {
error!(
"Restart transition: failed to request close for session {}: {}",
from_session, e
)
.with_appid(self.appid.clone());
}
let relaunch_path = self.config().get_initial_route();
let (open_mode, panel_id) = {
let state = self.state.lock().unwrap_or_else(|error| error.into_inner());
(
state.startup_options.open_mode,
state.startup_options.panel_id.clone(),
)
};
let appid = self.appid.clone();
let release_type = self.release_type;
std::mem::drop(crate::executor::spawn(async move {
let wait_deadline = Instant::now() + Duration::from_millis(1500);
loop {
let Some(current) = crate::lxapp::try_get(&appid) else {
break;
};
if current.session_id() != from_session {
return;
}
if current.status() == LxAppSessionStatus::Closed {
break;
}
if Instant::now() >= wait_deadline {
warn!(
"Restart transition: close wait timeout for session {}, forcing recreate",
from_session
)
.with_appid(appid.clone());
break;
}
time::sleep(Duration::from_millis(20)).await;
}
if let Some(manager) = get_lxapps_manager() {
let new_app = match manager.recreate_lxapp(appid.clone(), release_type) {
Ok(app) => app,
Err(e) => {
error!("Failed to recreate lxapp after restart: {}", e)
.with_appid(appid.clone());
return;
}
};
let options = LxAppStartupOptions::new(&relaunch_path)
.set_release_type(release_type)
.set_open_mode(open_mode)
.set_panel_id(panel_id);
if let Err(e) = new_app.open(options) {
error!("Failed to start lxapp after restart: {}", e);
}
}
}));
Ok(())
}
pub fn get_lxapp_info(&self) -> config::LxAppInfo {
self.config().get_lxapp_info(self.release_type.as_str())
}
pub fn listing_name(&self) -> String {
registry::display_name(&self.appid).unwrap_or_else(|| self.get_lxapp_info().app_name)
}
}
pub(crate) fn lxapp_fingermark(lxappid: &str, release_type: Channel) -> String {
let device_fp = match crate::provider::get_provider().get_fingerprint() {
Ok(fp) => fp,
Err(e) => {
warn!("Device fingerprint unavailable: {}", e);
String::new()
}
};
let combined = format!("{}|{}|{}", lxappid, release_type.as_str(), device_fp);
let mut hasher = DefaultHasher::new();
combined.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
impl Drop for LxApp {
fn drop(&mut self) {
if self.is_home_lxapp {
return;
}
info!("Dropping LxApp").with_appid(self.appid.clone());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LxAppOpenRegion {
Main,
Aside,
}
impl LxAppOpenRegion {
pub const fn as_str(self) -> &'static str {
match self {
Self::Main => "main",
Self::Aside => "aside",
}
}
}
impl From<lingxia_platform::traits::app_runtime::LxAppOpenMode> for LxAppOpenRegion {
fn from(mode: lingxia_platform::traits::app_runtime::LxAppOpenMode) -> Self {
match mode {
lingxia_platform::traits::app_runtime::LxAppOpenMode::Panel => Self::Aside,
lingxia_platform::traits::app_runtime::LxAppOpenMode::Normal => Self::Main,
}
}
}
pub fn open_region(appid: &str) -> Option<LxAppOpenRegion> {
let app = runtime_registry::try_get(appid)?;
app.current_open_region()
}
#[cfg(test)]
mod startup_cancellation_tests {
use super::LxAppSession;
#[test]
fn shutdown_interrupts_pending_startup_and_drops_its_waiter() {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let session = LxAppSession::new();
let (sender, receiver) = tokio::sync::oneshot::channel::<()>();
let executed = std::cell::Cell::new(false);
let startup = session.while_alive(async {
receiver.await.unwrap();
executed.set(true);
});
tokio::pin!(startup);
assert!(futures::poll!(&mut startup).is_pending());
session.cancel();
assert_eq!(startup.await, None);
assert!(!executed.get());
assert!(sender.send(()).is_err());
});
}
#[test]
fn reopening_a_closed_instance_rearms_it_and_leaves_the_closed_run_cancelled() {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let session = LxAppSession::new();
let (sender, receiver) = tokio::sync::oneshot::channel::<()>();
let closing = session.while_alive(receiver);
tokio::pin!(closing);
assert!(futures::poll!(&mut closing).is_pending());
session.cancel();
session.revive();
assert!(!session.is_cancelled());
assert_eq!(closing.await, None);
assert!(sender.send(()).is_err());
assert_eq!(session.while_alive(async { 42 }).await, Some(42));
});
}
#[test]
fn cancellation_wins_over_ready_startup_and_does_not_affect_replacement() {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let old = LxAppSession::new();
old.cancel();
let executed = std::cell::Cell::new(false);
assert_eq!(
old.while_alive(async {
executed.set(true);
})
.await,
None
);
assert!(!executed.get());
let replacement = LxAppSession::new();
assert_eq!(replacement.while_alive(async { 42 }).await, Some(42));
assert_ne!(old.id, replacement.id);
});
}
#[test]
fn cancellation_during_source_resolution_discards_the_result() {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let session = LxAppSession::new();
assert_eq!(session.while_alive(async {}).await, Some(()));
let source = session
.while_alive(async {
session.cancel();
"must not evaluate"
})
.await;
assert_eq!(source, None);
});
}
}
#[cfg(test)]
mod delayed_destroy_tests {
use super::*;
use tokio::sync::oneshot::error::TryRecvError;
fn class_test_runtime() -> Arc<Platform> {
let root = std::env::temp_dir().join(format!("lingxia-lxapp-class-{}", Uuid::new_v4()));
Arc::new(
Platform::new(
root.join("data").display().to_string(),
root.join("cache").display().to_string(),
"en-US".to_string(),
)
.expect("test platform"),
)
}
#[test]
fn capsule_closed_app_is_recallable_but_retired_app_gets_a_new_session() {
#[cfg(target_vendor = "apple")]
let _host = crate::apple_host_stubs::headless_lifecycle();
let appid = format!("app.lingxia.retirement.{}", Uuid::new_v4());
register_synthetic_lxapp(appid.clone());
let runtime = class_test_runtime();
let manager = LxApps::new((*runtime).clone(), LxAppWorkers::init(1), 2);
let app = manager
.ensure_lxapp(appid.clone(), Channel::Release)
.unwrap();
app.set_status(LxAppSessionStatus::Opened);
crate::delegate::LxAppDelegate::on_lxapp_closed(&app, app.session_id());
assert_eq!(app.status(), LxAppSessionStatus::Closed);
assert!(!app.session.is_cancelled(), "capsule close preserves Logic");
let recalled = manager
.ensure_lxapp(appid.clone(), Channel::Release)
.unwrap();
assert!(Arc::ptr_eq(&app, &recalled));
let retired = manager.retire_lxapp(&appid).unwrap();
assert!(Arc::ptr_eq(&app, &retired));
assert!(
app.session.is_cancelled(),
"Closed must not skip force shutdown"
);
assert!(!manager.lxapps.contains_key(&appid));
app.session.revive();
assert!(
app.session.is_cancelled(),
"retirement is permanent even through a retained Arc"
);
assert!(app.open(LxAppStartupOptions::default()).is_err());
let replacement = manager
.ensure_lxapp(appid.clone(), Channel::Release)
.unwrap();
assert_ne!(replacement.session_id(), app.session_id());
replacement.set_status(LxAppSessionStatus::Opened);
crate::delegate::LxAppDelegate::on_lxapp_closed(&replacement, app.session_id());
assert_eq!(
replacement.status(),
LxAppSessionStatus::Opened,
"old native close must not affect replacement"
);
manager.retire_lxapp(&appid).unwrap();
}
#[test]
fn app_session_class_is_constructor_assigned_and_preserved_on_rebuild() {
let appid = format!("app.lingxia.class-test.{}", Uuid::new_v4());
register_synthetic_lxapp(appid.clone());
let runtime = class_test_runtime();
let workers = LxAppWorkers::init(1);
let manager = LxApps::new((*runtime).clone(), workers, 1);
let standard = manager
.ensure_lxapp(appid.clone(), Channel::Release)
.expect("standard app");
assert!(!manager.session_transition_locks.contains_key(&appid));
let control = manager
.initialize_home_lxapp(appid.clone())
.expect("control app");
assert!(!manager.session_transition_locks.contains_key(&appid));
assert_eq!(standard.appid, control.appid);
assert_eq!(standard.bundle_source, control.bundle_source);
assert_eq!(standard.app_session_class(), AppSessionClass::StandardApp);
assert!(!standard.is_control_app());
assert_eq!(control.app_session_class(), AppSessionClass::ControlApp);
assert!(control.is_control_app());
assert_eq!(
control.state.lock().unwrap().startup_options.path,
control.config().get_initial_route()
);
let rebuilt = manager
.recreate_lxapp(appid.clone(), Channel::Release)
.expect("rebuilt control app");
assert!(!manager.session_transition_locks.contains_key(&appid));
assert_eq!(rebuilt.app_session_class(), AppSessionClass::ControlApp);
assert!(rebuilt.is_control_app());
let ensured = manager
.ensure_lxapp(appid, Channel::Release)
.expect("ordinary ensure after rebuild");
assert_eq!(ensured.app_session_class(), AppSessionClass::ControlApp);
}
#[test]
fn control_app_class_follows_the_home_identity_not_the_live_session() {
const HOME: &str = "app.lingxia.home";
const GUEST: &str = "app.lingxia.guest";
assert_eq!(
LxApps::session_class_for_identity(HOME, Some(HOME), None),
AppSessionClass::ControlApp
);
assert_eq!(
LxApps::session_class_for_identity(
HOME,
Some(HOME),
Some(AppSessionClass::StandardApp)
),
AppSessionClass::ControlApp
);
assert_eq!(
LxApps::session_class_for_identity(
GUEST,
Some(HOME),
Some(AppSessionClass::ControlSurface)
),
AppSessionClass::ControlSurface
);
assert_eq!(
LxApps::session_class_for_identity(GUEST, Some(HOME), None),
AppSessionClass::StandardApp
);
assert_eq!(
LxApps::session_class_for_identity(HOME, None, None),
AppSessionClass::StandardApp
);
}
#[test]
fn control_surface_is_not_home_and_keeps_its_class_on_ordinary_ensure() {
let appid = format!("app.lingxia.surface-test.{}", Uuid::new_v4());
register_synthetic_lxapp(appid.clone());
let runtime = class_test_runtime();
let workers = LxAppWorkers::init(1);
let manager = LxApps::new((*runtime).clone(), workers.clone(), 1);
let surface = Arc::new(
LxApp::new_with_session_class_for_test(
appid.clone(),
runtime.clone(),
workers,
AppSessionClass::ControlSurface,
)
.expect("control surface"),
);
surface.bind_arc();
assert_eq!(surface.app_session_class(), AppSessionClass::ControlSurface);
assert!(!surface.is_control_app());
assert!(!surface.is_home_lxapp);
manager.lxapps.insert(appid.clone(), surface.clone());
let ensured = manager
.ensure_lxapp(appid.clone(), Channel::Release)
.expect("ordinary ensure keeps the live surface");
assert!(Arc::ptr_eq(&ensured, &surface));
let rebuilt = manager
.recreate_lxapp(appid, Channel::Release)
.expect("rebuilt surface");
assert_eq!(rebuilt.app_session_class(), AppSessionClass::ControlSurface);
assert!(!rebuilt.is_home_lxapp);
}
#[test]
fn control_classes_refuse_unsealed_identities() {
let appid = format!("app.lingxia.unsealed-test.{}", Uuid::new_v4());
register_synthetic_lxapp(appid.clone());
let runtime = class_test_runtime();
let manager = LxApps::new((*runtime).clone(), LxAppWorkers::init(1), 1);
assert!(
manager
.ensure_lxapp_for_native_control(appid.clone(), Channel::Release)
.is_err()
);
assert!(
manager
.ensure_lxapp_for_control_surface(appid.clone(), Channel::Release)
.is_err()
);
assert!(!manager.lxapps.contains_key(&appid));
assert!(!manager.session_transition_locks.contains_key(&appid));
assert!(control_surface_bundle_source_allowed(Some(
&LxAppBundleSource::BuiltinAssets
)));
assert!(!control_surface_bundle_source_allowed(Some(
&LxAppBundleSource::Installed
)));
assert!(!control_surface_bundle_source_allowed(Some(
&LxAppBundleSource::Synthetic
)));
assert!(!control_surface_bundle_source_allowed(None));
}
#[test]
fn session_transition_locks_are_reused_while_live_and_removed_when_idle() {
let runtime = class_test_runtime();
let manager = LxApps::new((*runtime).clone(), LxAppWorkers::init(1), 1);
let appid = format!("app.lingxia.transition-lock-test.{}", Uuid::new_v4());
let first = manager.session_transition_lock(&appid);
manager.cleanup_session_transition_lock(&appid);
assert!(manager.session_transition_locks.contains_key(&appid));
let second = manager.session_transition_lock(&appid);
assert!(Arc::ptr_eq(&first, &second));
drop(second);
drop(first);
manager.cleanup_session_transition_lock(&appid);
assert!(!manager.session_transition_locks.contains_key(&appid));
}
#[test]
fn first_timer_is_registered_and_replacement_is_cancelled() {
let mut pending = HashMap::new();
let (first_cancel, mut first_rx) = oneshot::channel();
replace_pending_destroy(
&mut pending,
"app".to_string(),
PendingDestroy {
generation: 1,
cancel: first_cancel,
},
);
assert_eq!(pending.get("app").map(|entry| entry.generation), Some(1));
assert!(matches!(first_rx.try_recv(), Err(TryRecvError::Empty)));
let (second_cancel, mut second_rx) = oneshot::channel();
replace_pending_destroy(
&mut pending,
"app".to_string(),
PendingDestroy {
generation: 2,
cancel: second_cancel,
},
);
assert_eq!(first_rx.try_recv(), Ok(()));
assert!(matches!(second_rx.try_recv(), Err(TryRecvError::Empty)));
assert_eq!(pending.get("app").map(|entry| entry.generation), Some(2));
}
#[test]
fn only_current_timer_can_claim_delayed_destroy() {
let mut pending = HashMap::new();
let (cancel, _rx) = oneshot::channel();
replace_pending_destroy(
&mut pending,
"app".to_string(),
PendingDestroy {
generation: 2,
cancel,
},
);
assert!(!claim_pending_destroy(&mut pending, "app", 1));
assert!(pending.contains_key("app"));
assert!(claim_pending_destroy(&mut pending, "app", 2));
assert!(!pending.contains_key("app"));
}
#[test]
fn lru_eviction_skips_home_and_stale_entries() {
let stack = vec![
"home".to_string(),
"stale".to_string(),
"app-b".to_string(),
"app-c".to_string(),
];
assert_eq!(
first_evictable_appid(&stack, |appid| matches!(appid, "app-b" | "app-c")),
Some("app-b".to_string())
);
assert_eq!(first_evictable_appid(&stack, |_| false), None);
}
#[test]
fn session_status_compare_exchange_has_one_winner() {
const CONTENDERS: usize = 16;
let session = Arc::new(LxAppSession::new());
session.set_status(LxAppSessionStatus::Opened);
let barrier = Arc::new(std::sync::Barrier::new(CONTENDERS));
let handles = (0..CONTENDERS)
.map(|_| {
let session = session.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
session.cas_status(LxAppSessionStatus::Opened, LxAppSessionStatus::Restarting)
})
})
.collect::<Vec<_>>();
let winners = handles
.into_iter()
.map(|handle| usize::from(handle.join().unwrap()))
.sum::<usize>();
assert_eq!(winners, 1);
assert_eq!(session.status(), LxAppSessionStatus::Restarting);
}
}
#[cfg(test)]
mod manifest_reload_tests {
use super::*;
use crate::page::PageInstance;
fn write_manifest(root: &std::path::Path, appid: &str, body: &str) {
std::fs::write(root.join("lxapp.json"), body.replace("APPID", appid)).unwrap();
}
fn test_runtime() -> Arc<Platform> {
let root = std::env::temp_dir().join(format!("lingxia-lxapp-reload-{}", Uuid::new_v4()));
let data = root.join("data");
let cache = root.join("cache");
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(&cache).unwrap();
Arc::new(
Platform::new(
data.display().to_string(),
cache.display().to_string(),
"en-US".to_string(),
)
.expect("test platform"),
)
}
fn dev_app(root: &std::path::Path, appid: &str) -> Arc<LxApp> {
register_dev_bundle_source(appid, root);
let runtime = test_runtime();
let workers = LxAppWorkers::init(1);
let app =
LxApp::new(appid.to_string(), runtime, workers, Channel::Draft).expect("dev lxapp");
let app = Arc::new(app);
app.bind_arc();
app
}
#[test]
fn reload_manifest_picks_up_pages_and_tabbar() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let appid = format!("app.lingxia.reload-pages.{}", Uuid::new_v4());
write_manifest(
root,
&appid,
r#"{
"appId": "APPID",
"appName": "Reload",
"version": "1.0.0",
"security": {"network":{"trustedDomains":[]},"privileges":[]},
"pages": [
{"name": "home", "path": "pages/home/index"},
{"name": "list", "path": "pages/list/index"}
]
}"#,
);
let app = dev_app(root, &appid);
assert_eq!(
app.page_entries()
.into_iter()
.map(|page| page.name)
.collect::<Vec<_>>(),
["home", "list"]
);
assert!(app.get_tabbar().is_none());
write_manifest(
root,
&appid,
r#"{
"appId": "APPID",
"appName": "Reload",
"version": "1.0.0",
"security": {"network":{"trustedDomains":[]},"privileges":[]},
"pages": [
{"name": "home", "path": "pages/home/index"},
{"name": "list", "path": "pages/list/index"},
{"name": "settings", "path": "pages/settings/index"}
],
"tabBar": {
"items": [
{"page": "home", "text": "Home"},
{"page": "settings", "text": "Settings"}
]
}
}"#,
);
app.reload_manifest().expect("reload manifest");
assert_eq!(
app.page_entries()
.into_iter()
.map(|page| page.name)
.collect::<Vec<_>>(),
["home", "list", "settings"]
);
assert_eq!(
app.find_page_path_by_name("settings").as_deref(),
Some("pages/settings/index")
);
let tabbar = app.get_tabbar().expect("tabbar after reload");
assert_eq!(tabbar.items.len(), 2);
assert_eq!(tabbar.items[0].page, "home");
assert_eq!(tabbar.items[1].page, "settings");
assert_eq!(tabbar.items[1].text.as_deref(), Some("Settings"));
write_manifest(
root,
&appid,
r#"{
"appId": "APPID",
"appName": "Reload",
"version": "1.0.0",
"security": {"network":{"trustedDomains":[]},"privileges":[]},
"pages": [
{"name": "home", "path": "pages/home/index"},
{"name": "list", "path": "pages/list/index"},
{"name": "settings", "path": "pages/settings/index"}
],
"tabBar": {
"items": [
{"page": "home", "text": "Home"},
{"page": "settings", "text": "Settings2"}
]
}
}"#,
);
app.with_tabbar_mut(|tabbar| {
tabbar.set_selected_index(1);
});
assert_eq!(app.get_tabbar().expect("tabbar").selected_index, 1);
app.reload_manifest().expect("reload tabBar text");
let tabbar = app.get_tabbar().expect("tabbar after text reload");
assert_eq!(tabbar.items[1].text.as_deref(), Some("Settings2"));
assert_eq!(tabbar.selected_index, 1);
}
#[test]
fn reload_applies_page_json_navigation_style() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let appid = format!("app.lingxia.reload-navbar.{}", Uuid::new_v4());
write_manifest(
root,
&appid,
r#"{
"appId": "APPID",
"appName": "Reload",
"version": "1.0.0",
"security": {"network":{"trustedDomains":[]},"privileges":[]},
"pages": [{"name": "home", "path": "pages/home/index"}]
}"#,
);
let page_dir = root.join("pages/home");
std::fs::create_dir_all(&page_dir).unwrap();
std::fs::write(
page_dir.join("index.json"),
r#"{"navigationStyle":"default"}"#,
)
.unwrap();
let app = dev_app(root, &appid);
let page =
PageInstance::new_headless(app.appid.clone(), "pages/home/index".to_string(), &app);
assert!(
page.get_page_state()
.expect("page state")
.navbar_state
.show_navbar
);
std::fs::write(
page_dir.join("index.json"),
r#"{"navigationStyle":"custom"}"#,
)
.unwrap();
page.apply_reloaded_page_json(&app);
assert!(
!page
.get_page_state()
.expect("page state")
.navbar_state
.show_navbar
);
}
}