#![doc(html_root_url = "https://docs.smix.dev/smix-runner-client")]
use serde::{Deserialize, Serialize};
use smix_input::{KeyName, SwipeDirection};
use smix_screen::{A11yNode, derive_roles_recursive};
use smix_selector::Selector;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RunnerTransportError {
#[error("runner {endpoint} fetch failed: {source}")]
FetchFailed {
endpoint: String,
#[source]
source: reqwest::Error,
},
#[error("runner {endpoint} returned status {status}: {body}")]
NonSuccessStatus {
endpoint: String,
status: u16,
body: String,
},
#[error("runner {endpoint} returned non-JSON body: {source}")]
NonJsonBody {
endpoint: String,
#[source]
source: reqwest::Error,
},
#[error("runner {endpoint} returned malformed body: {detail}")]
MalformedBody { endpoint: String, detail: String },
#[error("runner {endpoint} unreachable: {message}")]
Unreachable { endpoint: String, message: String },
}
#[derive(Debug, Error)]
#[error("tap not_found for selector {selector_json}: {body}")]
pub struct TapNotFoundError {
pub selector_json: String,
pub body: String,
}
#[derive(Debug, Error)]
#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
pub struct RunnerScrollNotMatched {
pub selector_json: String,
pub swipes: u32,
}
#[derive(Debug, Error)]
#[error("swipeOnce failed: {detail}")]
pub struct RunnerSwipeOnceFailure {
pub detail: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct OcrFrame {
pub nx: f64,
pub ny: f64,
pub w: f64,
pub h: f64,
}
impl OcrFrame {
#[must_use]
pub fn mid_x(&self) -> f64 {
self.nx + self.w * 0.5
}
#[must_use]
pub fn mid_y(&self) -> f64 {
self.ny + self.h * 0.5
}
}
pub use smix_runner_wire::{
FindRequest, FindResponse, IncludeScope, KeyboardStages, RecordEventsResponse, RecordedEvent,
RunnerIncludeOpts, RunnerKeyboardResult, RunnerScrollSelector, ScrollResponse, SystemPopup,
SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
};
const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
const TRANSPORT_BACKOFF_MS: u64 = 100;
#[derive(Debug)]
pub struct HttpRunnerClient {
base: String,
client: reqwest::Client,
reachable: Arc<AtomicBool>,
}
impl HttpRunnerClient {
pub fn new(port: u16) -> Self {
Self::with_base(format!("http://127.0.0.1:{port}"))
}
pub fn with_base<S: Into<String>>(base: S) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("reqwest::Client::builder default never fails");
HttpRunnerClient {
base: base.into(),
client,
reachable: Arc::new(AtomicBool::new(false)),
}
}
pub fn base(&self) -> &str {
&self.base
}
fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
match include {
Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
None => format!("{}{}", self.base, endpoint),
}
}
async fn send_with_retry<F>(
&self,
endpoint: &str,
builder_fn: F,
) -> Result<reqwest::Response, RunnerTransportError>
where
F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
{
let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
loop {
attempts_left -= 1;
match builder_fn(&self.client).send().await {
Ok(res) => return Ok(res),
Err(e) => {
let retryable = e.is_connect() || e.is_request();
if !retryable || attempts_left == 0 {
return Err(RunnerTransportError::FetchFailed {
endpoint: endpoint.to_string(),
source: e,
});
}
tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
}
}
}
}
async fn json_get<T: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
include: Option<IncludeScope>,
) -> Result<T, RunnerTransportError> {
let url = self.url(endpoint, include);
let res = self.send_with_retry(endpoint, |c| c.get(&url)).await?;
let status = res.status();
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
return Err(RunnerTransportError::NonSuccessStatus {
endpoint: endpoint.to_string(),
status: status.as_u16(),
body: body.chars().take(200).collect(),
});
}
res.json::<T>()
.await
.map_err(|source| RunnerTransportError::NonJsonBody {
endpoint: endpoint.to_string(),
source,
})
}
async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &B,
include: Option<IncludeScope>,
) -> Result<T, RunnerTransportError> {
let url = self.url(endpoint, include);
let res = self
.send_with_retry(endpoint, |c| c.post(&url).json(body))
.await?;
let status = res.status();
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
return Err(RunnerTransportError::NonSuccessStatus {
endpoint: endpoint.to_string(),
status: status.as_u16(),
body: body.chars().take(200).collect(),
});
}
res.json::<T>()
.await
.map_err(|source| RunnerTransportError::NonJsonBody {
endpoint: endpoint.to_string(),
source,
})
}
pub async fn health(&self) -> bool {
let url = self.url("/health", None);
match self.client.get(&url).send().await {
Ok(res) => res.status().is_success(),
Err(_) => false,
}
}
pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
if self.reachable.load(Ordering::Acquire) {
return Ok(());
}
if self.health().await {
self.reachable.store(true, Ordering::Release);
return Ok(());
}
Err(RunnerTransportError::Unreachable {
endpoint: "/health".into(),
message: format!("SmixRunner not reachable at {}", self.base),
})
}
pub async fn get_tree(
&self,
include: Option<IncludeScope>,
) -> Result<A11yNode, RunnerTransportError> {
let mut tree: A11yNode = self.json_get("/tree", include).await?;
derive_roles_recursive(&mut tree);
Ok(tree)
}
pub async fn system_popups(
&self,
include: Option<IncludeScope>,
) -> Result<Vec<SystemPopup>, RunnerTransportError> {
#[derive(Deserialize)]
struct Envelope {
popups: Vec<SystemPopup>,
}
let env: Envelope = self.json_get("/system-popups", include).await?;
Ok(env.popups)
}
pub async fn system_popup_action(
&self,
popup_id: &str,
button_id: &str,
) -> Result<bool, RunnerTransportError> {
let url = self.url("/system-popup-action", None);
let body = SystemPopupActionRequest {
popup_id: popup_id.to_string(),
button_id: button_id.to_string(),
};
let res = self
.send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
.await?;
let status = res.status();
if status.as_u16() == 404 {
return Ok(false);
}
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
return Err(RunnerTransportError::NonSuccessStatus {
endpoint: "/system-popup-action".to_string(),
status: status.as_u16(),
body: body.chars().take(200).collect(),
});
}
let resp: SystemPopupActionResponse =
res.json()
.await
.map_err(|source| RunnerTransportError::NonJsonBody {
endpoint: "/system-popup-action".to_string(),
source,
})?;
Ok(resp.ok)
}
pub async fn tap(
&self,
selector: &Selector,
mode: TapMode,
include: Option<IncludeScope>,
) -> Result<TapResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
mode: TapMode,
}
self.json_post("/tap", &Req { selector, mode }, include)
.await
}
pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req {
nx: f64,
ny: f64,
}
let _: serde_json::Value = self
.json_post("/tap-at-norm-coord", &Req { nx, ny }, None)
.await?;
Ok(())
}
pub async fn double_tap_at_norm_coord(
&self,
nx: f64,
ny: f64,
) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req {
nx: f64,
ny: f64,
}
let _: serde_json::Value = self
.json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
.await?;
Ok(())
}
pub async fn long_press_at_norm_coord(
&self,
nx: f64,
ny: f64,
duration_ms: u64,
) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req {
nx: f64,
ny: f64,
#[serde(rename = "durationMs")]
duration_ms: u64,
}
let _: serde_json::Value = self
.json_post(
"/long-press-at-norm-coord",
&Req {
nx,
ny,
duration_ms,
},
None,
)
.await?;
Ok(())
}
pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
text: &'a str,
}
let _: serde_json::Value = self.json_post("/input-text", &Req { text }, None).await?;
Ok(())
}
pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
id: &'a str,
}
#[derive(Deserialize)]
struct Resp {
ok: bool,
}
let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
Ok(resp.ok)
}
pub async fn find_text_by_ocr(
&self,
text: &str,
locales: &[String],
recognition_level: &str,
) -> Result<Option<OcrFrame>, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
text: &'a str,
locales: &'a [String],
recognition_level: &'a str,
}
#[derive(Deserialize)]
struct Resp {
found: bool,
frame: Option<[f64; 4]>,
}
let resp: Resp = self
.json_post(
"/find-text-by-ocr",
&Req {
text,
locales,
recognition_level,
},
None,
)
.await?;
if !resp.found {
return Ok(None);
}
match resp.frame {
Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
None => Ok(None),
}
}
pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
js: &'a str,
}
#[derive(Deserialize)]
struct Resp {
result: serde_json::Value,
error: String,
}
let url = "http://127.0.0.1:28080/eval";
let resp = reqwest::Client::new()
.post(url)
.json(&Req { js })
.send()
.await
.map_err(|e| RunnerTransportError::Unreachable {
endpoint: "webview-bridge".into(),
message: format!("webview-bridge POST: {e}"),
})?;
let status = resp.status();
let body: Resp = resp
.json()
.await
.map_err(|e| RunnerTransportError::Unreachable {
endpoint: "webview-bridge".into(),
message: format!("webview-bridge JSON decode (status {status}): {e}"),
})?;
if !body.error.is_empty() {
return Err(RunnerTransportError::Unreachable {
endpoint: "webview-bridge".into(),
message: format!("webview-bridge JS error: {}", body.error),
});
}
Ok(body.result)
}
pub async fn double_tap(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<TapResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
}
self.json_post("/double-tap", &Req { selector }, include)
.await
}
pub async fn long_press(
&self,
selector: &Selector,
duration_ms: u64,
include: Option<IncludeScope>,
) -> Result<TapResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
#[serde(rename = "durationMs")]
duration_ms: u64,
}
self.json_post(
"/long-press",
&Req {
selector,
duration_ms,
},
include,
)
.await
}
pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
orientation: &'a str,
}
let _: serde_json::Value = self
.json_post("/set-orientation", &Req { orientation }, None)
.await?;
Ok(())
}
pub async fn swipe_at_norm_coord(
&self,
from: (f64, f64),
to: (f64, f64),
) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req {
#[serde(rename = "fromNx")]
from_nx: f64,
#[serde(rename = "fromNy")]
from_ny: f64,
#[serde(rename = "toNx")]
to_nx: f64,
#[serde(rename = "toNy")]
to_ny: f64,
}
let _: serde_json::Value = self
.json_post(
"/swipe-at-norm-coord",
&Req {
from_nx: from.0,
from_ny: from.1,
to_nx: to.0,
to_ny: to.1,
},
None,
)
.await?;
Ok(())
}
pub async fn find(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<bool, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
}
#[derive(Deserialize)]
struct Resp {
#[serde(default)]
found: bool,
#[serde(default)]
exists: bool,
}
let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
Ok(r.found || r.exists)
}
pub async fn fill(
&self,
selector: &Selector,
text: &str,
include: Option<IncludeScope>,
) -> Result<RunnerKeyboardResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
text: &'a str,
}
self.json_post("/fill", &Req { selector, text }, include)
.await
}
pub async fn clear(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<RunnerKeyboardResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a Selector,
}
self.json_post("/clear", &Req { selector }, include).await
}
pub async fn press_key(
&self,
key: KeyName,
) -> Result<RunnerKeyboardResult, RunnerTransportError> {
#[derive(Serialize)]
struct Req {
key: KeyName,
}
self.json_post("/press-key", &Req { key }, None).await
}
pub async fn scroll(
&self,
selector: &RunnerScrollSelector,
direction: SwipeDirection,
include: Option<IncludeScope>,
) -> Result<u32, RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
selector: &'a RunnerScrollSelector,
direction: SwipeDirection,
}
#[derive(Deserialize)]
struct Resp {
#[serde(default)]
matched: Option<bool>,
#[serde(default)]
swipes: Option<u32>,
}
let r: Resp = self
.json_post(
"/scroll",
&Req {
selector,
direction,
},
include,
)
.await?;
let matched = r.matched.unwrap_or(false);
let swipes = r.swipes.unwrap_or(0);
if !matched {
return Err(RunnerTransportError::MalformedBody {
endpoint: "/scroll".into(),
detail: format!("not_matched after {swipes} swipes"),
});
}
Ok(swipes)
}
pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req {
direction: SwipeDirection,
}
let _: serde_json::Value = self
.json_post("/swipe-once", &Req { direction }, None)
.await?;
Ok(())
}
pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
#[derive(Serialize)]
struct Req<'a> {
#[serde(rename = "bundleId")]
bundle_id: &'a str,
}
let _: serde_json::Value = self
.json_post("/foreground", &Req { bundle_id }, None)
.await?;
Ok(())
}
pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
let _: serde_json::Value = self
.json_post("/hide-keyboard", &serde_json::json!({}), None)
.await?;
Ok(())
}
pub async fn back(&self) -> Result<(), RunnerTransportError> {
let _: serde_json::Value = self
.json_post("/back", &serde_json::json!({}), None)
.await?;
Ok(())
}
pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
let _: serde_json::Value = self
.json_post("/record/start", &serde_json::json!({}), None)
.await?;
Ok(())
}
pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
#[derive(Deserialize)]
struct Envelope {
events: Vec<RecordedEvent>,
}
let env: Envelope = self.json_get("/record/poll", None).await?;
Ok(env.events)
}
pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
#[derive(Deserialize)]
struct Envelope {
events: Vec<RecordedEvent>,
}
let env: Envelope = self
.json_post("/record/stop", &serde_json::json!({}), None)
.await?;
Ok(env.events)
}
}