lingxia 0.5.1

LingXia - Cross-platform LxApp (lightweight application) framework for Android, iOS, and HarmonyOS
use std::path::PathBuf;
use std::sync::OnceLock;

use lingxia_platform::traits::app_runtime::AppRuntime;

const LXAPP_PATH_ENV: &str = "LINGXIA_LXAPP_PATH";

#[derive(Debug, serde::Deserialize)]
#[allow(non_snake_case)]
struct LxAppManifest {
    appId: String,
    version: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LxAppDevIdentity {
    pub appid: String,
    pub version: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LxAppDevConfig {
    pub root: PathBuf,
    pub identity: LxAppDevIdentity,
}

static LXAPP_DEV_CONFIG: OnceLock<LxAppDevConfig> = OnceLock::new();

pub fn install_lxapp_dev_config(config: LxAppDevConfig) -> bool {
    if let Some(existing) = LXAPP_DEV_CONFIG.get() {
        if existing == &config {
            return true;
        }
        log::warn!(
            "Lxapp dev config already set for appid={}, refusing conflicting appid={}",
            existing.identity.appid,
            config.identity.appid
        );
        return false;
    }

    match LXAPP_DEV_CONFIG.set(config.clone()) {
        Ok(()) => {
            log::info!(
                "Installed explicit lxapp dev config: appid={}, version={}, root={}",
                config.identity.appid,
                config.identity.version,
                config.root.display()
            );
            true
        }
        Err(_) => {
            log::warn!("Lxapp dev config already set");
            false
        }
    }
}

pub(crate) fn lxapp_dev_config() -> Option<&'static LxAppDevConfig> {
    LXAPP_DEV_CONFIG.get()
}

fn resolve_runnable_lxapp_path(path: &std::path::Path) -> PathBuf {
    let dist_path = path.join("dist");
    if dist_path.join("lxapp.json").exists() {
        return dist_path;
    }
    path.to_path_buf()
}

fn read_lxapp_manifest(path: &std::path::Path) -> Result<LxAppManifest, String> {
    let manifest_path = path.join("lxapp.json");
    let content = std::fs::read_to_string(&manifest_path)
        .map_err(|e| format!("failed to read {}: {}", manifest_path.display(), e))?;
    let manifest: LxAppManifest = serde_json::from_str(&content)
        .map_err(|e| format!("invalid {}: {}", manifest_path.display(), e))?;
    let appid = manifest.appId.trim();
    if appid.is_empty() {
        return Err(format!(
            r#""appId" must not be empty in {}"#,
            manifest_path.display()
        ));
    }
    let version = manifest.version.trim();
    if version.is_empty() {
        return Err(format!(
            r#""version" must not be empty in {}"#,
            manifest_path.display()
        ));
    }
    Ok(LxAppManifest {
        appId: appid.to_string(),
        version: version.to_string(),
    })
}

pub fn install_lxapp_dev_config_from_env() -> bool {
    let Ok(raw_path) = std::env::var(LXAPP_PATH_ENV) else {
        return false;
    };

    let path = raw_path.trim();
    if path.is_empty() {
        log::warn!("{LXAPP_PATH_ENV} is set but empty; ignoring");
        return false;
    }

    let root = resolve_runnable_lxapp_path(&PathBuf::from(path));
    if !root.exists() {
        log::warn!("{LXAPP_PATH_ENV} path does not exist: {}", root.display());
        return false;
    }
    if !root.join("logic.js").exists() {
        log::warn!(
            "{LXAPP_PATH_ENV} logic.js not found in {} (continuing; build output may be incomplete)",
            root.display()
        );
    }

    let manifest = match read_lxapp_manifest(&root) {
        Ok(manifest) => manifest,
        Err(err) => {
            log::warn!(
                "Failed to initialize lxapp dev config from {}={}: {}",
                LXAPP_PATH_ENV,
                path,
                err
            );
            return false;
        }
    };

    install_lxapp_dev_config(LxAppDevConfig {
        root,
        identity: LxAppDevIdentity {
            appid: manifest.appId,
            version: manifest.version,
        },
    })
}

fn build_host_app_config(
    runtime: &lingxia_platform::Platform,
    dev_config: &LxAppDevConfig,
) -> lingxia_app_context::AppConfig {
    let product_name = runtime
        .get_app_identifier()
        .ok()
        .filter(|value: &String| !value.trim().is_empty())
        .unwrap_or_else(|| "LingXia Host".to_string());

    lingxia_app_context::AppConfig {
        product_name,
        product_version: env!("CARGO_PKG_VERSION").to_string(),
        lingxia_id: None,
        api_server: None,
        home_lxapp_appid: dev_config.identity.appid.clone(),
        home_lxapp_version: dev_config.identity.version.clone(),
        cache_max_age_days: 7,
        cache_max_size_mb: 1024,
        panels: None,
    }
}

pub(crate) fn load_host_app_config(
    runtime: &std::sync::Arc<lingxia_platform::Platform>,
    load_bundled: impl FnOnce(
        &std::sync::Arc<lingxia_platform::Platform>,
    ) -> Option<lingxia_app_context::AppConfig>,
) -> Option<lingxia_app_context::AppConfig> {
    let Some(dev_config) = lxapp_dev_config() else {
        return load_bundled(runtime);
    };

    let mut app_config = match runtime.read_asset("app.json") {
        Ok(_) => load_bundled(runtime)?,
        Err(lingxia_platform::error::PlatformError::AssetNotFound(path)) if path == "app.json" => {
            log::info!(
                "Bootstrapping host in explicit lxapp dev mode using host defaults for {}",
                dev_config.identity.appid
            );
            build_host_app_config(runtime.as_ref(), dev_config)
        }
        Err(e) => {
            log::error!("Failed to read app.json: {}", e);
            return None;
        }
    };
    app_config.home_lxapp_appid = dev_config.identity.appid.clone();
    app_config.home_lxapp_version = dev_config.identity.version.clone();
    Some(app_config)
}

pub(crate) fn register_bundle_source_override() {
    let Some(dev_config) = lxapp_dev_config() else {
        return;
    };
    lxapp::register_dev_bundle_source(dev_config.identity.appid.clone(), dev_config.root.clone());
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn read_lxapp_manifest_valid() {
        let tmp = tempfile::tempdir().unwrap();
        fs::write(
            tmp.path().join("lxapp.json"),
            r#"{"appId":"demo","version":"1.0.0"}"#,
        )
        .unwrap();
        let manifest = read_lxapp_manifest(tmp.path()).unwrap();
        assert_eq!(manifest.appId, "demo");
        assert_eq!(manifest.version, "1.0.0");
    }

    #[test]
    fn read_lxapp_manifest_rejects_empty_appid() {
        let tmp = tempfile::tempdir().unwrap();
        fs::write(
            tmp.path().join("lxapp.json"),
            r#"{"appId":"","version":"1.0.0"}"#,
        )
        .unwrap();
        let err = read_lxapp_manifest(tmp.path()).unwrap_err();
        assert!(err.contains("appId"), "unexpected error: {err}");
    }

    #[test]
    fn read_lxapp_manifest_rejects_empty_version() {
        let tmp = tempfile::tempdir().unwrap();
        fs::write(
            tmp.path().join("lxapp.json"),
            r#"{"appId":"demo","version":""}"#,
        )
        .unwrap();
        let err = read_lxapp_manifest(tmp.path()).unwrap_err();
        assert!(err.contains("version"), "unexpected error: {err}");
    }

    #[test]
    fn read_lxapp_manifest_rejects_malformed_json() {
        let tmp = tempfile::tempdir().unwrap();
        fs::write(tmp.path().join("lxapp.json"), "not json").unwrap();
        assert!(read_lxapp_manifest(tmp.path()).is_err());
    }

    #[test]
    fn read_lxapp_manifest_rejects_missing_file() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(read_lxapp_manifest(tmp.path()).is_err());
    }

    #[test]
    fn resolve_runnable_lxapp_path_prefers_dist() {
        let tmp = tempfile::tempdir().unwrap();
        let dist = tmp.path().join("dist");
        fs::create_dir_all(&dist).unwrap();
        fs::write(dist.join("lxapp.json"), "{}").unwrap();
        assert_eq!(resolve_runnable_lxapp_path(tmp.path()), dist);
    }

    #[test]
    fn resolve_runnable_lxapp_path_falls_back_when_dist_missing_manifest() {
        let tmp = tempfile::tempdir().unwrap();
        let dist = tmp.path().join("dist");
        fs::create_dir_all(&dist).unwrap();
        assert_eq!(
            resolve_runnable_lxapp_path(tmp.path()),
            tmp.path().to_path_buf()
        );
    }
}