use crate::WebViewScriptError;
use serde::Deserialize;
use serde_json::Value;
#[cfg(any(
target_os = "android",
all(target_os = "linux", target_env = "ohos"),
test
))]
use std::collections::hash_map::RandomState;
#[cfg(any(
target_os = "android",
all(target_os = "linux", target_env = "ohos"),
test
))]
use std::hash::{BuildHasher, Hash, Hasher};
#[cfg(all(feature = "webview-input", target_os = "macos"))]
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>,
}
fn looks_like_function_body(src: &str) -> bool {
let trimmed = src.trim_start();
trimmed.starts_with("const ")
|| trimmed.starts_with("let ")
|| trimmed.starts_with("var ")
|| trimmed.starts_with("if ")
|| trimmed.starts_with("for ")
|| trimmed.starts_with("while ")
|| trimmed.starts_with("try ")
|| trimmed.starts_with("return ")
|| trimmed.starts_with("return;")
|| trimmed == "return"
|| trimmed.starts_with("function ")
}
#[cfg(any(
target_os = "android",
all(target_os = "linux", target_env = "ohos"),
test
))]
pub(crate) fn new_eval_token(request_id: u64) -> String {
fn hash_with_random_state(request_id: u64, domain: u8) -> u64 {
let mut hasher = RandomState::new().build_hasher();
domain.hash(&mut hasher);
request_id.hash(&mut hasher);
hasher.finish()
}
let hi = hash_with_random_state(request_id, 1);
let lo = hash_with_random_state(request_id, 2);
format!("{hi:016x}{lo:016x}")
}
pub(crate) fn build_async_eval_body(src: &str, resolve_call: Option<&str>) -> String {
let inner_await = if looks_like_function_body(src) {
format!("await (async () => {{ {src} }})()")
} else {
format!("await ({src})")
};
let envelope_then_finish = match resolve_call {
Some(call) => format!("{call}; return;"),
None => "return __lxR;".to_string(),
};
format!(
"let __lxR; \
try {{ \
const __lxV = {inner_await}; \
__lxR = JSON.stringify({{ok:true, value: __lxV === undefined ? null : __lxV}}); \
}} catch (e) {{ \
__lxR = JSON.stringify({{ok:false, error: String(e && e.stack ? e.stack : e)}}); \
}} \
{envelope_then_finish}"
)
}
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 async_eval_body_expression_uses_await() {
let body = build_async_eval_body("1 + 1", None);
assert!(body.contains("await (1 + 1)"));
assert!(body.contains("return __lxR;"));
}
#[test]
fn async_eval_body_function_body_wraps_with_iife() {
let body = build_async_eval_body("const x = 1; return x;", None);
assert!(body.contains("await (async () => { const x = 1; return x; })()"));
}
#[test]
fn async_eval_body_iife_expression_with_semicolons_is_expression() {
let body = build_async_eval_body(
"((sel, idx) => { const els = []; return {ok:true}; })('x', 0)",
None,
);
assert!(body.contains("await (((sel, idx) =>"));
assert!(!body.contains("await (async () =>"));
}
#[test]
fn async_eval_body_with_resolve_call_replaces_return() {
let body = build_async_eval_body("await lx.foo()", Some("Bridge.resolve('r0', __lxR)"));
assert!(body.contains("Bridge.resolve('r0', __lxR);"));
assert!(!body.contains("return __lxR;"));
}
#[test]
fn eval_token_is_not_request_id() {
let token = new_eval_token(42);
assert_eq!(token.len(), 32);
assert_ne!(token, "42");
}
#[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"));
}
#[cfg(all(feature = "webview-input", target_os = "macos"))]
#[test]
fn helper_invocation_bootstraps_namespace() {
let script = build_helper_invocation("window.__LingXiaInput.query_box(\"#app\")");
assert!(script.contains("__LingXiaInput"));
assert!(script.contains("query_box"));
}
}