lingxia-lxapp 0.18.0

LxApp (lightweight application) container and runtime for LingXia framework
Documentation
//! Runtime bootstrap flow (`init`) and startup housekeeping.

use super::*;

/// Prepares the base directory structure for lxapps
fn prepare_directory_structure(runtime: Arc<Platform>) -> Result<(), LxAppError> {
    let data_dir = runtime.app_data_dir();
    let cache_dir = runtime.app_cache_dir();

    // Create required directories
    let dirs = [
        data_dir.join(LINGXIA_DIR).join(LXAPPS_DIR),
        data_dir.join(LINGXIA_DIR).join(PLUGINS_DIR),
        data_dir.join(LINGXIA_DIR).join(USER_DATA_DIR),
        data_dir.join(LINGXIA_DIR).join(USER_CACHE_DIR),
        lingxia_transfer::dir(&runtime.app_data_dir()),
        data_dir.join(LINGXIA_DIR).join(STORAGE_DIR),
        cache_dir.join(LINGXIA_DIR).join(LXAPPS_DIR).join(TEMP_DIR),
    ];

    for dir in &dirs {
        fs::create_dir_all(dir)?;
    }

    let metadata_path = data_dir.join(LINGXIA_DIR).join(LXAPPS_DB_FILE);
    metadata::init(metadata_path)
}

fn spawn_cache_cleanup(runtime: Arc<Platform>) {
    let max_bytes = lingxia_app_context::cache_max_size_bytes();
    if max_bytes == 0 {
        info!("Cache cleanup disabled (cacheMaxSizeMB=0)");
        return;
    }

    std::mem::drop(crate::executor::spawn(async move {
        let cache_base_dir = runtime
            .app_data_dir()
            .join(LINGXIA_DIR)
            .join(USER_CACHE_DIR);
        cleanup_cache_base_dir(&cache_base_dir, max_bytes);
    }));
}

fn cleanup_cache_base_dir(cache_base_dir: &Path, max_bytes: u64) {
    if let Ok(entries) = fs::read_dir(cache_base_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if file_type.is_dir() && !file_type.is_symlink() {
                lingxia_service::storage::cleanup_cache_dir(&path, max_bytes);
            }
        }
    }
}

fn installed_home_version(
    appid: &str,
    release_type: Channel,
) -> Result<Option<Version>, LxAppError> {
    let Some(record) = metadata::get(appid, release_type)? else {
        return Ok(None);
    };

    let install_path = Path::new(&record.install_path);
    let manifest_path = install_path.join("lxapp.json");
    if record.install_path.trim().is_empty() || !install_path.is_dir() || !manifest_path.is_file() {
        let _ = metadata::remove(appid, release_type);
        return Ok(None);
    }

    let manifest = fs::read_to_string(&manifest_path).map_err(LxAppError::from)?;
    let manifest_json: serde_json::Value = serde_json::from_str(&manifest)
        .map_err(|e| LxAppError::InvalidJsonFile(format!("{}: {}", manifest_path.display(), e)))?;
    let config = LxAppConfig::from_value(manifest_json)
        .map_err(|e| LxAppError::InvalidJsonFile(format!("{}: {}", manifest_path.display(), e)))?;
    if config.get_initial_route().trim().is_empty() {
        warn!(
            "Installed home lxapp manifest has no pages: {}; reinstalling bundled home app",
            manifest_path.display()
        )
        .with_appid(appid.to_string());
        let _ = metadata::remove(appid, release_type);
        return Ok(None);
    }

    Ok(Some(Version {
        major: record.version.major,
        minor: record.version.minor,
        patch: record.version.patch,
    }))
}

/// Whether this process is an active `lingxia dev` session: a dev websocket is
/// configured either via the `LINGXIA_DEV_WS_URL` env var or `app.json`'s
/// `dev_ws_url` (written by `lingxia dev`). Drives dev-only behaviour such as
/// forcing a bundled-asset refresh and enabling WebView debugging.
pub fn dev_session_active() -> bool {
    let env_active = std::env::var("LINGXIA_DEV_WS_URL")
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false);
    if env_active {
        return true;
    }
    lingxia_app_context::app_config()
        .and_then(|config| config.dev_ws_url.as_deref())
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false)
}

/// Whether this process is the LingXia Runner (the `lingxia dev` device
/// simulator), which sets `LINGXIA_RUNNER` on its child process. Unlike a real
/// host app in dev mode, the Runner lacks host-declared surfaces such as the
/// terminal; the bridge exposes this so apps can hide those affordances.
static RUNNER_HOST: AtomicBool = AtomicBool::new(false);

/// Identify a Runner host before SDK initialization, including direct launches.
/// This designation is process-wide and cannot be revoked by the environment.
pub fn register_runner_host() {
    RUNNER_HOST.store(true, Ordering::Release);
}

pub fn runner_active() -> bool {
    runner_identity(
        RUNNER_HOST.load(Ordering::Acquire),
        std::env::var("LINGXIA_RUNNER").ok().as_deref(),
    )
}

fn runner_identity(registered: bool, marker: Option<&str>) -> bool {
    registered || marker.is_some_and(|value| !value.trim().is_empty())
}

#[cfg(test)]
mod runner_identity_tests {
    use super::runner_identity;

    #[test]
    fn native_runner_identity_survives_missing_or_empty_environment() {
        assert!(runner_identity(true, None));
        assert!(runner_identity(true, Some("")));
        assert!(runner_identity(true, Some("  ")));
        assert!(runner_identity(false, Some("1")));
        assert!(!runner_identity(false, None));
        assert!(!runner_identity(false, Some("")));
    }
}

/// Initialize the LxApps singleton using the host app configuration from app-context.
pub fn init(runtime: Platform) -> Result<Option<String>, LxAppError> {
    init_with_native_authority(runtime, None, None, Box::new(|_, _, _, _| Ok(())))
        .map(|(home_app_id, _, _)| home_app_id)
}

type NativeAuthorityInstaller = dyn FnOnce(
        crate::NativeControlPlaneAuthority,
        crate::NativeControlPlaneAuthority,
        crate::NativeControlPlaneAuthority,
        crate::terminal_automation::TerminalAutomationAuthority,
    ) -> Result<(), LxAppError>
    + 'static;

/// Private Rust-ABI edge used by the `lingxia` platform facade. Keeping this
/// symbol out of the Rust public API prevents a linked extension from entering
/// bootstrap, obtaining its token, or pre-installing a resolver through safe
/// Rust. The ABI edge never returns the native token.
#[unsafe(export_name = "lingxia_lxapp_platform_bootstrap_v1")]
pub(crate) extern "Rust" fn platform_bootstrap(
    runtime: Platform,
    app_grant_resolver: Option<Arc<crate::host::AppResourceGrantResolver>>,
    devtools_grant_resolver: Option<Arc<crate::host::DevtoolsResourceGrantResolver>>,
    install_authorities: Box<NativeAuthorityInstaller>,
) -> Result<
    (
        Option<String>,
        crate::terminal_automation::TerminalAutomationAuthority,
        crate::NativeControlPlaneAuthority,
    ),
    LxAppError,
> {
    init_with_native_authority(
        runtime,
        app_grant_resolver,
        devtools_grant_resolver,
        install_authorities,
    )
}

fn init_with_native_authority(
    runtime: Platform,
    app_grant_resolver: Option<Arc<crate::host::AppResourceGrantResolver>>,
    devtools_grant_resolver: Option<Arc<crate::host::DevtoolsResourceGrantResolver>>,
    install_authorities: Box<NativeAuthorityInstaller>,
) -> Result<
    (
        Option<String>,
        crate::terminal_automation::TerminalAutomationAuthority,
        crate::NativeControlPlaneAuthority,
    ),
    LxAppError,
> {
    // Set up panic hook to capture panic information
    std::panic::set_hook(Box::new(|panic_info| {
        let location = panic_info
            .location()
            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
            .unwrap_or_else(|| "unknown location".to_string());
        let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
            s.to_string()
        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
            s.clone()
        } else {
            "unknown panic message".to_string()
        };

        error!("RUST PANIC: {} at {}", message, location);
    }));

    // LingXia bootstrap pre-registers these for static validation. Keep the
    // idempotent call here for embedders that initialize lingxia-lxapp directly.
    crate::host::register_builtin_routes();

    let runtime_arc = Arc::new(runtime.clone());
    super::runtime_registry::set_runtime(runtime_arc.clone());
    let native_token = crate::terminal_automation::NativeHostRuntimeToken::new(&runtime_arc);
    crate::host::install_bootstrap_resource_grant_resolvers(
        app_grant_resolver,
        devtools_grant_resolver,
    )
    .map_err(|message| LxAppError::Runtime(message.to_string()))?;
    let terminal_authority =
        crate::terminal_automation::TerminalAutomationAuthority::for_native_runtime(&native_token);
    install_authorities(
        crate::NativeControlPlaneAuthority::for_native_runtime(&native_token),
        crate::NativeControlPlaneAuthority::for_native_runtime(&native_token),
        crate::NativeControlPlaneAuthority::for_native_runtime(&native_token),
        terminal_authority.clone(),
    )?;
    let browser_registration_authority =
        crate::NativeControlPlaneAuthority::for_native_runtime(&native_token);

    // Prepare directory structure
    if let Err(e) = prepare_directory_structure(runtime_arc.clone()) {
        error!("Failed to prepare directory structure: {}", e);
        return Err(e);
    }

    let num_workers = get_num_workers();
    let executor = LxAppWorkers::init(num_workers);

    // Hosts may launch native or web content without a home lxapp. Initialize
    // the manager first so lxapps opened later still have the complete runtime.
    let lxapps_manager = Arc::new(LxApps::new(runtime, executor.clone(), num_workers));
    if let Err(e) = super::runtime_registry::set_lxapps_manager(lxapps_manager.clone()) {
        error!("{}", e);
        return Err(LxAppError::Runtime(e.to_string()));
    }

    let (Some(home_app_id), Some(home_app_version)) = (
        lingxia_app_context::home_app_id(),
        lingxia_app_context::home_app_version(),
    ) else {
        info!("LxApps initialized without a home lxapp");
        spawn_cache_cleanup(runtime_arc);
        return Ok((None, terminal_authority, browser_registration_authority));
    };
    let home_app_id = home_app_id.to_string();

    let bundled_home_version = match Version::parse(home_app_version) {
        Ok(version) => version,
        Err(e) => {
            error!(
                "Invalid bundled home lxapp version '{}': {}",
                home_app_version, e
            )
            .with_appid(home_app_id.clone());
            return Err(LxAppError::InvalidParameter(format!(
                "invalid bundled home lxapp version '{home_app_version}': {e}"
            )));
        }
    };
    let home_channel = crate::default_channel();
    let installed_home_version = match installed_home_version(&home_app_id, home_channel) {
        Ok(version) => version,
        Err(e) => {
            warn!("Failed to inspect installed home lxapp version: {}", e)
                .with_appid(home_app_id.clone());
            None
        }
    };

    let dev_session_active = dev_session_active();
    let should_reinstall_home = dev_session_active
        || installed_home_version
            .as_ref()
            .map(|installed| installed < &bundled_home_version)
            .unwrap_or(true);

    // In dev mode the home app is served directly from its `DevPath` root (the
    // freshly-built `dist`), not from bundled assets — so it must not be
    // installed/reinstalled from the runner's assets (which don't contain the
    // dev lxapp). `LxApp::new_as_home` loads it straight from the dev root.
    let home_is_dev_sourced = matches!(
        super::lxapp_bundle_source_for(&home_app_id),
        Some(super::LxAppBundleSource::DevPath { .. })
    );

    if home_is_dev_sourced {
        info!("Home lxapp is dev-sourced; serving from dev root, skipping bundled-asset install")
            .with_appid(home_app_id.clone());
    } else if should_reinstall_home {
        let reason = if dev_session_active {
            "dev session active; refreshing from bundled assets".to_string()
        } else {
            match installed_home_version {
                None => "home lxapp is not installed or install is invalid".to_string(),
                Some(installed) => format!(
                    "bundled version {} is newer than installed {}",
                    bundled_home_version, installed
                ),
            }
        };
        info!("Installing home lxapp from bundled assets: {}", reason)
            .with_appid(home_app_id.clone());
        if let Err(e) = crate::update::UpdateManager::install_from_assets(
            runtime_arc.clone(),
            &home_app_id,
            home_app_version,
        ) {
            error!("Failed to install home LxApp: {}", e);
            return Err(e);
        }
    } else {
        let has_pending_home_update = metadata::downloaded_get(&home_app_id, home_channel)
            .map(|record| record.is_some())
            .unwrap_or(false);
        if has_pending_home_update {
            match crate::update::UpdateManager::apply_downloaded_update(
                runtime_arc.clone(),
                &home_app_id,
                home_channel,
            ) {
                Ok(()) => {
                    info!("Applied pending home lxapp update before startup")
                        .with_appid(home_app_id.clone());
                }
                Err(e) => {
                    warn!("Failed to apply pending home lxapp update: {}", e)
                        .with_appid(home_app_id.clone());
                }
            }
        }
    }
    // Create the home LxApp instance (loads lxapp.json once) under the same
    // per-app transition lock used by ordinary opens and restarts.
    let home_app = match lxapps_manager.initialize_home_lxapp(home_app_id.clone()) {
        Ok(app) => app,
        Err(e) => {
            error!("Failed to setup home LxApp: {}", e).with_appid(home_app_id.clone());
            return Err(e);
        }
    };

    // Pre-create JS worker for home lxapp when enabled. Native-only hosts skip this path.
    if let Err(e) = home_app.executor.create_app_svc(home_app.clone()) {
        error!("Failed to trigger home app service: {}", e).with_appid(home_app_id.clone());
    }

    info!("LxApps initialized successfully");

    spawn_cache_cleanup(runtime_arc.clone());
    Ok((
        Some(home_app_id),
        terminal_authority,
        browser_registration_authority,
    ))
}