use crate::lxapp::LxApp;
use crate::startup::split_path_query;
use crate::{NavigationType, PageInstance};
use lingxia_webview::WebView;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize)]
pub struct PageStatus {
pub appid: String,
pub name: Option<String>,
pub path: String,
pub current: bool,
pub in_stack: bool,
pub ready: bool,
}
pub fn resolve_lxapp(raw: &str) -> Result<Arc<LxApp>, String> {
let trimmed = raw.trim();
let appid = if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("current") {
let (appid, _, _) = crate::lxapp::get_current_lxapp();
if appid.is_empty() {
return Err("no current lxapp".to_string());
}
appid
} else {
trimmed.to_string()
};
crate::lxapp::try_get(&appid).ok_or_else(|| format!("lxapp is not active: {appid}"))
}
pub fn resolve_page(
app: &Arc<LxApp>,
page_name: Option<&str>,
) -> Result<(PageInstance, Option<String>), String> {
let name = page_name.map(str::trim).filter(|value| !value.is_empty());
match name {
None => {
let page = resolve_current_page(app)?;
let name = page_name_for_path(app, &page.path());
Ok((page, name))
}
Some(n) if n.eq_ignore_ascii_case("current") => {
let page = resolve_current_page(app)?;
let name = page_name_for_path(app, &page.path());
Ok((page, name))
}
Some(n) => {
if let Some(page) = app.get_page_by_instance_id_str(n) {
let name = page_name_for_path(app, &page.path());
return Ok((page, name));
}
let path = app
.find_page_path_by_name(n)
.ok_or_else(|| format!("unknown page name: {n}"))?;
let page = resolve_active_page_by_path(app, &path)
.ok_or_else(|| format!("page is not active: {n}"))?;
Ok((page, Some(n.to_string())))
}
}
}
fn resolve_current_page(app: &Arc<LxApp>) -> Result<PageInstance, String> {
if let Some(page) = app
.page_instance_runtime_info()
.into_iter()
.find(|page| page.current)
.and_then(|page| app.get_page_by_instance_id_str(&page.instance_id))
{
return Ok(page);
}
app.current_page().map_err(|err| err.to_string())
}
pub fn resolve_webview(app: &Arc<LxApp>, page_name: Option<&str>) -> Result<Arc<WebView>, String> {
let (page, _) = resolve_page(app, page_name)?;
page.webview()
.ok_or_else(|| "page WebView is not ready".to_string())
}
pub fn page_name_known(app: &Arc<LxApp>, page_name: Option<&str>) -> bool {
match page_name.map(str::trim).filter(|value| !value.is_empty()) {
None => true,
Some(n) if n.eq_ignore_ascii_case("current") => true,
Some(n) => {
app.get_page_by_instance_id_str(n).is_some() || app.find_page_path_by_name(n).is_some()
}
}
}
fn resolve_active_page_by_path(app: &Arc<LxApp>, path: &str) -> Option<PageInstance> {
let pages = app.page_instance_runtime_info();
active_runtime_page_by_path(&pages, path)
.and_then(|page| app.get_page_by_instance_id_str(&page.instance_id))
.or_else(|| app.require_page(path).ok())
}
fn active_runtime_page_by_path<'a>(
pages: &'a [crate::PageInstanceRuntimeInfo],
path: &str,
) -> Option<&'a crate::PageInstanceRuntimeInfo> {
pages
.iter()
.find(|page| page_paths_match(&page.path, path) && page.current)
.or_else(|| {
pages
.iter()
.filter(|page| page_paths_match(&page.path, path) && page.stack_index.is_some())
.max_by_key(|page| page.stack_index)
})
}
fn page_path_key(path: &str) -> String {
let (path, _) = split_path_query(path);
path.trim_start_matches('/').to_string()
}
pub fn page_paths_match(left: &str, right: &str) -> bool {
page_path_key(left) == page_path_key(right)
}
pub fn page_name_for_path(app: &Arc<LxApp>, path: &str) -> Option<String> {
app.runtime_info()
.page_entries
.into_iter()
.find(|entry| page_paths_match(&entry.path, path))
.map(|entry| entry.name)
.filter(|name| !name.is_empty())
}
pub fn page_status(app: &Arc<LxApp>, page: &PageInstance, name: Option<&str>) -> PageStatus {
let info = app.runtime_info();
let path = page.path();
let instance_id = page.instance_id_string();
if let Some(runtime_page) = app
.page_instance_runtime_info()
.into_iter()
.find(|runtime_page| runtime_page.instance_id == instance_id)
{
return PageStatus {
appid: info.appid,
name: name.map(str::to_string),
path: runtime_page.path,
current: runtime_page.current,
in_stack: runtime_page.stack_index.is_some(),
ready: runtime_page.state.ready,
};
}
PageStatus {
appid: info.appid,
name: name.map(str::to_string),
current: info
.current_page
.as_deref()
.is_some_and(|current| page_paths_match(current, &path)),
in_stack: info
.page_stack
.iter()
.any(|stack_page| page_paths_match(stack_page, &path)),
ready: page.webview().is_some(),
path,
}
}
pub fn list_page_statuses(app: &Arc<LxApp>) -> Vec<PageStatus> {
let info = app.runtime_info();
let runtime_pages = app.page_instance_runtime_info();
info.page_entries
.iter()
.map(|entry| {
let runtime_page = active_runtime_page_by_path(&runtime_pages, &entry.path);
PageStatus {
appid: info.appid.clone(),
name: (!entry.name.is_empty()).then(|| entry.name.clone()),
path: entry.path.clone(),
current: runtime_page.is_some_and(|page| page.current),
in_stack: runtime_page.is_some_and(|page| page.stack_index.is_some()),
ready: runtime_page.is_some_and(|page| page.state.ready),
}
})
.collect()
}
fn normalize_tabbar_path(url: &str) -> String {
let (path, _) = split_path_query(url);
let mut trimmed = path.trim_start_matches('/').to_string();
if let Some(dot_pos) = trimmed.rfind('.')
&& trimmed.rfind('/').is_none_or(|slash| dot_pos > slash)
{
trimmed.truncate(dot_pos);
}
trimmed
}
fn is_tabbar_page_url(app: &LxApp, url: &str) -> bool {
let Some(tabbar) = app.get_tabbar() else {
return false;
};
let target = normalize_tabbar_path(url);
tabbar
.list
.iter()
.any(|item| normalize_tabbar_path(&item.pagePath) == target)
}
pub async fn navigate(
app: &Arc<LxApp>,
page_name: &str,
query: Option<&Value>,
kind: NavigationType,
wait_ready: bool,
) -> Result<(PageInstance, Option<String>), String> {
let page_name = page_name.trim();
if page_name.is_empty() {
return Err("page name is required".to_string());
}
let path = app
.find_page_path_by_name(page_name)
.ok_or_else(|| format!("unknown page name: {page_name}"))?;
let target_url = match query {
Some(query) => crate::append_page_query(path, query)?,
None => path,
};
if kind == NavigationType::Replace && is_tabbar_page_url(app, &target_url) {
return Err("redirectTo cannot navigate to a tabBar page".to_string());
}
app.ensure_page_exists(&target_url)
.map_err(|err| err.to_string())?;
let current_path = app
.peek_current_page()
.ok_or_else(|| "no current page".to_string())?;
let current_page = app
.get_page(¤t_path)
.ok_or_else(|| "current page not found".to_string())?;
let target_page = app.get_or_create_page(&target_url);
let target_page = current_page
.navigate_to(target_page, kind)
.map_err(|err| err.to_string())?;
if wait_ready {
target_page
.wait_webview_ready()
.await
.map_err(|err| err.to_string())?;
}
resolve_page(app, None)
}
pub async fn navigate_back(
app: &Arc<LxApp>,
delta: u32,
wait_ready: bool,
) -> Result<(PageInstance, Option<String>), String> {
app.current_page()
.map_err(|err| err.to_string())?
.navigate_back(delta)
.map_err(|err| err.to_string())?;
let (page, name) = resolve_page(app, None)?;
if wait_ready {
page.wait_webview_ready()
.await
.map_err(|err| err.to_string())?;
}
Ok((page, name))
}
pub async fn page_eval(
app: &Arc<LxApp>,
page_name: Option<&str>,
js: &str,
) -> Result<Value, String> {
resolve_webview(app, page_name)?
.evaluate_javascript(js)
.await
.map_err(|err| err.to_string())
}
pub async fn page_query(
app: &Arc<LxApp>,
page_name: Option<&str>,
selector: &str,
index: Option<usize>,
all: bool,
max_text: Option<usize>,
) -> Result<Value, String> {
let script = build_query_script(selector, index, all, max_text)?;
page_eval(app, page_name, &script).await
}
pub async fn page_click(
app: &Arc<LxApp>,
page_name: Option<&str>,
selector: &str,
index: Option<usize>,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.click(selector, lingxia_webview::ClickOptions { index })
.await
.map_err(|err| err.to_string())
}
pub async fn page_type(
app: &Arc<LxApp>,
page_name: Option<&str>,
selector: &str,
index: Option<usize>,
text: &str,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.type_text(
selector,
text,
lingxia_webview::TypeOptions {
index,
replace: false,
},
)
.await
.map_err(|err| err.to_string())
}
pub async fn page_fill(
app: &Arc<LxApp>,
page_name: Option<&str>,
selector: &str,
index: Option<usize>,
text: &str,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.fill(selector, text, lingxia_webview::FillOptions { index })
.await
.map_err(|err| err.to_string())
}
pub async fn page_press(
app: &Arc<LxApp>,
page_name: Option<&str>,
key: &str,
selector: Option<&str>,
index: Option<usize>,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.press(
key,
lingxia_webview::PressOptions {
selector: selector.map(ToOwned::to_owned),
index,
},
)
.await
.map_err(|err| err.to_string())
}
pub async fn page_screenshot(app: &Arc<LxApp>, page_name: Option<&str>) -> Result<Vec<u8>, String> {
resolve_webview(app, page_name)?
.take_screenshot()
.await
.map_err(|err| err.to_string())
}
pub async fn page_scroll(
app: &Arc<LxApp>,
page_name: Option<&str>,
dx: f64,
dy: f64,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.scroll(dx, dy, lingxia_webview::ScrollOptions)
.await
.map_err(|err| err.to_string())
}
pub async fn page_scroll_to(
app: &Arc<LxApp>,
page_name: Option<&str>,
selector: &str,
) -> Result<(), String> {
resolve_webview(app, page_name)?
.scroll_to(selector, lingxia_webview::ScrollOptions)
.await
.map_err(|err| err.to_string())
}
pub fn build_query_script(
selector: &str,
index: Option<usize>,
all: bool,
max_text_chars: Option<usize>,
) -> Result<String, String> {
let selector_json =
serde_json::to_string(selector).map_err(|err| format!("invalid selector: {err}"))?;
let index_json =
serde_json::to_string(&index).map_err(|err| format!("invalid index: {err}"))?;
let max_text_json = serde_json::to_string(&max_text_chars)
.map_err(|err| format!("invalid query limit: {err}"))?;
Ok(format!(
r#"
(() => {{
const selector = {selector_json};
const requestedIndex = {index_json};
const all = {all};
const maxText = {max_text_json};
const truncate = (value) => {{
const text = String(value ?? "");
if (typeof maxText === "number" && maxText >= 0 && text.length > maxText) {{
return {{ value: text.slice(0, maxText), truncated: true }};
}}
return {{ value: text, truncated: false }};
}};
if (typeof selector !== "string" || selector.trim() === "") {{
throw new Error("selector must not be empty");
}}
let nodes;
try {{
nodes = Array.from(document.querySelectorAll(selector));
}} catch (err) {{
throw new Error("invalid selector: " + String(err && err.message ? err.message : err));
}}
const describe = (el, index, count) => {{
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const disabled = !!el.disabled || el.getAttribute("aria-disabled") === "true";
const tag = (el.tagName || "").toLowerCase();
const inputType = tag === "input" ? String(el.type || "text").toLowerCase() : "";
const blockedInputTypes = new Set([
"button", "checkbox", "color", "file", "hidden", "image", "radio",
"range", "reset", "submit"
]);
const editable = !!el.isContentEditable ||
(tag === "textarea" && !disabled && !el.readOnly) ||
(tag === "input" && !disabled && !el.readOnly && !blockedInputTypes.has(inputType));
const visible = rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth &&
style.visibility !== "hidden" &&
style.display !== "none" &&
Number(style.opacity || "1") !== 0;
const hasValue = "value" in el;
const text = truncate(el.innerText || el.textContent || "");
const value = hasValue ? truncate(el.value ?? "") : null;
return {{
exists: true,
index,
count,
tag,
type: inputType || null,
id: el.id || null,
name: el.getAttribute("name"),
role: el.getAttribute("role"),
aria_label: el.getAttribute("aria-label"),
placeholder: el.getAttribute("placeholder"),
visible,
enabled: !disabled,
editable,
text: text.value,
text_truncated: text.truncated,
value: value ? value.value : null,
value_truncated: value ? value.truncated : false,
rect: {{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom,
center_x: rect.left + (rect.width / 2),
center_y: rect.top + (rect.height / 2),
viewport_width: window.innerWidth,
viewport_height: window.innerHeight
}}
}};
}};
const count = nodes.length;
if (all) {{
return {{ count, items: nodes.map((el, index) => describe(el, index, count)) }};
}}
const index = typeof requestedIndex === "number" ? requestedIndex : 0;
const el = nodes[index];
if (!el) {{
return {{
exists: false,
index,
count,
visible: false,
enabled: false,
editable: false
}};
}}
return describe(el, index, count);
}})()
"#
))
}
#[cfg(test)]
mod tests {
use super::*;
fn runtime_page(
instance_id: &str,
path: &str,
current: bool,
stack_index: Option<usize>,
) -> crate::PageInstanceRuntimeInfo {
crate::PageInstanceRuntimeInfo {
instance_id: instance_id.to_string(),
name: None,
path: path.to_string(),
query: Value::Null,
owner: crate::PageOwner::Host,
presentation: crate::PresentationKind::Window,
lifecycle: if current { "visible" } else { "hidden" }.to_string(),
stack_index,
current,
state: crate::PageAutomationState {
webview_attached: true,
webview_ready: true,
webview_error: None,
bridge_ready: true,
render_state: "finished",
lifecycle: if current { "onShow" } else { "onHide" },
ready: true,
query: Value::Null,
},
}
}
#[test]
fn active_page_resolution_ignores_stale_same_path_instances() {
let pages = vec![
runtime_page("disposed", "pages/device/index.vue", false, None),
runtime_page(
"current",
"pages/device/index.vue?type=screen",
true,
Some(0),
),
];
let selected = active_runtime_page_by_path(&pages, "pages/device/index.vue").unwrap();
assert_eq!(selected.instance_id, "current");
}
#[test]
fn active_page_resolution_prefers_topmost_stack_instance() {
let pages = vec![
runtime_page("lower", "pages/device/index.vue", false, Some(1)),
runtime_page("upper", "pages/device/index.vue", false, Some(3)),
];
let selected = active_runtime_page_by_path(&pages, "pages/device/index.vue").unwrap();
assert_eq!(selected.instance_id, "upper");
}
}