#![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 },
#[error("runner {endpoint}: app unavailable (target={target:?}, reason={reason:?})")]
AppUnavailable {
endpoint: String,
target: Option<String>,
reason: Option<String>,
},
#[error(
"runner degraded: {non_success_recent}/{window} recent requests failed \
(last_endpoint={last_endpoint}, last_error={last_error})"
)]
RunnerDegraded {
window: usize,
non_success_recent: usize,
last_endpoint: String,
last_error: String,
},
#[error("runner died mid-session: last_seen_ms={last_seen_ms}, last_error={last_error}")]
RunnerDied {
last_seen_ms: u64,
last_error: String,
},
}
#[derive(Debug, Clone)]
pub(crate) struct LivenessState {
window: usize,
outcomes: std::collections::VecDeque<bool>,
last_endpoint: String,
last_error: String,
last_seen_ms: u64,
died: bool,
}
impl LivenessState {
fn with_window(window: usize) -> Self {
Self {
window: window.max(2),
outcomes: std::collections::VecDeque::with_capacity(window.max(2)),
last_endpoint: String::new(),
last_error: String::new(),
last_seen_ms: 0,
died: false,
}
}
fn record(&mut self, endpoint: &str, success: bool, error: &str) {
while self.outcomes.len() >= self.window {
self.outcomes.pop_front();
}
self.outcomes.push_back(success);
self.last_endpoint = endpoint.to_string();
if success {
self.last_error.clear();
self.last_seen_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.died = false;
} else {
self.last_error = error.to_string();
}
}
fn degraded(&self) -> Option<RunnerTransportError> {
if self.outcomes.len() < self.window {
return None;
}
let bad = self.outcomes.iter().filter(|ok| !**ok).count();
if bad * 2 > self.window {
Some(RunnerTransportError::RunnerDegraded {
window: self.window,
non_success_recent: bad,
last_endpoint: self.last_endpoint.clone(),
last_error: self.last_error.clone(),
})
} else {
None
}
}
}
#[derive(Clone, Debug, Default)]
pub struct RequestContext {
pub bundle_id: Option<String>,
pub activate: bool,
}
#[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, HealthProcessInfo, HealthResponse, HealthTestHostInfo, IncludeScope,
KeyboardStages, RecordEventsResponse, RecordedEvent, RunnerIncludeOpts, RunnerKeyboardResult,
RunnerScrollSelector, ScrollResponse, SessionAppLifecycleRequest,
SessionAppLifecycleResponse, SessionCloseAllResponse, SessionCloseRequest,
SessionCloseResponse, SessionListResponse, SessionOpenRequest, SessionOpenResponse,
SessionRelaunchAppRequest, SessionRelaunchAppResponse, SessionRenewActivationRequest,
SessionRenewActivationResponse, SessionSummary, SimHealthWireState,
DiagnosticDumpResponse, SubprocessRecord as WireSubprocessRecord, SystemPopup,
SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
};
const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
const TRANSPORT_BACKOFF_MS: u64 = 100;
fn classify_error_body(endpoint: &str, status: u16, body: &str) -> RunnerTransportError {
if body.contains("snapshot_unavailable") {
#[derive(Deserialize)]
struct Unavailable {
#[serde(default)]
target: Option<String>,
#[serde(default)]
reason: Option<String>,
}
let parsed: Option<Unavailable> = serde_json::from_str(body).ok();
return RunnerTransportError::AppUnavailable {
endpoint: endpoint.to_string(),
target: parsed.as_ref().and_then(|p| p.target.clone()),
reason: parsed.as_ref().and_then(|p| p.reason.clone()),
};
}
RunnerTransportError::NonSuccessStatus {
endpoint: endpoint.to_string(),
status,
body: body.chars().take(200).collect(),
}
}
#[derive(Debug)]
pub struct HttpRunnerClient {
base: String,
client: reqwest::Client,
reachable: Arc<AtomicBool>,
target_bundle_id: Option<String>,
auto_activate: bool,
input_dispatch_mode: Option<InputDispatchMode>,
session_id: Option<String>,
liveness: Arc<std::sync::Mutex<Option<LivenessState>>>,
sim_health: Option<smix_sim_health::SimHealthMonitor>,
session_state: Arc<std::sync::Mutex<Option<Arc<std::sync::atomic::AtomicU8>>>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputDispatchMode {
A11y,
KeyEvents,
Auto,
}
impl InputDispatchMode {
fn header_value(self) -> &'static str {
match self {
InputDispatchMode::A11y => "a11y",
InputDispatchMode::KeyEvents => "key-events",
InputDispatchMode::Auto => "auto",
}
}
}
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)),
target_bundle_id: None,
auto_activate: false,
input_dispatch_mode: None,
session_id: None,
liveness: Arc::new(std::sync::Mutex::new(None)),
sim_health: None,
session_state: Arc::new(std::sync::Mutex::new(None)),
}
}
pub fn attach_session_state(
&self,
state: Arc<std::sync::atomic::AtomicU8>,
) {
let mut guard = self
.session_state
.lock()
.expect("session_state mutex must not be poisoned");
*guard = Some(state);
}
fn update_session_state_from_header(&self, header: Option<&str>) {
let Some(value) = header else {
return;
};
let Some(state) = smix_runner_wire::SimHealthWireState::from_header(value) else {
return;
};
let byte = match state {
smix_runner_wire::SimHealthWireState::Healthy => 0u8,
smix_runner_wire::SimHealthWireState::Degraded => 1u8,
smix_runner_wire::SimHealthWireState::Cycling => 2u8,
smix_runner_wire::SimHealthWireState::Dead => 3u8,
_ => return,
};
let guard = self
.session_state
.lock()
.expect("session_state mutex must not be poisoned");
if let Some(atomic) = guard.as_ref() {
atomic.store(byte, std::sync::atomic::Ordering::Release);
}
}
#[must_use]
pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
self.target_bundle_id = Some(bundle.into());
self
}
pub fn set_target_bundle_id<S: Into<String>>(&mut self, bundle: S) {
self.target_bundle_id = Some(bundle.into());
}
#[must_use]
pub fn with_auto_activate(mut self, activate: bool) -> Self {
self.auto_activate = activate;
self
}
pub fn set_auto_activate(&mut self, activate: bool) {
self.auto_activate = activate;
}
#[must_use]
pub fn with_input_dispatch_mode(mut self, mode: InputDispatchMode) -> Self {
self.input_dispatch_mode = Some(mode);
self
}
pub fn set_input_dispatch_mode(&mut self, mode: InputDispatchMode) {
self.input_dispatch_mode = Some(mode);
}
pub fn target_bundle_id(&self) -> Option<&str> {
self.target_bundle_id.as_deref()
}
pub fn auto_activate(&self) -> bool {
self.auto_activate
}
pub fn set_session_id<S: Into<String>>(&mut self, id: S) {
self.session_id = Some(id.into());
}
pub fn clear_session_id(&mut self) {
self.session_id = None;
}
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
#[must_use]
pub fn with_liveness_window(self, window: usize) -> Self {
{
let mut guard = self
.liveness
.lock()
.expect("liveness mutex must not be poisoned");
*guard = Some(LivenessState::with_window(window));
}
self
}
#[must_use]
pub fn with_sim_health(mut self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
self.sim_health = Some(monitor);
self
}
pub fn set_sim_health(&mut self, monitor: smix_sim_health::SimHealthMonitor) {
self.sim_health = Some(monitor);
}
pub fn sim_health(&self) -> Option<&smix_sim_health::SimHealthMonitor> {
self.sim_health.as_ref()
}
fn apply_context(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut b = builder;
if let Some(bundle) = &self.target_bundle_id {
b = b.header("App-Bundle-Id", bundle);
}
if self.auto_activate {
b = b.header("App-Activate", "true");
}
if let Some(mode) = self.input_dispatch_mode {
b = b.header("Input-Dispatch-Mode", mode.header_value());
}
if let Some(sid) = &self.session_id {
b = b.header("Session-Id", sid);
}
b
}
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,
{
if let Some(err) = self.preflight_liveness() {
return Err(err);
}
let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
loop {
attempts_left -= 1;
match builder_fn(&self.client).send().await {
Ok(res) => {
let sim_health_hdr = res
.headers()
.get("X-Sim-Health")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
self.update_session_state_from_header(sim_health_hdr.as_deref());
return Ok(res);
}
Err(e) => {
let retryable = e.is_connect() || e.is_request();
if !retryable || attempts_left == 0 {
let is_connect = e.is_connect();
let err_str = format!("{e}");
self.record_outcome(endpoint, false, &err_str);
if is_connect && let Some(died) = self.probe_death(endpoint, &err_str).await
{
return Err(died);
}
return Err(RunnerTransportError::FetchFailed {
endpoint: endpoint.to_string(),
source: e,
});
}
tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
}
}
}
}
fn record_outcome(&self, endpoint: &str, success: bool, err: &str) {
let mut guard = self
.liveness
.lock()
.expect("liveness mutex must not be poisoned");
if let Some(state) = guard.as_mut() {
state.record(endpoint, success, err);
}
}
fn preflight_liveness(&self) -> Option<RunnerTransportError> {
let guard = self
.liveness
.lock()
.expect("liveness mutex must not be poisoned");
if let Some(state) = guard.as_ref() {
if state.died {
return Some(RunnerTransportError::RunnerDied {
last_seen_ms: state.last_seen_ms,
last_error: state.last_error.clone(),
});
}
return state.degraded();
}
None
}
async fn probe_death(&self, endpoint: &str, err_str: &str) -> Option<RunnerTransportError> {
let last_seen_ms = {
let guard = self
.liveness
.lock()
.expect("liveness mutex must not be poisoned");
guard.as_ref().map(|s| s.last_seen_ms).unwrap_or(0)
};
let url = format!("{}/health", self.base);
let alive = tokio::time::timeout(Duration::from_millis(1000), self.client.get(&url).send())
.await
.ok()
.and_then(|r| r.ok())
.map(|r| r.status().is_success())
.unwrap_or(false);
if alive {
return None;
}
{
let mut guard = self
.liveness
.lock()
.expect("liveness mutex must not be poisoned");
if let Some(state) = guard.as_mut() {
state.died = true;
state.last_endpoint = endpoint.to_string();
state.last_error = err_str.to_string();
}
}
Some(RunnerTransportError::RunnerDied {
last_seen_ms,
last_error: err_str.to_string(),
})
}
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| self.apply_context(c.get(&url)))
.await?;
let status = res.status();
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
let err = classify_error_body(endpoint, status.as_u16(), &body);
self.record_outcome(endpoint, false, &err.to_string());
return Err(err);
}
match res.json::<T>().await {
Ok(v) => {
self.record_outcome(endpoint, true, "");
Ok(v)
}
Err(source) => {
let err = RunnerTransportError::NonJsonBody {
endpoint: endpoint.to_string(),
source,
};
self.record_outcome(endpoint, false, &err.to_string());
Err(err)
}
}
}
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| self.apply_context(c.post(&url).json(body)))
.await?;
let status = res.status();
if !status.is_success() {
let body = res.text().await.unwrap_or_default();
let err = classify_error_body(endpoint, status.as_u16(), &body);
self.record_outcome(endpoint, false, &err.to_string());
return Err(err);
}
match res.json::<T>().await {
Ok(v) => {
self.record_outcome(endpoint, true, "");
Ok(v)
}
Err(source) => {
let err = RunnerTransportError::NonJsonBody {
endpoint: endpoint.to_string(),
source,
};
self.record_outcome(endpoint, false, &err.to_string());
Err(err)
}
}
}
pub async fn health(&self) -> bool {
let url = self.url("/health", None);
let ok = match self.client.get(&url).send().await {
Ok(res) => res.status().is_success(),
Err(_) => false,
};
if let Some(monitor) = &self.sim_health {
if ok {
monitor.record_health_ok();
} else {
monitor.record_health_fail();
}
}
ok
}
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 health_detail(
&self,
) -> Result<smix_runner_wire::HealthResponse, RunnerTransportError> {
let url = self.url("/health", None);
let res = self.client.get(&url).send().await.map_err(|source| {
if let Some(monitor) = &self.sim_health {
monitor.record_health_fail();
}
RunnerTransportError::FetchFailed {
endpoint: "/health".into(),
source,
}
})?;
let status = res.status();
if !status.is_success() {
if let Some(monitor) = &self.sim_health {
monitor.record_health_fail();
}
let body = res.text().await.unwrap_or_default();
return Err(RunnerTransportError::NonSuccessStatus {
endpoint: "/health".into(),
status: status.as_u16(),
body,
});
}
if let Some(monitor) = &self.sim_health {
monitor.record_health_ok();
}
let text = res.text().await.unwrap_or_default();
if text.trim().is_empty() {
return Ok(smix_runner_wire::HealthResponse {
ok: true,
..Default::default()
});
}
Ok(
serde_json::from_str(&text).unwrap_or(smix_runner_wire::HealthResponse {
ok: true,
..Default::default()
}),
)
}
pub async fn open_session(
&self,
req: &smix_runner_wire::SessionOpenRequest,
) -> Result<smix_runner_wire::SessionOpenResponse, RunnerTransportError> {
self.json_post("/session/open", req, None).await
}
pub async fn close_session(
&self,
req: &smix_runner_wire::SessionCloseRequest,
) -> Result<smix_runner_wire::SessionCloseResponse, RunnerTransportError> {
self.json_post("/session/close", req, None).await
}
pub async fn renew_session_activation(
&self,
req: &smix_runner_wire::SessionRenewActivationRequest,
) -> Result<smix_runner_wire::SessionRenewActivationResponse, RunnerTransportError> {
self.json_post("/session/renew-activation", req, None).await
}
pub async fn close_all_sessions(
&self,
) -> Result<smix_runner_wire::SessionCloseAllResponse, RunnerTransportError> {
self.json_post("/session/close-all", &serde_json::json!({}), None)
.await
}
pub async fn relaunch_session_app(
&self,
req: &smix_runner_wire::SessionRelaunchAppRequest,
) -> Result<smix_runner_wire::SessionRelaunchAppResponse, RunnerTransportError> {
self.json_post("/session/relaunch-app", req, None).await
}
pub async fn list_sessions(
&self,
) -> Result<smix_runner_wire::SessionListResponse, RunnerTransportError> {
self.json_post("/session/list", &serde_json::json!({}), None)
.await
}
pub async fn terminate_session_app(
&self,
req: &smix_runner_wire::SessionAppLifecycleRequest,
) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
self.json_post("/session/terminate-app", req, None).await
}
pub async fn launch_session_app(
&self,
req: &smix_runner_wire::SessionAppLifecycleRequest,
) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
self.json_post("/session/launch-app", req, None).await
}
pub async fn diagnostic_dump(
&self,
) -> Result<smix_runner_wire::DiagnosticDumpResponse, RunnerTransportError> {
self.json_post("/diagnostic/dump", &serde_json::json!({}), None)
.await
}
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)
}
}