use std::sync::Arc;
use std::time::Duration;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use tauri::{Manager, Runtime};
use crate::{window_by_label, WebDriverState};
struct FrameRef {
selector: String,
index: usize,
}
struct ServerState<R: Runtime> {
app: tauri::AppHandle<R>,
current_window_label: std::sync::Mutex<Option<String>>,
frame_stack: std::sync::Mutex<Vec<FrameRef>>,
}
type SharedState<R> = Arc<ServerState<R>>;
fn build_frame_prefix<R: Runtime>(state: &SharedState<R>) -> String {
let stack = state.frame_stack.lock().expect("lock poisoned");
if stack.is_empty() {
return String::new();
}
let mut js = "var __doc=document;".to_string();
for fr in stack.iter() {
let sel_json = serde_json::to_string(&fr.selector).unwrap();
js.push_str(&format!(
"var __f=__doc.querySelectorAll({sel_json})[{idx}];\
if(!__f)throw new Error('frame not found');\
__doc=__f.contentDocument;\
if(!__doc)throw new Error('cannot access frame document');",
sel_json = sel_json,
idx = fr.index,
));
}
js
}
fn in_frame<R: Runtime>(state: &SharedState<R>) -> bool {
!state.frame_stack.lock().expect("lock poisoned").is_empty()
}
enum ApiError {
NotFound(String),
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
};
(status, Json(json!({"error": msg}))).into_response()
}
}
type ApiResult = Result<Json<Value>, ApiError>;
async fn eval_js<R: Runtime>(
state: &SharedState<R>,
script: &str,
) -> Result<Value, ApiError> {
let label = state
.current_window_label
.lock()
.expect("lock poisoned")
.clone();
let window = window_by_label(&state.app, label.as_deref())
.ok_or_else(|| ApiError::NotFound("no such window".into()))?;
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.insert(id.clone(), tx);
}
let frame_prefix = build_frame_prefix(state);
let is_framed = in_frame(state);
let wrapped = if is_framed {
format!(
concat!(
"(function(){{try{{{frame_prefix}",
"var __r=(function(document){{{script}}}).call(null,__doc);",
"window.__WEBDRIVER__.resolve(\"{id}\",__r)",
"}}catch(__e){{window.__WEBDRIVER__.resolve(\"{id}\",",
"{{error:__e.name,message:__e.message,stacktrace:__e.stack||\"\"}})",
"}}}})()"
),
frame_prefix = frame_prefix,
script = script,
id = id,
)
} else {
format!(
concat!(
"(function(){{try{{var __r=(function(){{{script}}})();",
"window.__WEBDRIVER__.resolve(\"{id}\",__r)",
"}}catch(__e){{window.__WEBDRIVER__.resolve(\"{id}\",",
"{{error:__e.name,message:__e.message,stacktrace:__e.stack||\"\"}})",
"}}}})()"
),
script = script,
id = id,
)
};
window
.eval(&wrapped)
.map_err(|e| ApiError::Internal(e.to_string()))?;
match tokio::time::timeout(Duration::from_secs(30), rx).await {
Ok(Ok(value)) => {
if let Some(obj) = value.as_object() {
if obj.contains_key("error") && obj.contains_key("message") {
let msg = obj
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("script error");
return Err(ApiError::Internal(msg.to_string()));
}
}
Ok(value)
}
Ok(Err(_)) => Err(ApiError::Internal("result channel closed".into())),
Err(_) => {
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.remove(&id);
Err(ApiError::Internal("script timed out".into()))
}
}
}
async fn eval_on_element<R: Runtime>(
state: &SharedState<R>,
selector: &str,
index: usize,
using: Option<&str>,
body: &str,
) -> Result<Value, ApiError> {
let script = if using == Some("shadow") {
let sel_json = serde_json::to_string(selector).unwrap();
format!(
"var el=window.__WEBDRIVER__.findElementInShadow({sel_json});\
if(!el)throw new Error(\"shadow element not found or stale\");\
{body}"
)
} else {
let sel_json = serde_json::to_string(selector).unwrap();
if using == Some("xpath") {
format!(
"var __xr=document.evaluate({sel_json},document,null,\
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
var el=__xr.snapshotItem({index});\
if(!el)throw new Error(\"element not found\");\
{body}"
)
} else {
format!(
"var el=document.querySelectorAll({sel_json})[{index}];\
if(!el)throw new Error(\"element not found\");\
{body}"
)
}
};
eval_js(state, &script).await
}
#[derive(Deserialize)]
struct LabelReq {
label: Option<String>,
}
#[derive(Deserialize)]
struct CloseReq {
label: String,
}
#[derive(Deserialize)]
struct SetRectReq {
label: Option<String>,
x: Option<f64>,
y: Option<f64>,
width: Option<f64>,
height: Option<f64>,
}
#[derive(Deserialize)]
struct FindReq {
using: String,
value: String,
}
#[derive(Deserialize)]
struct ElemReq {
selector: String,
index: usize,
#[serde(default)]
using: Option<String>,
}
#[derive(Deserialize)]
struct ElemAttrReq {
selector: String,
index: usize,
name: String,
#[serde(default)]
using: Option<String>,
}
#[derive(Deserialize)]
struct SendKeysReq {
selector: String,
index: usize,
text: String,
#[serde(default)]
using: Option<String>,
}
#[derive(Deserialize)]
struct ScriptReq {
script: String,
#[serde(default)]
args: Vec<Value>,
}
#[derive(Deserialize)]
struct NavReq {
url: String,
}
#[derive(Deserialize)]
struct CookieNameReq {
name: String,
}
#[derive(Deserialize)]
struct CookieAddReq {
cookie: CookieData,
}
#[derive(Deserialize)]
struct CookieData {
name: String,
value: String,
#[serde(default = "default_path")]
path: String,
#[serde(default)]
domain: Option<String>,
#[serde(default)]
secure: bool,
#[serde(rename = "httpOnly", default)]
http_only: bool,
#[serde(default)]
expiry: Option<u64>,
}
fn default_path() -> String {
"/".to_string()
}
async fn window_handle<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let label = state
.current_window_label
.lock()
.expect("lock poisoned")
.clone();
let window = window_by_label(&state.app, label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
Ok(Json(json!(window.label())))
}
async fn window_handles<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let labels: Vec<String> = state.app.webview_windows().keys().cloned().collect();
Ok(Json(json!(labels)))
}
async fn window_close<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<CloseReq>,
) -> ApiResult {
let window = state
.app
.get_webview_window(&body.label)
.ok_or_else(|| ApiError::NotFound(format!("window '{}' not found", body.label)))?;
window
.close()
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!(true)))
}
async fn window_rect<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<LabelReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
let scale = window
.scale_factor()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let pos = window
.outer_position()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let size = window
.outer_size()
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!({
"x": pos.x as f64 / scale,
"y": pos.y as f64 / scale,
"width": size.width as f64 / scale,
"height": size.height as f64 / scale,
})))
}
async fn window_set_rect<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<SetRectReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
if let (Some(x), Some(y)) = (body.x, body.y) {
window
.set_position(tauri::LogicalPosition::new(x, y))
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
if let (Some(w), Some(h)) = (body.width, body.height) {
window
.set_size(tauri::LogicalSize::new(w, h))
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
Ok(Json(json!(true)))
}
async fn window_fullscreen<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<LabelReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
window
.set_fullscreen(true)
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!(true)))
}
async fn window_minimize<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<LabelReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
window
.minimize()
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!(true)))
}
async fn window_maximize<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<LabelReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
window
.maximize()
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!(true)))
}
async fn window_insets<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<LabelReq>,
) -> ApiResult {
let window = window_by_label(&state.app, body.label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
let scale = window
.scale_factor()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let outer_pos = window
.outer_position()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let inner_pos = window
.inner_position()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let top = (inner_pos.y - outer_pos.y) as f64 / scale;
let left = (inner_pos.x - outer_pos.x) as f64 / scale;
Ok(Json(json!({
"top": top,
"bottom": 0.0,
"x": left,
"y": top,
})))
}
async fn element_find<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<FindReq>,
) -> ApiResult {
let val_json = serde_json::to_string(&body.value).unwrap();
let script = if body.using == "xpath" {
format!(
"var r=document.evaluate({v},document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
var a=[];for(var i=0;i<r.snapshotLength;i++)a.push({{selector:{v},index:i,using:\"xpath\"}});\
return a",
v = val_json,
)
} else {
format!(
"var els=document.querySelectorAll({v});\
var a=[];for(var i=0;i<els.length;i++)a.push({{selector:{v},index:i}});\
return a",
v = val_json,
)
};
let result = eval_js(&state, &script).await?;
Ok(Json(json!({"elements": result})))
}
async fn element_text<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"return el.textContent||''",
)
.await?;
Ok(Json(json!({"text": result})))
}
async fn element_attribute<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemAttrReq>,
) -> ApiResult {
let name_json = serde_json::to_string(&body.name).unwrap();
let js = format!("return el.getAttribute({name_json})");
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
&js,
)
.await?;
Ok(Json(json!({"value": result})))
}
async fn element_property<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemAttrReq>,
) -> ApiResult {
let name_json = serde_json::to_string(&body.name).unwrap();
let js = format!("return el[{name_json}]");
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
&js,
)
.await?;
Ok(Json(json!({"value": result})))
}
async fn element_tag<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"return el.tagName.toLowerCase()",
)
.await?;
Ok(Json(json!({"tag": result})))
}
async fn element_rect<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"var r=el.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height}",
)
.await?;
Ok(Json(result))
}
async fn element_click<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"el.scrollIntoView({block:'center',inline:'center'});el.focus();el.click();return null",
)
.await?;
Ok(Json(json!(null)))
}
async fn element_clear<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"el.focus();el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));\
el.dispatchEvent(new Event('change',{bubbles:true}));return null",
)
.await?;
Ok(Json(json!(null)))
}
async fn element_send_keys<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<SendKeysReq>,
) -> ApiResult {
let text_json = serde_json::to_string(&body.text).unwrap();
let js = format!(
"el.focus();el.value+={text_json};\
el.dispatchEvent(new Event('input',{{bubbles:true}}));\
el.dispatchEvent(new Event('change',{{bubbles:true}}));return null"
);
eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
&js,
)
.await?;
Ok(Json(json!(null)))
}
async fn element_displayed<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"var s=window.getComputedStyle(el);\
return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'",
)
.await?;
Ok(Json(json!({"displayed": result})))
}
async fn element_enabled<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"return !el.disabled",
)
.await?;
Ok(Json(json!({"enabled": result})))
}
async fn element_selected<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"return el.selected||el.checked||false",
)
.await?;
Ok(Json(json!({"selected": result})))
}
async fn script_execute<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ScriptReq>,
) -> ApiResult {
let args_json = serde_json::to_string(&body.args).unwrap();
let script = format!(
"var __args={args_json};return (function(){{{}}}).apply(null,__args)",
body.script
);
let result = eval_js(&state, &script).await?;
Ok(Json(json!({"value": result})))
}
async fn script_execute_async<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ScriptReq>,
) -> ApiResult {
let label = state
.current_window_label
.lock()
.expect("lock poisoned")
.clone();
let window = window_by_label(&state.app, label.as_deref())
.ok_or(ApiError::NotFound("no window".into()))?;
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.insert(id.clone(), tx);
}
let args_json = serde_json::to_string(&body.args).unwrap();
let script = format!(
"(function(){{var __args={args_json};\
var __done=function(r){{window.__WEBDRIVER__.resolve(\"{id}\",r)}};\
__args.push(__done);\
try{{(function(){{{user_script}}}).apply(null,__args)}}\
catch(__e){{window.__WEBDRIVER__.resolve(\"{id}\",\
{{error:__e.name,message:__e.message,stacktrace:__e.stack||\"\"}})}}}})();",
user_script = body.script,
id = id,
);
window
.eval(&script)
.map_err(|e| ApiError::Internal(e.to_string()))?;
match tokio::time::timeout(Duration::from_secs(30), rx).await {
Ok(Ok(value)) => {
if let Some(obj) = value.as_object() {
if obj.contains_key("error") && obj.contains_key("message") {
let msg = obj
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("script error");
return Err(ApiError::Internal(msg.to_string()));
}
}
Ok(Json(json!({"value": value})))
}
Ok(Err(_)) => Err(ApiError::Internal("result channel closed".into())),
Err(_) => {
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.remove(&id);
Err(ApiError::Internal("async script timed out".into()))
}
}
}
async fn navigate_url<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<NavReq>,
) -> ApiResult {
let url_json = serde_json::to_string(&body.url).unwrap();
eval_js(
&state,
&format!("window.location.href={url_json};return null"),
)
.await?;
Ok(Json(json!(null)))
}
async fn navigate_current<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let result = eval_js(&state, "return window.location.href").await?;
Ok(Json(json!({"url": result})))
}
async fn navigate_title<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let result = eval_js(&state, "return window.document.title").await?;
Ok(Json(json!({"title": result})))
}
async fn navigate_back<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
eval_js(&state, "window.history.back();return null").await?;
Ok(Json(json!(null)))
}
async fn navigate_forward<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
eval_js(&state, "window.history.forward();return null").await?;
Ok(Json(json!(null)))
}
async fn navigate_refresh<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
eval_js(&state, "window.location.reload();return null").await?;
Ok(Json(json!(null)))
}
async fn eval_js_callback<R: Runtime>(
state: &SharedState<R>,
script: &str,
) -> Result<Value, ApiError> {
let label = state
.current_window_label
.lock()
.expect("lock poisoned")
.clone();
let window = window_by_label(&state.app, label.as_deref())
.ok_or_else(|| ApiError::NotFound("no such window".into()))?;
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.insert(id.clone(), tx);
}
let final_script = script.replace("__CALLBACK_ID__", &id);
window
.eval(&final_script)
.map_err(|e| ApiError::Internal(e.to_string()))?;
match tokio::time::timeout(Duration::from_secs(30), rx).await {
Ok(Ok(value)) => {
if let Some(obj) = value.as_object() {
if obj.contains_key("error") && obj.contains_key("message") {
let msg = obj
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("script error");
return Err(ApiError::Internal(msg.to_string()));
}
}
Ok(value)
}
Ok(Err(_)) => Err(ApiError::Internal("result channel closed".into())),
Err(_) => {
let ws = state.app.state::<WebDriverState>();
ws.pending_scripts
.lock()
.expect("lock poisoned")
.remove(&id);
Err(ApiError::Internal("screenshot timed out".into()))
}
}
}
async fn screenshot<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let script = r#"(function(){try{
var el=document.documentElement;
var w=Math.max(el.scrollWidth,el.clientWidth);
var h=Math.max(el.scrollHeight,el.clientHeight);
var xml=new XMLSerializer().serializeToString(el);
var svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+w+'" height="'+h+'">'
+'<foreignObject width="100%" height="100%">'+xml+'</foreignObject></svg>';
var c=document.createElement('canvas');c.width=w;c.height=h;
var ctx=c.getContext('2d');var img=new Image();
img.onload=function(){try{ctx.drawImage(img,0,0);
var d=c.toDataURL('image/png').split(',')[1];
window.__WEBDRIVER__.resolve("__CALLBACK_ID__",d)}
catch(e){window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{error:"SecurityError",message:e.message,stacktrace:""})}};
img.onerror=function(){window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{error:"ScreenshotError",message:"SVG render failed",stacktrace:""})};
img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg)
}catch(e){window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{error:e.name,message:e.message,stacktrace:e.stack||""})}})()"#;
let result = eval_js_callback(&state, script).await?;
Ok(Json(json!({"data": result})))
}
async fn screenshot_element<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let find_fn = if body.using.as_deref() == Some("xpath") {
"findElementByXPath"
} else {
"findElement"
};
let sel_json = serde_json::to_string(&body.selector).unwrap();
let script = format!(
r#"(function(){{try{{
var tgt=window.__WEBDRIVER__.{find_fn}({sel_json},{index});
if(!tgt){{window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{{error:"NoSuchElement",message:"element not found",stacktrace:""}});return}}
var rect=tgt.getBoundingClientRect();
var el=document.documentElement;
var w=Math.max(el.scrollWidth,el.clientWidth);
var h=Math.max(el.scrollHeight,el.clientHeight);
var xml=new XMLSerializer().serializeToString(el);
var svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+w+'" height="'+h+'">'
+'<foreignObject width="100%" height="100%">'+xml+'</foreignObject></svg>';
var fc=document.createElement('canvas');fc.width=w;fc.height=h;
var fctx=fc.getContext('2d');var img=new Image();
img.onload=function(){{try{{fctx.drawImage(img,0,0);
var c=document.createElement('canvas');
c.width=Math.ceil(rect.width);c.height=Math.ceil(rect.height);
var ctx=c.getContext('2d');
ctx.drawImage(fc,rect.x,rect.y,rect.width,rect.height,0,0,rect.width,rect.height);
var d=c.toDataURL('image/png').split(',')[1];
window.__WEBDRIVER__.resolve("__CALLBACK_ID__",d)}}
catch(e){{window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{{error:"SecurityError",message:e.message,stacktrace:""}})}}}};
img.onerror=function(){{window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{{error:"ScreenshotError",message:"SVG render failed",stacktrace:""}})}};
img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg)
}}catch(e){{window.__WEBDRIVER__.resolve("__CALLBACK_ID__",
{{error:e.name,message:e.message,stacktrace:e.stack||""}})}}}})()
"#,
find_fn = find_fn,
sel_json = sel_json,
index = body.index,
);
let result = eval_js_callback(&state, &script).await?;
Ok(Json(json!({"data": result})))
}
async fn cookie_get_all<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let script = r#"
var store = window.__WEBDRIVER__.cookies;
var cookies = [];
var keys = Object.keys(store);
for (var i = 0; i < keys.length; i++) {
cookies.push(store[keys[i]]);
}
return cookies;
"#;
let result = eval_js(&state, script).await?;
Ok(Json(json!({"cookies": result})))
}
async fn cookie_get<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<CookieNameReq>,
) -> ApiResult {
let name_json = serde_json::to_string(&body.name).unwrap();
let script = format!(
"var c=window.__WEBDRIVER__.cookies[{name_json}];\
return c||null"
);
let result = eval_js(&state, &script).await?;
Ok(Json(json!({"cookie": result})))
}
async fn cookie_add<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<CookieAddReq>,
) -> ApiResult {
let c = &body.cookie;
let name_json = serde_json::to_string(&c.name).unwrap();
let value_json = serde_json::to_string(&c.value).unwrap();
let path_json = serde_json::to_string(&c.path).unwrap();
let domain_json = match &c.domain {
Some(d) => serde_json::to_string(d).unwrap(),
None => "window.location.hostname".to_string(),
};
let secure = c.secure;
let http_only = c.http_only;
let expiry_js = match c.expiry {
Some(e) => format!("{e}"),
None => "null".to_string(),
};
let script = format!(
"window.__WEBDRIVER__.cookies[{name_json}]={{\
name:{name_json},value:{value_json},path:{path_json},\
domain:{domain_json},secure:{secure},httpOnly:{http_only},\
expiry:{expiry_js},sameSite:\"Lax\"\
}};return null"
);
eval_js(&state, &script).await?;
Ok(Json(json!(null)))
}
async fn cookie_delete<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<CookieNameReq>,
) -> ApiResult {
let name_json = serde_json::to_string(&body.name).unwrap();
let script = format!(
"delete window.__WEBDRIVER__.cookies[{name_json}];return null"
);
eval_js(&state, &script).await?;
Ok(Json(json!(null)))
}
async fn cookie_delete_all<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let script = "var s=window.__WEBDRIVER__.cookies;\
var k=Object.keys(s);for(var i=0;i<k.length;i++)delete s[k[i]];\
return null";
eval_js(&state, script).await?;
Ok(Json(json!(null)))
}
async fn actions_perform<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<Value>,
) -> ApiResult {
let action_sequences = body
.get("actions")
.and_then(|a| a.as_array())
.ok_or_else(|| ApiError::Internal("Missing 'actions' array".into()))?;
let tick_count = action_sequences
.iter()
.filter_map(|seq| seq.get("actions").and_then(|a| a.as_array()).map(|a| a.len()))
.max()
.unwrap_or(0);
for tick_idx in 0..tick_count {
let mut js_parts: Vec<String> = Vec::new();
let mut pause_ms: u64 = 0;
for seq in action_sequences {
let source_type = seq.get("type").and_then(|t| t.as_str()).unwrap_or("null");
let actions_arr = match seq.get("actions").and_then(|a| a.as_array()) {
Some(a) => a,
None => continue,
};
let action = match actions_arr.get(tick_idx) {
Some(a) => a,
None => continue,
};
let action_type = action
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("pause");
match (source_type, action_type) {
("key", "keyDown") => {
let key = action
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("");
let key_json = serde_json::to_string(key).unwrap();
js_parts.push(format!(
"(function(){{var k={key_json};\
var code=k.length===1?'Key'+k.toUpperCase():k;\
var tgt=document.activeElement||document.body;\
tgt.dispatchEvent(new KeyboardEvent('keydown',\
{{key:k,code:code,bubbles:true,cancelable:true}}))}})();"
));
}
("key", "keyUp") => {
let key = action
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("");
let key_json = serde_json::to_string(key).unwrap();
js_parts.push(format!(
"(function(){{var k={key_json};\
var code=k.length===1?'Key'+k.toUpperCase():k;\
var tgt=document.activeElement||document.body;\
tgt.dispatchEvent(new KeyboardEvent('keyup',\
{{key:k,code:code,bubbles:true,cancelable:true}}))}})();"
));
}
("pointer", "pointerMove") => {
let x = action.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = action.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let origin = action
.get("origin")
.and_then(|v| v.as_str())
.unwrap_or("viewport");
if let Some(origin_obj) =
action.get("origin").and_then(|v| v.as_object())
{
if let Some(elem) =
origin_obj.values().next().and_then(|v| v.as_object())
{
let sel = elem
.get("selector")
.and_then(|s| s.as_str())
.unwrap_or("");
let idx = elem
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0);
let sel_json = serde_json::to_string(sel).unwrap();
js_parts.push(format!(
"(function(){{var el=document.querySelectorAll({sel_json})[{idx}];\
if(el){{var r=el.getBoundingClientRect();\
window.__wdPointerX=r.x+r.width/2+{x};\
window.__wdPointerY=r.y+r.height/2+{y};}}}})();"
));
}
} else {
match origin {
"pointer" => {
js_parts.push(format!(
"window.__wdPointerX=(window.__wdPointerX||0)+{x};\
window.__wdPointerY=(window.__wdPointerY||0)+{y};"
));
}
_ => {
js_parts.push(format!(
"window.__wdPointerX={x};window.__wdPointerY={y};"
));
}
}
}
js_parts.push(
"(function(){var tgt=document.elementFromPoint(\
window.__wdPointerX||0,window.__wdPointerY||0)||document.body;\
tgt.dispatchEvent(new MouseEvent('mousemove',\
{clientX:window.__wdPointerX||0,clientY:window.__wdPointerY||0,\
bubbles:true,cancelable:true}))})();"
.to_string(),
);
}
("pointer", "pointerDown") => {
let button =
action.get("button").and_then(|v| v.as_u64()).unwrap_or(0);
js_parts.push(format!(
"(function(){{var tgt=document.elementFromPoint(\
window.__wdPointerX||0,window.__wdPointerY||0)||document.body;\
tgt.dispatchEvent(new MouseEvent('mousedown',\
{{clientX:window.__wdPointerX||0,clientY:window.__wdPointerY||0,\
button:{button},bubbles:true,cancelable:true}}))}})();"
));
}
("pointer", "pointerUp") => {
let button =
action.get("button").and_then(|v| v.as_u64()).unwrap_or(0);
js_parts.push(format!(
"(function(){{var tgt=document.elementFromPoint(\
window.__wdPointerX||0,window.__wdPointerY||0)||document.body;\
tgt.dispatchEvent(new MouseEvent('mouseup',\
{{clientX:window.__wdPointerX||0,clientY:window.__wdPointerY||0,\
button:{button},bubbles:true,cancelable:true}}));\
tgt.dispatchEvent(new MouseEvent('click',\
{{clientX:window.__wdPointerX||0,clientY:window.__wdPointerY||0,\
button:{button},bubbles:true,cancelable:true}}))}})();"
));
}
("wheel", "scroll") => {
let x = action.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = action.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let delta_x =
action.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
let delta_y =
action.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
js_parts.push(format!(
"(function(){{var tgt=document.elementFromPoint({x},{y})||document.body;\
tgt.dispatchEvent(new WheelEvent('wheel',\
{{clientX:{x},clientY:{y},deltaX:{delta_x},deltaY:{delta_y},\
bubbles:true,cancelable:true}}))}})();"
));
}
(_, "pause") => {
let d = action
.get("duration")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if d > pause_ms {
pause_ms = d;
}
}
_ => {}
}
}
if !js_parts.is_empty() {
let combined = js_parts.join("");
let script = format!("{combined}return null");
eval_js(&state, &script).await?;
}
if pause_ms > 0 {
tokio::time::sleep(Duration::from_millis(pause_ms)).await;
}
}
Ok(Json(json!(null)))
}
async fn actions_release<R: Runtime>(
AxumState(_state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
Ok(Json(json!(null)))
}
async fn element_shadow<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
"return el.shadowRoot !== null",
)
.await?;
Ok(Json(json!({"hasShadow": result})))
}
#[derive(Deserialize)]
struct ShadowFindReq {
host_selector: String,
host_index: usize,
#[serde(default)]
host_using: Option<String>,
#[allow(dead_code)]
using: String,
value: String,
}
async fn shadow_find<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ShadowFindReq>,
) -> ApiResult {
let host_find_fn = if body.host_using.as_deref() == Some("xpath") {
"findElementByXPath"
} else {
"findElement"
};
let host_sel_json = serde_json::to_string(&body.host_selector).unwrap();
let val_json = serde_json::to_string(&body.value).unwrap();
let script = format!(
"if(!window.__wdShadowCtr)window.__wdShadowCtr=0;\
var host=window.__WEBDRIVER__.{host_find_fn}({host_sel_json},{host_index});\
if(!host)throw new Error('host element not found');\
var sr=host.shadowRoot;\
if(!sr)throw new Error('no shadow root');\
var els=sr.querySelectorAll({val_json});\
var a=[];for(var i=0;i<els.length;i++){{\
var id='wds-'+(++window.__wdShadowCtr);\
window.__WEBDRIVER__.__shadowCache[id]=els[i];\
a.push({{selector:id,index:0,using:'shadow'}})}}\
return a",
host_find_fn = host_find_fn,
host_sel_json = host_sel_json,
host_index = body.host_index,
val_json = val_json,
);
let result = eval_js(&state, &script).await?;
Ok(Json(json!({"elements": result})))
}
#[derive(Deserialize)]
struct SwitchWindowReq {
label: String,
}
async fn window_set_current<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<SwitchWindowReq>,
) -> ApiResult {
state
.app
.get_webview_window(&body.label)
.ok_or_else(|| ApiError::NotFound(format!("window '{}' not found", body.label)))?;
*state
.current_window_label
.lock()
.expect("lock poisoned") = Some(body.label.clone());
Ok(Json(json!(true)))
}
#[derive(Deserialize)]
struct FindFromReq {
parent_selector: String,
parent_index: usize,
#[serde(default)]
parent_using: Option<String>,
using: String,
value: String,
}
async fn element_find_from<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<FindFromReq>,
) -> ApiResult {
let parent_sel_json = serde_json::to_string(&body.parent_selector).unwrap();
let val_json = serde_json::to_string(&body.value).unwrap();
let parent_js = if body.parent_using.as_deref() == Some("xpath") {
format!(
"var __xr=document.evaluate({sel},document,null,\
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
var parent=__xr.snapshotItem({idx});\
if(!parent)throw new Error('parent element not found');",
sel = parent_sel_json,
idx = body.parent_index,
)
} else {
format!(
"var parent=document.querySelectorAll({sel})[{idx}];\
if(!parent)throw new Error('parent element not found');",
sel = parent_sel_json,
idx = body.parent_index,
)
};
let child_js = if body.using == "xpath" {
format!(
"var r=document.evaluate({v},parent,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
var a=[];for(var i=0;i<r.snapshotLength;i++){{\
var e=r.snapshotItem(i);var id='wd-'+(++window.__wdFindFromCtr);\
e.setAttribute('data-wd-id',id);\
a.push({{selector:'[data-wd-id=\"'+id+'\"]',index:0}})}}\
return a",
v = val_json,
)
} else {
format!(
"var els=parent.querySelectorAll({v});\
var a=[];for(var i=0;i<els.length;i++){{\
var id='wd-'+(++window.__wdFindFromCtr);\
els[i].setAttribute('data-wd-id',id);\
a.push({{selector:'[data-wd-id=\"'+id+'\"]',index:0}})}}\
return a",
v = val_json,
)
};
let script = format!(
"if(!window.__wdFindFromCtr)window.__wdFindFromCtr=0;\
{parent_js}{child_js}"
);
let result = eval_js(&state, &script).await?;
Ok(Json(json!({"elements": result})))
}
async fn element_computed_role<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let js = r#"var tag=el.tagName.toLowerCase();
var role=el.getAttribute('role');
if(role)return role;
var map={button:'button',a:'link',h1:'heading',h2:'heading',h3:'heading',h4:'heading',h5:'heading',h6:'heading',
input:'textbox',textarea:'textbox',select:'combobox',option:'option',ul:'list',ol:'list',li:'listitem',
table:'table',tr:'row',td:'cell',th:'columnheader',img:'img',nav:'navigation',main:'main',header:'banner',
footer:'contentinfo',aside:'complementary',form:'form',details:'group',summary:'button',dialog:'dialog',
progress:'progressbar',meter:'meter'};
if(tag==='input'){var t=(el.getAttribute('type')||'text').toLowerCase();
if(t==='checkbox')return 'checkbox';if(t==='radio')return 'radio';
if(t==='range')return 'slider';if(t==='number')return 'spinbutton';
if(t==='search')return 'searchbox';return 'textbox'}
if(tag==='a'&&el.hasAttribute('href'))return 'link';
return map[tag]||'generic'"#;
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
js,
)
.await?;
Ok(Json(json!({"role": result})))
}
async fn element_computed_label<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<ElemReq>,
) -> ApiResult {
let js = r#"var lblBy=el.getAttribute('aria-labelledby');
if(lblBy){var ids=lblBy.split(/\s+/);var parts=[];
for(var i=0;i<ids.length;i++){var e=document.getElementById(ids[i]);if(e)parts.push(e.textContent.trim())}
if(parts.length)return parts.join(' ')}
var lbl=el.getAttribute('aria-label');if(lbl)return lbl;
if(el.id){var labels=document.querySelectorAll('label[for="'+el.id+'"]');
if(labels.length)return labels[0].textContent.trim()}
if(el.placeholder)return el.placeholder;
if(el.alt)return el.alt;
if(el.title)return el.title;
return ''"#;
let result = eval_on_element(
&state,
&body.selector,
body.index,
body.using.as_deref(),
js,
)
.await?;
Ok(Json(json!({"label": result})))
}
async fn element_active<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let result =
eval_js(&state, "return window.__WEBDRIVER__.getActiveElement()").await?;
Ok(Json(json!({"element": result})))
}
async fn get_source<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let result =
eval_js(&state, "return document.documentElement.outerHTML").await?;
Ok(Json(json!({"source": result})))
}
#[derive(Deserialize)]
struct FrameSwitchReq {
id: Value, }
async fn frame_switch<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(body): Json<FrameSwitchReq>,
) -> ApiResult {
if body.id.is_null() {
state.frame_stack.lock().expect("lock poisoned").clear();
return Ok(Json(json!(null)));
}
if let Some(index) = body.id.as_u64() {
state
.frame_stack
.lock()
.expect("lock poisoned")
.push(FrameRef {
selector: "iframe".to_string(),
index: index as usize,
});
return Ok(Json(json!(null)));
}
if let Some(obj) = body.id.as_object() {
let selector = obj
.get("selector")
.and_then(|s| s.as_str())
.ok_or_else(|| ApiError::Internal("frame element missing selector".into()))?
.to_string();
let index = obj
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0) as usize;
state
.frame_stack
.lock()
.expect("lock poisoned")
.push(FrameRef { selector, index });
return Ok(Json(json!(null)));
}
Err(ApiError::Internal("invalid frame id".into()))
}
async fn frame_parent<R: Runtime>(
AxumState(state): AxumState<SharedState<R>>,
Json(_body): Json<Value>,
) -> ApiResult {
let mut stack = state.frame_stack.lock().expect("lock poisoned");
stack.pop(); Ok(Json(json!(null)))
}
pub(crate) async fn start<R: Runtime>(
app: tauri::AppHandle<R>,
_webview_created_rx: tokio::sync::broadcast::Receiver<tauri::WebviewWindow<R>>,
) {
let state: SharedState<R> = Arc::new(ServerState {
app,
current_window_label: std::sync::Mutex::new(None),
frame_stack: std::sync::Mutex::new(Vec::new()),
});
let router = Router::new()
.route("/window/handle", post(window_handle::<R>))
.route("/window/handles", post(window_handles::<R>))
.route("/window/close", post(window_close::<R>))
.route("/window/rect", post(window_rect::<R>))
.route("/window/set-rect", post(window_set_rect::<R>))
.route("/window/fullscreen", post(window_fullscreen::<R>))
.route("/window/minimize", post(window_minimize::<R>))
.route("/window/maximize", post(window_maximize::<R>))
.route("/window/insets", post(window_insets::<R>))
.route("/window/set-current", post(window_set_current::<R>))
.route("/element/find", post(element_find::<R>))
.route("/element/text", post(element_text::<R>))
.route("/element/attribute", post(element_attribute::<R>))
.route("/element/property", post(element_property::<R>))
.route("/element/tag", post(element_tag::<R>))
.route("/element/rect", post(element_rect::<R>))
.route("/element/click", post(element_click::<R>))
.route("/element/clear", post(element_clear::<R>))
.route("/element/send-keys", post(element_send_keys::<R>))
.route("/element/displayed", post(element_displayed::<R>))
.route("/element/enabled", post(element_enabled::<R>))
.route("/element/selected", post(element_selected::<R>))
.route("/element/active", post(element_active::<R>))
.route("/element/find-from", post(element_find_from::<R>))
.route("/element/shadow", post(element_shadow::<R>))
.route("/shadow/find", post(shadow_find::<R>))
.route("/element/computed-role", post(element_computed_role::<R>))
.route("/element/computed-label", post(element_computed_label::<R>))
.route("/script/execute", post(script_execute::<R>))
.route("/script/execute-async", post(script_execute_async::<R>))
.route("/navigate/url", post(navigate_url::<R>))
.route("/navigate/current", post(navigate_current::<R>))
.route("/navigate/title", post(navigate_title::<R>))
.route("/navigate/back", post(navigate_back::<R>))
.route("/navigate/forward", post(navigate_forward::<R>))
.route("/navigate/refresh", post(navigate_refresh::<R>))
.route("/screenshot", post(screenshot::<R>))
.route("/screenshot/element", post(screenshot_element::<R>))
.route("/cookie/get-all", post(cookie_get_all::<R>))
.route("/cookie/get", post(cookie_get::<R>))
.route("/cookie/add", post(cookie_add::<R>))
.route("/cookie/delete", post(cookie_delete::<R>))
.route("/cookie/delete-all", post(cookie_delete_all::<R>))
.route("/source", post(get_source::<R>))
.route("/actions/perform", post(actions_perform::<R>))
.route("/actions/release", post(actions_release::<R>))
.route("/frame/switch", post(frame_switch::<R>))
.route("/frame/parent", post(frame_parent::<R>))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind webdriver plugin server");
let port = listener.local_addr().unwrap().port();
println!("[webdriver] listening on port {}", port);
axum::serve(listener, router)
.await
.expect("webdriver plugin server error");
}