lingxia-logic 0.9.0

JavaScript runtime for LingXia lightweight application
use crate::i18n::{
    js_error_from_business_code_with_detail, js_error_from_lxapp_error,
    js_error_from_platform_error, js_invalid_parameter_error,
};
use lingxia_platform::traits::share::{
    ShareRequest, ShareResult as PlatformShareResult, ShareService,
};
use lxapp::{LxApp, LxAppError, lx};
use rong::{IntoJSObj, JSArray, JSContext, JSFunc, JSObject, JSResult, JSValue};
use serde_json::Value;

struct JSShareOptions {
    title: Option<String>,
    text: Option<String>,
    page: Option<JSSharePage>,
    files: Vec<String>,
}

struct JSSharePage {
    query: Option<JSObject>,
}

#[derive(IntoJSObj)]
struct JSShareResult {
    completed: Option<bool>,
}

async fn share(ctx: JSContext, options: JSValue) -> JSResult<JSShareResult> {
    let lxapp = LxApp::from_ctx(&ctx)?;
    let options = parse_share_options(options)?;
    let title = non_empty(options.title);
    let text = non_empty(options.text);
    if !options.files.is_empty() && options.page.is_some() {
        return Err(js_invalid_parameter_error(
            "share page and files cannot be used together",
        ));
    }
    if !options.files.is_empty() && text.is_some() {
        return Err(js_invalid_parameter_error(
            "share files does not support text; share text separately",
        ));
    }
    let files = resolve_share_files(&lxapp, options.files)?;
    let url = options
        .page
        .map(|page| build_page_share_url(&lxapp, page))
        .transpose()?;

    if title.is_none() && text.is_none() && url.is_none() && files.is_empty() {
        return Err(js_invalid_parameter_error(
            "share requires title, text, page, or files",
        ));
    }

    let result = lxapp
        .runtime
        .share(ShareRequest {
            title,
            text,
            url,
            files,
        })
        .await
        .map_err(|e| js_error_from_platform_error(&e))?;

    Ok(result.into())
}

fn parse_share_options(value: JSValue) -> JSResult<JSShareOptions> {
    let Some(obj) = value.into_object() else {
        return Err(js_invalid_parameter_error(
            "share options must be an object",
        ));
    };
    if get_present_property(&obj, "query").is_some() {
        return Err(js_invalid_parameter_error(
            "share page query must be nested under page.query",
        ));
    }
    Ok(JSShareOptions {
        title: read_optional_string(&obj, "title")?,
        text: read_optional_string(&obj, "text")?,
        page: read_optional_page_target(&obj)?,
        files: read_optional_files(&obj)?,
    })
}

fn non_empty(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let trimmed = value.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_string())
    })
}

fn build_page_share_url(lxapp: &LxApp, page: JSSharePage) -> JSResult<String> {
    let current_page = lxapp.peek_current_page().ok_or_else(|| {
        js_error_from_lxapp_error(&LxAppError::Runtime("No current page found".to_string()))
    })?;
    let page_url = append_query(current_page, page.query.as_ref())
        .map_err(|e| js_error_from_lxapp_error(&e))?;
    lxapp
        .ensure_page_exists(&page_url)
        .map_err(|e| js_error_from_lxapp_error(&e))?;

    let host = first_app_link_host()?;
    let (path, raw_query) = lxapp::startup::split_path_query(&page_url);
    validate_share_page_query(raw_query.as_deref())?;
    let mut pairs = vec![
        format!("appId={}", urlencoding::encode(&lxapp.appid)),
        format!("path={}", urlencoding::encode(path.trim_start_matches('/'))),
    ];
    if let Some(env_version) = app_link_env_version(lxapp) {
        pairs.push(format!("envVersion={env_version}"));
    }
    if let Some(raw_query) = raw_query.filter(|value| !value.is_empty()) {
        pairs.push(raw_query);
    }
    Ok(format!("https://{host}/lxapp/open?{}", pairs.join("&")))
}

fn get_present_property(obj: &JSObject, field: &str) -> Option<JSValue> {
    obj.get::<_, JSValue>(field)
        .ok()
        .filter(|value| !value.is_undefined() && !value.is_null())
}

fn read_optional_string(obj: &JSObject, field: &str) -> JSResult<Option<String>> {
    let Some(value) = get_present_property(obj, field) else {
        return Ok(None);
    };
    if !value.is_string() {
        return Err(js_invalid_parameter_error(format!(
            "share {field} must be a string"
        )));
    }
    value
        .to_rust::<String>()
        .map(Some)
        .map_err(|_| js_invalid_parameter_error(format!("share {field} must be a string")))
}

fn read_optional_page_target(obj: &JSObject) -> JSResult<Option<JSSharePage>> {
    let Some(value) = get_present_property(obj, "page") else {
        return Ok(None);
    };

    if value.is_boolean() {
        let enabled = value
            .to_rust::<bool>()
            .map_err(|_| js_invalid_parameter_error("share page must be true or an object"))?;
        return Ok(enabled.then_some(JSSharePage { query: None }));
    }

    let Some(page_obj) = value.into_object() else {
        return Err(js_invalid_parameter_error(
            "share page must be true or an object",
        ));
    };
    if get_present_property(&page_obj, "path").is_some()
        || get_present_property(&page_obj, "name").is_some()
    {
        return Err(js_invalid_parameter_error(
            "share page only supports the current page; use page.query for query parameters",
        ));
    }
    let query = read_optional_query(&page_obj)?;
    Ok(Some(JSSharePage { query }))
}

fn read_optional_query(obj: &JSObject) -> JSResult<Option<JSObject>> {
    let Some(value) = get_present_property(obj, "query") else {
        return Ok(None);
    };
    value
        .into_object()
        .map(Some)
        .ok_or_else(|| js_invalid_parameter_error("share page.query must be an object"))
}

fn read_optional_files(obj: &JSObject) -> JSResult<Vec<String>> {
    let Some(value) = get_present_property(obj, "files") else {
        return Ok(Vec::new());
    };
    let values: Vec<JSValue> = value
        .into_object()
        .and_then(JSArray::from_object)
        .ok_or_else(|| js_invalid_parameter_error("share files must be an array"))?
        .iter_values()?
        .collect::<JSResult<Vec<_>>>()?;
    values
        .into_iter()
        .enumerate()
        .map(|(index, value)| {
            if !value.is_string() {
                return Err(js_invalid_parameter_error(format!(
                    "share files[{index}] must be a string"
                )));
            }
            value.to_rust::<String>().map_err(|_| {
                js_invalid_parameter_error(format!("share files[{index}] must be a string"))
            })
        })
        .collect()
}

fn append_query(path: String, query: Option<&JSObject>) -> Result<String, LxAppError> {
    let Some(query) = query else {
        return Ok(path);
    };
    let query_json = query.to_json_string().map_err(LxAppError::from)?;
    let query: Value = serde_json::from_str(&query_json)?;
    lxapp::append_page_query(path, &query).map_err(LxAppError::InvalidParameter)
}

fn validate_share_page_query(raw_query: Option<&str>) -> JSResult<()> {
    let Some(raw_query) = raw_query else {
        return Ok(());
    };
    for pair in raw_query.split('&').filter(|pair| !pair.is_empty()) {
        let raw_key = pair.split_once('=').map(|(key, _)| key).unwrap_or(pair);
        let key = urlencoding::decode(raw_key)
            .map(|value| value.to_string())
            .map_err(|_| js_invalid_parameter_error("share page query has invalid encoding"))?;
        if matches!(key.as_str(), "appId" | "appid" | "path" | "envVersion") {
            return Err(js_invalid_parameter_error(format!(
                "share page query key is reserved: {key}"
            )));
        }
    }
    Ok(())
}

fn app_link_env_version(lxapp: &LxApp) -> Option<&'static str> {
    match lxapp.release_type() {
        lxapp::ReleaseType::Release => None,
        lxapp::ReleaseType::Preview => Some("preview"),
        lxapp::ReleaseType::Developer => Some("develop"),
    }
}

fn first_app_link_host() -> JSResult<String> {
    let config = lingxia_app_context::app_config().ok_or_else(share_page_unsupported_error)?;
    let host = config
        .app_links
        .as_ref()
        .and_then(|links| links.hosts.iter().find(|host| !host.trim().is_empty()))
        .map(|host| host.trim().to_string())
        .ok_or_else(share_page_unsupported_error)?;
    Ok(host)
}

fn share_page_unsupported_error() -> rong::RongJSError {
    js_error_from_business_code_with_detail(
        6000,
        "share page requires appLinks.hosts configuration",
    )
}

fn resolve_share_files(lxapp: &LxApp, files: Vec<String>) -> JSResult<Vec<String>> {
    files
        .into_iter()
        .map(|file| {
            let path = file.trim();
            if path.is_empty() {
                return Err(js_invalid_parameter_error("share file path is required"));
            }
            if is_platform_file_reference(path) && lxapp.has_transient_file_reference(path) {
                return Ok(path.to_string());
            }
            if is_platform_file_reference(path) {
                return Err(js_invalid_parameter_error(
                    "share file path must be returned by lx.chooseFile/lx.chooseMedia",
                ));
            }
            if !path.starts_with("lx://") {
                return Err(js_invalid_parameter_error(
                    "share file path must be returned by lx.chooseFile/lx.chooseMedia",
                ));
            }
            let resolved = lxapp
                .resolve_accessible_path(path)
                .map_err(|e| js_error_from_lxapp_error(&e))?;
            let metadata = std::fs::metadata(&resolved)
                .map_err(|e| js_invalid_parameter_error(e.to_string()))?;
            if !metadata.is_file() {
                return Err(js_invalid_parameter_error(format!(
                    "share file path is not a file: {}",
                    resolved.display()
                )));
            }
            Ok(resolved.to_string_lossy().to_string())
        })
        .collect()
}

fn is_platform_file_reference(path: &str) -> bool {
    let Some((scheme, _)) = path.split_once(':') else {
        return false;
    };
    matches!(
        scheme.to_ascii_lowercase().as_str(),
        "content" | "datashare" | "file"
    )
}

impl From<PlatformShareResult> for JSShareResult {
    fn from(value: PlatformShareResult) -> Self {
        Self {
            completed: value.completed,
        }
    }
}

pub(crate) fn init(ctx: &JSContext) -> JSResult<()> {
    let share_func = JSFunc::new(ctx, |ctx, options| async move { share(ctx, options).await })?;
    lx::register_js_api(ctx, "share", share_func)?;
    Ok(())
}