blob: bd695919595706cd48944bff2627b8ddc7a01327 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use crate::protocol::*;
use base64::Engine;
use maka_agent_facts::language::Language;
use maka_agent_facts::tools::ToolError;
use maka_capabilities::computer::BrowserConnection;
use serde::Deserialize;
use serde_json::{Value, json};
use std::{collections::HashMap, time::Duration};
mod ax;
mod cursor;
mod frames;
#[derive(Clone, Debug)]
struct ElementRef {
backend: u64,
frame: Option<frames::Frame>,
}
use crate::desktop::validate_connections;
#[derive(Default)]
pub(crate) struct Browsers {
connections: Vec<BrowserConnection>,
targets: HashMap<String, Tab>,
}
#[derive(Clone, Deserialize)]
struct TabInfo {
id: String,
title: String,
url: String,
#[serde(rename = "type")]
kind: String,
#[serde(rename = "webSocketDebuggerUrl")]
websocket: Option<String>,
}
struct Tab {
provider: BrowserConnection,
generation: String,
info: TabInfo,
socket: Option<Cdp>,
frame: Option<String>,
refs: HashMap<u64, ElementRef>,
frames: frames::Frames,
ax: ax::Projection,
previous: Option<String>,
capture: Option<Capture>,
navigation: Option<Navigation>,
cursor_world: Option<(String, u64)>,
}
enum Navigation {
Loader(String),
Reload(String),
Url(String),
}
#[derive(Clone, Copy)]
struct Capture {
width: f64,
height: f64,
image_width: f64,
image_height: f64,
x: f64,
y: f64,
}
struct Cdp {
connection: std::sync::Arc<cua_driver_core::browser::cdp_ws::CdpConnection>,
}
impl Cdp {
async fn connect(url: &str, provider: &BrowserConnection) -> Result<Self, ToolError> {
let url = reqwest::Url::parse(url).map_err(failed)?;
let endpoint = reqwest::Url::parse(&provider.endpoint).map_err(failed)?;
if url.scheme() != "ws"
|| url.host_str() != endpoint.host_str()
|| url.port_or_known_default() != endpoint.port_or_known_default()
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(failed(
"browser returned a websocket outside its configured endpoint",
));
}
let connection = tokio::time::timeout(
Duration::from_secs(10),
cua_driver_core::browser::cdp_ws::CdpConnection::connect_bounded(
url.as_str(),
usize::MAX,
),
)
.await
.map_err(|_| failed("browser connection timed out"))?
.map_err(failed)?;
Ok(Self {
connection: std::sync::Arc::new(connection),
})
}
async fn request(&mut self, method: &str, params: Value) -> Result<Value, ToolError> {
self.request_in(None, method, params).await
}
async fn request_in(
&self,
session: Option<&str>,
method: &str,
params: Value,
) -> Result<Value, ToolError> {
self.connection
.call(session, method, params)
.await
.map_err(|error| {
let message = error.to_string();
if message.contains(" failed (") {
failed(message)
} else {
unknown(message)
}
})
}
}
// This is a typed owner boundary, not an error-text classifier. Lower CDP
// requests retain raw diagnostics; the model-facing result adds advice once.
pub(crate) fn with_unknown_guidance(error: ToolError, language: Language) -> ToolError {
match error {
ToolError::OutcomeUnknown(message) => {
ToolError::OutcomeUnknown(message + crate::language::browser_unknown(language))
}
error => error,
}
}
async fn http(provider: &BrowserConnection, path: &str) -> Result<Value, ToolError> {
let url = format!("{}{path}", provider.endpoint.trim_end_matches('/'));
let mut response = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(5))
.build()
.map_err(failed)?
.get(url)
.send()
.await
.map_err(failed)?
.error_for_status()
.map_err(failed)?;
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(failed)? {
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(failed)
}
async fn inventory(provider: &BrowserConnection) -> Result<(String, Vec<TabInfo>), ToolError> {
let version = http(provider, "/json/version").await?;
let generation = version["webSocketDebuggerUrl"]
.as_str()
.ok_or_else(|| failed("browser generation unavailable"))?
.to_owned();
let tabs: Vec<TabInfo> =
serde_json::from_value(http(provider, "/json/list").await?).map_err(failed)?;
Ok((
generation,
tabs.into_iter().filter(|t| t.kind == "page").collect(),
))
}
fn tab_info(provider: &BrowserConnection, tab: &TabInfo) -> Value {
json!({"id":format!("{}:{}",provider.id,tab.id),"providerTabId":tab.id,"browserId":provider.id,"title":tab.title,"url":tab.url})
}
impl Browsers {
pub(crate) fn target_label(&self, id: &str) -> Option<&str> {
self.targets.get(id).map(|tab| tab.provider.id.as_str())
}
pub(crate) fn configure(
&mut self,
connections: Vec<BrowserConnection>,
language: Language,
) -> Result<(), ToolError> {
validate_connections(&connections)?;
if connections != self.connections && !self.targets.is_empty() {
return Err(failed(match language {
Language::En => {
"browser configuration changed; reset Cua before using the new providers"
}
Language::ZhCn => "浏览器配置已改变;请先重置 Cua,再使用新的提供方",
Language::ZhTw => "瀏覽器設定已變更;請先重設 Cua,再使用新的提供者",
}));
}
self.connections = connections;
Ok(())
}
pub(crate) async fn state(&self) -> (Vec<Value>, Vec<String>) {
let mut browsers = Vec::new();
let mut errors = Vec::new();
for provider in &self.connections {
match inventory(provider).await {
Ok((_, tabs)) => browsers.push(json!({"id":provider.id,"name":provider.id,"type":"cdp","tabs":tabs.iter().map(|t| tab_info(provider,t)).collect::<Vec<_>>() })),
Err(error) => errors.push(format!("{}: {error}", provider.id)),
}
}
(browsers, errors)
}
pub(crate) async fn invoke(
&mut self,
command: Command,
cursor: &crate::cursor::Spec,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
match command {
Command::ListBrowsers { .. } => {
let (mut browsers, errors) = self.state().await;
if !errors.is_empty() {
return Err(failed(errors.join("\n")));
}
for browser in &mut browsers {
browser.as_object_mut().unwrap().remove("tabs");
}
Ok(json!(browsers))
}
Command::ListTabs { options } => {
let mut tabs = Vec::new();
for provider in self.providers(options.browser.as_deref())? {
let (_, listed) = inventory(provider).await?;
tabs.extend(listed.iter().map(|tab| tab_info(provider, tab)));
}
Ok(json!(tabs))
}
Command::GetBrowser { options } => {
if options.extension_instance_id.is_some() {
return Err(failed("extension browser providers are unavailable"));
}
let mut matches = Vec::new();
for provider in self.providers(options.id.as_deref())? {
let (_, tabs) = inventory(provider).await?;
if options
.url
.as_ref()
.is_none_or(|url| tabs.iter().any(|tab| &tab.url == url))
{
matches.push(provider);
}
}
if matches.len() != 1 {
return Err(failed(match language {
Language::En => {
"browser selection is ambiguous or missing; select an ID from listBrowsers"
}
Language::ZhCn => "浏览器选择不明确或缺失;请从 listBrowsers 选择 ID",
Language::ZhTw => "瀏覽器選擇不明確或缺少;請從 listBrowsers 選擇 ID",
}));
}
Ok(json!({"id":matches[0].id}))
}
Command::GetTab { reference, options } => {
let mut matches = Vec::new();
for provider in self.providers(options.browser.as_deref())? {
let (generation, tabs) = inventory(provider).await?;
for tab in tabs {
let selected = match &reference {
TabReference::Id(id) => {
id == &tab.id || id == &format!("{}:{}", provider.id, tab.id)
}
TabReference::Url { url } => &tab.url == url,
TabReference::Mention { .. } => {
return Err(failed(
"tab mentions require a connected client provider",
));
}
};
if selected {
matches.push((provider.clone(), generation.clone(), tab));
}
}
}
if matches.len() != 1 {
return Err(failed(match language {
Language::En => {
"tab selection is ambiguous or missing; choose an exact ID from listTabs"
}
Language::ZhCn => "标签页选择不明确或缺失;请从 listTabs 选择确切 ID",
Language::ZhTw => "分頁選擇不明確或缺少;請從 listTabs 選擇確切 ID",
}));
}
let (provider, generation, tab) = matches.pop().unwrap();
self.bind(provider, generation, tab, cancellation, language)
.await
}
Command::CreateBrowserTab {
browser_id,
url,
options,
} => {
if options.visible.is_some() || options.session_name.is_some() {
return Err(failed(
"this CDP provider does not support visibility or sessionName options",
));
}
validate_url(&url)?;
let provider = self.providers(Some(&browser_id))?[0].clone();
let (generation, _) = inventory(&provider).await?;
let mut browser = Cdp::connect(&generation, &provider).await?;
let created = browser
.request("Target.createTarget", json!({"url":url,"background":true}))
.await?;
let id = created["targetId"]
.as_str()
.ok_or_else(|| unknown("browser did not return a target ID"))?;
let (current, tabs) = inventory(&provider).await?;
if current != generation {
return Err(unknown("browser restarted after tab creation"));
}
let tab = tabs.into_iter().find(|t| t.id == id).ok_or_else(|| {
unknown(match language {
Language::En => "created tab is not observable",
Language::ZhCn => "已创建的标签页不可观察",
Language::ZhTw => "已建立的分頁無法觀察",
})
})?;
self.bind(provider, generation, tab, cancellation, language)
.await
}
Command::Observe {
handle: Handle::Tab(handle),
kind,
options,
} => {
self.observe(
&handle,
kind,
!options.disable_diffing,
cancellation,
language,
)
.await
}
Command::Action {
handle: Handle::Tab(handle),
action,
} => {
self.action(&handle, action, cursor, cancellation, language)
.await
}
_ => Err(failed("invalid browser operation")),
}
}
fn providers(&self, id: Option<&str>) -> Result<Vec<&BrowserConnection>, ToolError> {
let providers: Vec<_> = self
.connections
.iter()
.filter(|p| id.is_none_or(|id| p.id == id))
.collect();
if providers.is_empty() && id.is_some() {
return Err(failed(
"browser_unavailable: no matching configured CDP provider",
));
}
Ok(providers)
}
async fn bind(
&mut self,
provider: BrowserConnection,
generation: String,
info: TabInfo,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
if self.targets.len() >= 64 {
return Err(failed(match language {
Language::En => "tab binding limit reached; reset Cua",
Language::ZhCn => "标签页绑定数已达上限;请重置 Cua",
Language::ZhTw => "分頁綁定數已達上限;請重設 Cua",
}));
}
let handle = uuid::Uuid::new_v4().to_string();
let id = format!("{}:{}", provider.id, info.id);
self.targets.insert(
handle.clone(),
Tab {
provider,
generation,
info,
socket: None,
frame: None,
refs: HashMap::new(),
ax: ax::Projection::default(),
frames: frames::Frames::default(),
previous: None,
capture: None,
navigation: None,
cursor_world: None,
},
);
match self
.observe(&handle, ObservationKind::Ax, false, cancellation, language)
.await
{
Ok(value) => Ok(
json!({"handle":Handle::Tab(handle),"id":id,"kind":"tab","state":value["state"]}),
),
Err(error) => {
self.targets.remove(&handle);
Err(error)
}
}
}
async fn observe(
&mut self,
handle: &str,
kind: ObservationKind,
diff: bool,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
let tab = self.targets.get_mut(handle).ok_or_else(|| {
failed(match language {
Language::En => "stale_tab: bind it again",
Language::ZhCn => "stale_tab: 请重新绑定标签页",
Language::ZhTw => "stale_tab: 請重新綁定分頁",
})
})?;
tab.refs.clear();
tab.capture = None;
tab.frame = None;
tab.verify(language).await?;
let result = tab.observe(kind, diff, cancellation, language).await;
if result.is_err() {
tab.socket = None;
tab.refs.clear();
tab.frame = None;
tab.capture = None;
}
result
}
async fn action(
&mut self,
handle: &str,
action: Action,
cursor: &crate::cursor::Spec,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
let closing = matches!(&action, Action::Close);
let moving_cursor = matches!(&action, Action::MoveCursor { .. });
let tab = self.targets.get_mut(handle).ok_or_else(|| {
failed(match language {
Language::En => "stale_tab: bind it again",
Language::ZhCn => "stale_tab: 请重新绑定标签页",
Language::ZhTw => "stale_tab: 請重新綁定分頁",
})
})?;
tab.verify(language).await?;
let result = tab.action(action, cursor, cancellation, language).await;
if !moving_cursor {
tab.capture = None;
}
if result.is_err() {
tab.socket = None;
tab.refs.clear();
tab.frame = None;
}
if closing && result.is_ok() {
self.targets.remove(handle);
}
result
}
pub(crate) fn close(&mut self) {
self.targets.clear();
}
}
impl Tab {
async fn wait_ready(&mut self, language: Language) -> Result<(), ToolError> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let tree = self.request("Page.getFrameTree", json!({})).await?;
let frame = &tree["frameTree"]["frame"];
let expected = match &self.navigation {
None => true,
Some(Navigation::Loader(loader)) => frame["loaderId"].as_str() == Some(loader),
Some(Navigation::Reload(previous)) => frame["loaderId"]
.as_str()
.is_some_and(|loader| loader != previous),
Some(Navigation::Url(url)) => frame["url"].as_str() == Some(url),
};
if expected {
let ready = self
.request(
"Runtime.evaluate",
json!({"expression":"document.readyState","returnByValue":true}),
)
.await?;
if matches!(
ready["result"]["value"].as_str(),
Some("interactive" | "complete")
) {
self.navigation = None;
return Ok(());
}
}
if tokio::time::Instant::now() >= deadline {
self.navigation = None;
return Err(failed(match language {
Language::En => {
"page has not reached an observable document; call getAXState again, without repeating navigation"
}
Language::ZhCn => "页面尚无可观察的文档;请再次调用 getAXState,不要重复导航",
Language::ZhTw => "頁面尚無可觀察的文件;請再次呼叫 getAXState,不要重複導覽",
}));
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
async fn verify(&mut self, language: Language) -> Result<(), ToolError> {
let (generation, tabs) = inventory(&self.provider).await?;
if generation != self.generation || !tabs.iter().any(|tab| tab.id == self.info.id) {
self.socket = None;
self.refs.clear();
self.capture = None;
self.frame = None;
return Err(failed(match language {
Language::En => "stale_tab: browser restarted or tab closed; bind the current tab",
Language::ZhCn => "stale_tab: 浏览器已重启或标签页已关闭;请绑定当前标签页",
Language::ZhTw => "stale_tab: 瀏覽器已重新啟動或分頁已關閉;請綁定目前分頁",
}));
}
self.info = tabs
.into_iter()
.find(|tab| tab.id == self.info.id)
.expect("verified tab identity");
if self
.socket
.as_ref()
.is_none_or(|socket| socket.connection.is_closed())
{
self.refs.clear();
self.capture = None;
self.frame = None;
let websocket = self
.info
.websocket
.as_deref()
.ok_or_else(|| failed("tab has no CDP websocket"))?;
self.socket = Some(Cdp::connect(websocket, &self.provider).await?);
self.frames = frames::Frames::default();
}
Ok(())
}
async fn request(&mut self, method: &str, params: Value) -> Result<Value, ToolError> {
self.socket
.as_mut()
.ok_or_else(|| failed("tab connection unavailable"))?
.request(method, params)
.await
}
async fn frame(&mut self) -> Result<String, ToolError> {
let tree = self.request("Page.getFrameTree", json!({})).await?;
let frame = &tree["frameTree"]["frame"];
Ok(format!(
"{}:{}",
frame["id"]
.as_str()
.ok_or_else(|| failed("missing main frame"))?,
frame["loaderId"]
.as_str()
.ok_or_else(|| failed("missing document generation"))?
))
}
async fn observe(
&mut self,
kind: ObservationKind,
diff: bool,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
self.wait_ready(language).await?;
crate::observation::settle(crate::observation::BROWSER_SETTLE, cancellation).await?;
self.verify(language).await?;
self.request(
"Runtime.releaseObjectGroup",
json!({"objectGroup":"maka-cua"}),
)
.await?;
let frame = self.frame().await?;
let mut value = json!({});
if !matches!(kind, ObservationKind::Screenshot) {
let frames = self.frames().await?;
let mut nodes = Vec::new();
for observed in &frames {
let session = observed.session.as_deref();
self.frame_request(
session,
"Runtime.releaseObjectGroup",
json!({"objectGroup":"maka-cua"}),
)
.await?;
let tree = self
.frame_request(
session,
"Accessibility.getFullAXTree",
json!({"depth":30,"frameId":observed.id}),
)
.await?;
let dom = self
.frame_request(
session,
"DOM.getDocument",
json!({"depth":30,"pierce":true}),
)
.await?;
let layout = self
.frame_request(
session,
"DOMSnapshot.captureSnapshot",
json!({
"computedStyles":cua_driver_core::browser::SEMANTIC_COMPUTED_STYLES,
"includePaintOrder":true,"includeDOMRects":true
}),
)
.await?;
let metrics = self
.frame_request(session, "Page.getLayoutMetrics", json!({}))
.await?;
let tree = cua_driver_core::browser::semantic_ax_tree(
&tree,
&dom,
&layout,
&metrics,
cua_driver_core::browser::store::FrameRef {
kind: if observed.parent.is_none() {
cua_driver_core::browser::store::FrameKind::Main
} else {
cua_driver_core::browser::store::FrameKind::Iframe
},
oopif_target_id: None,
identity: Some(cua_driver_core::browser::store::FrameIdentity {
frame_id: observed.id.clone(),
loader_id: observed.loader.clone(),
}),
},
);
let scope =
serde_json::to_string(&[&observed.id, &observed.loader]).map_err(failed)?;
for raw in tree["nodes"]
.as_array()
.ok_or_else(|| failed("browser returned no accessibility nodes"))?
{
let mut node = raw.clone();
for key in ["nodeId", "parentId"] {
if let Some(id) = node[key].as_str() {
node[key] = json!(format!("{scope}:{id}"));
}
}
if let Some(children) = node["childIds"].as_array() {
node["childIds"] = json!(
children
.iter()
.filter_map(Value::as_str)
.map(|id| format!("{scope}:{id}"))
.collect::<Vec<_>>()
);
}
node["makaFrame"] = json!(observed);
nodes.push(node);
}
}
if self.frames().await? != frames {
return Err(failed(match language {
Language::En => "frame changed during observation; observe again",
Language::ZhCn => "观察期间框架已改变;请重新观察",
Language::ZhTw => "觀察期間框架已變更;請重新觀察",
}));
}
let (full, refs) = self.ax.render(&frame, &nodes, language)?;
self.refs = refs;
let body = if diff {
crate::session::display_diff(self.previous.as_deref(), &full, language)
} else {
full.clone()
};
let label = match language {
Language::En => "Tab",
Language::ZhCn => "标签页",
Language::ZhTw => "分頁",
};
let state = format!("{label} {}: {}\n{body}", self.info.id, self.info.url);
value["state"] = json!(state);
self.previous = Some(full);
} else {
self.previous = None;
}
if !matches!(kind, ObservationKind::Ax) {
let metrics = self.request("Page.getLayoutMetrics", json!({})).await?;
let viewport = &metrics["cssVisualViewport"];
let width = viewport["clientWidth"]
.as_f64()
.ok_or_else(|| failed("viewport dimensions unavailable"))?;
let height = viewport["clientHeight"]
.as_f64()
.ok_or_else(|| failed("viewport dimensions unavailable"))?;
let image = self
.request(
"Page.captureScreenshot",
json!({"format":"png","captureBeyondViewport":false}),
)
.await?;
let data = image["data"]
.as_str()
.ok_or_else(|| failed("browser screenshot unavailable"))?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(failed)?;
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" {
return Err(failed("browser screenshot is not PNG"));
}
let image_width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as f64;
let image_height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as f64;
if image_width == 0.0 || image_height == 0.0 {
return Err(failed("empty browser screenshot"));
}
value["image"] = json!({"type":"image","mimeType":"image/png","data":data});
self.capture = Some(Capture {
width,
height,
image_width,
image_height,
x: viewport["pageX"].as_f64().unwrap_or(0.0),
y: viewport["pageY"].as_f64().unwrap_or(0.0),
});
}
if self.frame().await? != frame {
return Err(failed(match language {
Language::En => "document changed during observation; observe again",
Language::ZhCn => "观察期间文档已改变;请重新观察",
Language::ZhTw => "觀察期間文件已變更;請重新觀察",
}));
}
self.frame = Some(frame);
Ok(value)
}
async fn call_element(
&mut self,
index: u64,
function: &str,
arguments: Vec<Value>,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
let element = self.verify_element(index, language).await?;
let frame = element
.frame
.as_ref()
.ok_or_else(|| failed("element frame was not proven"))?;
let resolved = self
.frame_request(
frame.session.as_deref(),
"DOM.resolveNode",
json!({"backendNodeId":element.backend,"objectGroup":"maka-cua"}),
)
.await?;
let object = resolved["object"]["objectId"]
.as_str()
.ok_or_else(|| failed("element is no longer attached"))?;
check_input(cancellation)?;
let result=self.frame_request(frame.session.as_deref(),"Runtime.callFunctionOn",json!({
"objectId":object,"functionDeclaration":function,
"arguments":arguments.into_iter().map(|value|json!({"value":value})).collect::<Vec<_>>(),"returnByValue":true
})).await?;
if let Some(error) = result.get("exceptionDetails") {
return Err(failed(format!(
"element operation refused: {}",
error["exception"]["description"]
)));
}
Ok(result["result"]["value"].clone())
}
async fn focus(
&mut self,
index: Option<u64>,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<(), ToolError> {
if let Some(index) = index {
self.call_element(index,"function(){ if(!this.isConnected) throw Error('detached'); this.focus(); if(this.getRootNode().activeElement!==this) throw Error('focus not confirmed'); }",vec![],cancellation, language).await?;
}
Ok(())
}
async fn point(&mut self, point: [f64; 2], language: Language) -> Result<[f64; 2], ToolError> {
let capture = self.capture.ok_or_else(|| {
failed(match language {
Language::En => "capture_required: getScreenshot before coordinate input",
Language::ZhCn => "capture_required: 按坐标输入前请先调用 getScreenshot",
Language::ZhTw => "capture_required: 按座標輸入前請先呼叫 getScreenshot",
})
})?;
let metrics = self.request("Page.getLayoutMetrics", json!({})).await?;
if metrics["cssVisualViewport"]["clientWidth"].as_f64() != Some(capture.width)
|| metrics["cssVisualViewport"]["clientHeight"].as_f64() != Some(capture.height)
|| metrics["cssVisualViewport"]["pageX"].as_f64() != Some(capture.x)
|| metrics["cssVisualViewport"]["pageY"].as_f64() != Some(capture.y)
|| !point.iter().all(|v| v.is_finite() && *v >= 0.0)
|| point[0] >= capture.image_width
|| point[1] >= capture.image_height
{
return Err(failed(
"stale_capture: viewport changed or point lies outside screenshot",
));
}
Ok([
point[0] * capture.width / capture.image_width,
point[1] * capture.height / capture.image_height,
])
}
async fn before_input(
&mut self,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<(), ToolError> {
check_input(cancellation)?;
let frame = self.frame().await?;
check_input(cancellation)?;
if self.frame.as_ref() != Some(&frame) {
self.refs.clear();
self.capture = None;
return Err(failed(match language {
Language::En => "stale_document: observe after navigation before acting",
Language::ZhCn => "stale_document: 导航后请先观察,再操作",
Language::ZhTw => "stale_document: 導覽後請先觀察,再操作",
}));
}
Ok(())
}
async fn before_element_input(
&mut self,
index: Option<u64>,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<(), ToolError> {
self.before_input(cancellation, language).await?;
if let Some(index) = index {
self.verify_element(index, language).await?;
}
check_input(cancellation)
}
async fn action(
&mut self,
action: Action,
cursor: &crate::cursor::Spec,
cancellation: &tokio_util::sync::CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
if !matches!(
&action,
Action::Navigate { .. }
| Action::Back
| Action::Forward
| Action::Reload
| Action::Close
) {
self.before_input(cancellation, language).await?;
}
let selected_index = match action.cursor_target() {
Some(Position::Element(index)) => Some(index),
_ => None,
};
if let Action::MoveCursor { target } = &action {
let point = self.cursor_point(target, cancellation, language).await?;
return self
.draw_cursor(cursor, Some(point), "idle", "update", language)
.await;
}
if cursor.enabled
&& !matches!(
action,
Action::Click { .. } | Action::Scroll { .. } | Action::Drag { .. }
)
&& let Some(target) = action.cursor_target()
&& let Ok(point) = self.cursor_point(&target, cancellation, language).await
{
let _ = self
.draw_cursor(
cursor,
Some(point),
action.cursor_action().as_str(),
"update",
language,
)
.await;
}
check_input(cancellation)?;
match action {
Action::MoveCursor { .. } => unreachable!("handled before input dispatch"),
Action::Navigate { url } => {
validate_url(&url)?;
self.refs.clear();
self.capture = None;
let result = self.request("Page.navigate", json!({"url":url})).await?;
if let Some(error) = result.get("errorText") {
return Err(failed(format!("navigation failed: {error}")));
}
self.navigation = result["loaderId"]
.as_str()
.map(|loader| Navigation::Loader(loader.into()));
}
Action::Reload => {
let tree = self.request("Page.getFrameTree", json!({})).await?;
let previous = tree["frameTree"]["frame"]["loaderId"]
.as_str()
.ok_or_else(|| failed("document identity unavailable"))?
.to_owned();
self.refs.clear();
check_input(cancellation)?;
self.request("Page.reload", json!({})).await?;
self.navigation = Some(Navigation::Reload(previous));
}
Action::Back | Action::Forward => {
let history = self.request("Page.getNavigationHistory", json!({})).await?;
let index = history["currentIndex"]
.as_i64()
.ok_or_else(|| failed("navigation history unavailable"))?
+ if matches!(action, Action::Back) {
-1
} else {
1
};
let entry = usize::try_from(index)
.ok()
.and_then(|index| history["entries"].get(index))
.ok_or_else(|| failed("no navigation history in that direction"))?;
self.refs.clear();
self.navigation = entry["url"].as_str().map(|url| Navigation::Url(url.into()));
check_input(cancellation)?;
self.request(
"Page.navigateToHistoryEntry",
json!({"entryId":entry["id"]}),
)
.await?;
}
Action::Close => {
self.refs.clear();
self.request("Page.close", json!({})).await?;
}
Action::TypeText { index, text } => {
self.focus(index, cancellation, language).await?;
self.before_element_input(selected_index, cancellation, language)
.await?;
self.request("Input.insertText", json!({"text":text}))
.await?;
}
Action::Paste {
index,
text,
options,
} => {
if matches!(options.format, Some(PasteFormat::Html)) {
let index = index.ok_or_else(|| {
failed("HTML paste requires an observed editable element")
})?;
self.call_element(index,"function(html){ if(!this.isConnected || !this.isContentEditable) throw Error('HTML paste requires contenteditable'); this.focus(); if(!document.execCommand('insertHTML',false,html)) throw Error('HTML insertion not supported'); }",vec![json!(text)],cancellation, language).await?;
} else {
self.focus(index, cancellation, language).await?;
self.before_element_input(selected_index, cancellation, language)
.await?;
self.request("Input.insertText", json!({"text":text}))
.await?;
}
}
Action::SetValue { index, value } => {
self.call_element(
index,
include_str!("browser/set-value.js"),
vec![json!(value)],
cancellation,
language,
)
.await?;
}
Action::SelectText {
index,
text,
options,
} => {
let selection = match options.selection_type.unwrap_or(SelectionType::Text) {
SelectionType::Text => "text",
SelectionType::CursorBefore => "cursor_before",
SelectionType::CursorAfter => "cursor_after",
};
self.call_element(
index,
include_str!("browser/select-text.js"),
vec![
json!(text),
json!(options.prefix),
json!(options.suffix),
json!(selection),
],
cancellation,
language,
)
.await?;
}
Action::Click { target, options } => {
let count = options.click_count.unwrap_or(1);
if !(1..=3).contains(&count) {
return Err(failed("clickCount must be 1 to 3"));
}
let button = options.mouse_button.unwrap_or(MouseButton::Left).name();
let point = match target {
Position::Point(point) => self.point(point, language).await?,
Position::Element(index) => {
self.element_point(index, true, cancellation, language)
.await?
}
};
if cursor.enabled {
let _ = self
.draw_cursor(cursor, Some(point), "click", "update", language)
.await;
}
self.before_element_input(selected_index, cancellation, language)
.await?;
let pressed=self.request("Input.dispatchMouseEvent",json!({"type":"mousePressed","x":point[0],"y":point[1],"button":button,"clickCount":count})).await;
self.request("Input.dispatchMouseEvent",json!({"type":"mouseReleased","x":point[0],"y":point[1],"button":button,"clickCount":count})).await.map_err(|error|ToolError::CleanupUnconfirmed(format!("mouse release was not acknowledged: {error}")))?;
pressed?;
}
Action::PressKey { index, key } => {
let (key, code, modifiers) = key_event(&key)?;
self.focus(index, cancellation, language).await?;
self.before_element_input(selected_index, cancellation, language)
.await?;
let mut event = json!({"type":"keyDown","key":key,"windowsVirtualKeyCode":code,"modifiers":modifiers});
if modifiers & 7 == 0 {
if key.len() == 1 {
event["text"] = json!(key);
} else if key == "Enter" {
event["text"] = json!("\r");
}
}
let pressed = self.request("Input.dispatchKeyEvent", event).await;
self.request("Input.dispatchKeyEvent",json!({"type":"keyUp","key":key,"windowsVirtualKeyCode":code,"modifiers":modifiers})).await.map_err(|error|ToolError::CleanupUnconfirmed(format!("key release was not acknowledged: {error}")))?;
pressed?;
}
Action::Scroll {
target,
direction,
distance,
} => {
let pixels = match distance.unwrap_or(Distance::Pages(1)) {
Distance::Pages(pages) => {
let layout = self.request("Page.getLayoutMetrics", json!({})).await?;
let dimension = if matches!(direction, Direction::Left | Direction::Right) {
"clientWidth"
} else {
"clientHeight"
};
let extent = layout["cssVisualViewport"][dimension]
.as_f64()
.ok_or_else(|| failed("viewport dimensions unavailable"))?;
extent * pages as f64
}
Distance::Pixels { pixels } => pixels as f64,
};
if !(1.0..=50000.0).contains(&pixels) {
return Err(failed("scroll distance out of range"));
}
let (dx, dy) = match direction {
Direction::Up => (0.0, -pixels),
Direction::Down => (0.0, pixels),
Direction::Left => (-pixels, 0.0),
Direction::Right => (pixels, 0.0),
};
let point = match target {
Position::Point(point) => self.point(point, language).await?,
Position::Element(index) => {
self.element_point(index, false, cancellation, language)
.await?
}
};
if cursor.enabled {
let _ = self
.draw_cursor(cursor, Some(point), "scroll", "update", language)
.await;
}
self.before_element_input(selected_index, cancellation, language)
.await?;
self.request(
"Input.dispatchMouseEvent",
json!({"type":"mouseWheel","x":point[0],"y":point[1],"deltaX":dx,"deltaY":dy}),
)
.await?;
}
Action::Drag { from, to } => {
let from = self.point(from, language).await?;
let to = self.point(to, language).await?;
if cursor.enabled {
let _ = self
.draw_cursor(cursor, Some(from), "drag", "update", language)
.await;
}
self.before_element_input(selected_index, cancellation, language)
.await?;
let pressed=self.request("Input.dispatchMouseEvent",json!({"type":"mousePressed","x":from[0],"y":from[1],"button":"left","clickCount":1})).await;
let moved = if pressed.is_ok() {
if cursor.enabled {
let _ = self
.draw_cursor(cursor, Some(to), "drag", "update", language)
.await;
}
if let Err(error) = self.before_input(cancellation, language).await {
Err(error)
} else {
self.request("Input.dispatchMouseEvent",json!({"type":"mouseMoved","x":to[0],"y":to[1],"button":"left","buttons":1})).await
}
} else {
pressed
};
let released=self.request("Input.dispatchMouseEvent",json!({"type":"mouseReleased","x":to[0],"y":to[1],"button":"left","clickCount":1})).await;
released.map_err(|error| {
ToolError::CleanupUnconfirmed(format!(
"mouse release was not acknowledged: {error}"
))
})?;
moved?;
}
Action::Secondary { .. } => {
return Err(failed(
"unsupported: CDP accessibility does not expose secondary action names",
));
}
}
if self.navigation.is_some() {
self.wait_ready(language).await?;
}
Ok(Value::Null)
}
}
fn validate_url(url: &str) -> Result<(), ToolError> {
if url == "about:blank" {
return Ok(());
}
let parsed = reqwest::Url::parse(url).map_err(failed)?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(failed(
"browser navigation supports HTTP(S) and about:blank",
));
}
Ok(())
}
fn key_event(key: &str) -> Result<(String, u32, u32), ToolError> {
let mut parts: Vec<_> = key.split('+').collect();
let key = parts.pop().unwrap_or_default();
let mut modifiers = 0;
for part in parts {
modifiers |= match part.to_ascii_lowercase().as_str() {
"alt" | "option" => 1,
"ctrl" | "control" => 2,
"super" | "meta" | "cmd" | "command" => 4,
"shift" => 8,
_ => return Err(failed("unsupported key modifier")),
};
}
let (name, code) = match key {
"Return" | "Enter" => ("Enter", 13),
"Tab" => ("Tab", 9),
"Escape" | "Esc" => ("Escape", 27),
"BackSpace" | "Backspace" => ("Backspace", 8),
"Delete" => ("Delete", 46),
"Up" | "ArrowUp" => ("ArrowUp", 38),
"Down" | "ArrowDown" => ("ArrowDown", 40),
"Left" | "ArrowLeft" => ("ArrowLeft", 37),
"Right" | "ArrowRight" => ("ArrowRight", 39),
"Home" => ("Home", 36),
"End" => ("End", 35),
"Page_Up" | "PageUp" => ("PageUp", 33),
"Page_Down" | "PageDown" => ("PageDown", 34),
key if key.len() == 1 && key.is_ascii() => {
(key, key.as_bytes()[0].to_ascii_uppercase() as u32)
}
_ => return Err(failed("unsupported browser key")),
};
Ok((name.into(), code, modifiers))
}
fn failed(error: impl std::fmt::Display) -> ToolError {
ToolError::Failed(error.to_string())
}
fn unknown(error: impl std::fmt::Display) -> ToolError {
ToolError::OutcomeUnknown(error.to_string())
}
fn check_input(cancellation: &tokio_util::sync::CancellationToken) -> Result<(), ToolError> {
if cancellation.is_cancelled() {
Err(failed("Computer Use cancelled before input dispatch"))
} else {
Ok(())
}
}