lingxia-webview 0.6.3

WebView abstraction layer for LingXia framework (Android, iOS, HarmonyOS)
use crate::WebViewScriptError;
use serde::Deserialize;
use serde_json::Value;

#[cfg(any(all(feature = "webview-input", target_os = "macos"), test))]
pub(crate) const INPUT_HELPER_BOOTSTRAP: &str = r#"
(function() {
    if (window.__LingXiaInput) return;

    function findElement(selector, index) {
        if (typeof selector !== 'string' || selector.trim() === '') {
            return { el: null, count: 0 };
        }
        try {
            const nodes = Array.from(document.querySelectorAll(selector));
            const resolvedIndex = Number.isInteger(index) && index >= 0 ? index : 0;
            return { el: nodes[resolvedIndex] || null, count: nodes.length, index: resolvedIndex };
        } catch (_err) {
            return { el: null, count: 0 };
        }
    }

    function isEditable(el) {
        if (!el) return false;
        if (el.isContentEditable) return true;
        const tag = (el.tagName || '').toLowerCase();
        if (tag === 'textarea') {
            return !el.disabled && !el.readOnly;
        }
        if (tag === 'input') {
            const type = (el.type || 'text').toLowerCase();
            const blocked = new Set(['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']);
            return !el.disabled && !el.readOnly && !blocked.has(type);
        }
        return false;
    }

    function rectPayload(el) {
        const rect = el.getBoundingClientRect();
        const visible = rect.width > 0 &&
            rect.height > 0 &&
            rect.bottom > 0 &&
            rect.right > 0 &&
            rect.top < window.innerHeight &&
            rect.left < window.innerWidth;
        return {
            left: rect.left,
            top: rect.top,
            width: rect.width,
            height: rect.height,
            centerX: rect.left + (rect.width / 2),
            centerY: rect.top + (rect.height / 2),
            viewportWidth: window.innerWidth,
            viewportHeight: window.innerHeight,
            visible,
            editable: isEditable(el)
        };
    }

    function elementResult(selector, index) {
        const found = findElement(selector, index);
        const el = found.el;
        if (!el) {
            return { ok: false, error: `Element not found: ${selector}`, count: found.count, index: found.index || 0 };
        }
        return { ok: true, count: found.count, index: found.index || 0, ...rectPayload(el) };
    }

    window.__LingXiaInput = {
        query_box(selector, index) {
            return elementResult(selector, index);
        },
        is_visible(selector, index) {
            const result = elementResult(selector, index);
            return result.ok ? { ok: true, visible: result.visible } : result;
        },
        is_editable(selector, index) {
            const result = elementResult(selector, index);
            return result.ok ? { ok: true, editable: result.editable } : result;
        }
    };
})();
"#;

#[derive(Debug, Deserialize)]
struct EvalEnvelope {
    ok: bool,
    #[serde(default)]
    value: Value,
    #[serde(default)]
    error: Option<String>,
}

pub(crate) fn build_wrapped_eval_script(js: &str) -> Result<String, WebViewScriptError> {
    let quoted = serde_json::to_string(js)
        .map_err(|err| WebViewScriptError::Platform(format!("Failed to encode script: {err}")))?;
    Ok(format!(
        "(function(){{try{{const __lxValue=(0,eval)({quoted});return JSON.stringify({{ok:true,value:__lxValue===undefined?null:__lxValue}});}}catch(e){{return JSON.stringify({{ok:false,error:String(e)}});}}}})()"
    ))
}

pub(crate) fn parse_wrapped_eval_result(raw: &str) -> Result<Value, WebViewScriptError> {
    let envelope: EvalEnvelope = serde_json::from_str(raw).map_err(|err| {
        WebViewScriptError::Platform(format!(
            "Failed to decode JavaScript result envelope: {err}"
        ))
    })?;
    if envelope.ok {
        Ok(envelope.value)
    } else {
        Err(WebViewScriptError::Js(envelope.error.unwrap_or_else(
            || "JavaScript evaluation failed".to_string(),
        )))
    }
}

#[cfg(all(feature = "webview-input", target_os = "macos"))]
pub(crate) fn build_helper_invocation(expr: &str) -> String {
    format!(
        "(() => {{ {} return {}; }})()",
        INPUT_HELPER_BOOTSTRAP, expr
    )
}

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

    #[test]
    fn wrapped_eval_script_quotes_source() {
        let script = build_wrapped_eval_script("1 + 1").unwrap();
        assert!(script.contains("eval"));
        assert!(script.contains("\"1 + 1\""));
    }

    #[test]
    fn parse_wrapped_eval_result_decodes_success() {
        let value = parse_wrapped_eval_result(r#"{"ok":true,"value":{"answer":42}}"#).unwrap();
        assert_eq!(value["answer"], 42);
    }

    #[test]
    fn parse_wrapped_eval_result_maps_js_error() {
        let err = parse_wrapped_eval_result(r#"{"ok":false,"error":"boom"}"#).unwrap_err();
        assert!(matches!(err, WebViewScriptError::Js(message) if message == "boom"));
    }

    #[test]
    fn helper_invocation_bootstraps_namespace() {
        #[cfg(not(all(feature = "webview-input", target_os = "macos")))]
        return;
        let script = build_helper_invocation("window.__LingXiaInput.query_box(\"#app\")");
        assert!(script.contains("__LingXiaInput"));
        assert!(script.contains("query_box"));
    }
}