smix_runner_client/lib.rs
1//! smix-runner-client — HTTP IPC client to `swift-bridge/SmixRunnerCore`.
2//! Allows reqwest + tokio + thiserror + tracing deps.
3//!
4//! Wire-level 1:1 compatibility with the Swift side — any route shape /
5//! query param / response body shape change must be done in lockstep with
6//! `swift-bridge/Sources/SmixRunnerCore`.
7//!
8//! # Routes
9//!
10//! - `GET /health` — connectivity probe (memoized via [`HttpRunnerClient::ensure_reachable`])
11//! - `GET /tree?include=` — full a11y tree dump (returns [`A11yNode`])
12//! - `GET /system-popups?include=` — list of [`SystemPopup`]
13//! - `POST /tap {selector, mode, include?}` — selector → element tap
14//! - `POST /tap-at-norm-coord {nx, ny}` — Apple native event chain coord tap
15//! - `POST /find {selector, include?}` — boolean existence check
16//! - `POST /fill {selector, text, include?}` — fill text into input
17//! - `POST /clear {selector, include?}` — clear input
18//! - `POST /press-key {key}` — press named key (Return/Tab/etc.)
19//! - `POST /scroll {selector, direction, include?}` — scroll-until-visible
20//! - `POST /swipe-once {direction}` — single swipe gesture (no probe loop)
21//! - `POST /foreground {bundleId}` — bring app to foreground
22//! - `POST /hide-keyboard` — swipe-down to dismiss keyboard
23//! - `POST /back` — back gesture
24//! - `POST /record/start` — begin recording AX notifications
25//! - `GET /record/poll` — drain recorded events
26//! - `POST /record/stop` — stop recording, drain final events
27//!
28//! Per CLAUDE.md §9 #4 — never expose raw URL / sleep / xpath surfaces.
29//! All operations go through the typed methods below.
30
31#![doc(html_root_url = "https://docs.smix.dev/smix-runner-client")]
32
33use serde::{Deserialize, Serialize};
34use smix_input::{KeyName, SwipeDirection};
35use smix_screen::{A11yNode, derive_roles_recursive};
36use smix_selector::Selector;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::time::Duration;
40use thiserror::Error;
41
42// -------------------- Error types ----------------------------------------
43
44/// Transport-level failure (network, non-2xx, malformed body). Mirrors
45/// TS `RunnerTransportError` 1:1.
46#[derive(Debug, Error)]
47pub enum RunnerTransportError {
48 #[error("runner {endpoint} fetch failed: {source}")]
49 FetchFailed {
50 endpoint: String,
51 #[source]
52 source: reqwest::Error,
53 },
54 #[error("runner {endpoint} returned status {status}: {body}")]
55 NonSuccessStatus {
56 endpoint: String,
57 status: u16,
58 body: String,
59 },
60 #[error("runner {endpoint} returned non-JSON body: {source}")]
61 NonJsonBody {
62 endpoint: String,
63 #[source]
64 source: reqwest::Error,
65 },
66 #[error("runner {endpoint} returned malformed body: {detail}")]
67 MalformedBody { endpoint: String, detail: String },
68 #[error("runner {endpoint} unreachable: {message}")]
69 Unreachable { endpoint: String, message: String },
70 /// The runner returned
71 /// `{"ok":false,"error":"snapshot_unavailable"}` (or the enriched
72 /// body with `target`/`reason` fields). This is not a network
73 /// failure — it means XCUITest could not snapshot the app the client
74 /// asked about. Callers that see this after all retries can surface
75 /// an AI-readable hint about foreground state.
76 #[error("runner {endpoint}: app unavailable (target={target:?}, reason={reason:?})")]
77 AppUnavailable {
78 endpoint: String,
79 target: Option<String>,
80 reason: Option<String>,
81 },
82 /// Client-side rolling-window observation: consecutive requests
83 /// have been failing (5xx, unreachable, non-JSON). The runner is
84 /// still responding to the transport, but its answers are unsound
85 /// — surfacing this instead of silently returning stale bodies is
86 /// the whole point of [`HttpRunnerClient::with_liveness_window`].
87 #[error(
88 "runner degraded: {non_success_recent}/{window} recent requests failed \
89 (last_endpoint={last_endpoint}, last_error={last_error})"
90 )]
91 RunnerDegraded {
92 window: usize,
93 non_success_recent: usize,
94 last_endpoint: String,
95 last_error: String,
96 },
97 /// Client-side observation of a hard runner death. Triggered when a
98 /// `is_connect()` error fires AND a `/health` probe times out or
99 /// returns a non-2xx within 1 s. Distinct from
100 /// [`RunnerTransportError::Unreachable`] which fires on the initial
101 /// reachability probe; `RunnerDied` fires mid-session after
102 /// `ensure_reachable` had already succeeded.
103 #[error("runner died mid-session: last_seen_ms={last_seen_ms}, last_error={last_error}")]
104 RunnerDied {
105 last_seen_ms: u64,
106 last_error: String,
107 },
108}
109
110/// Client-side rolling window tracking the last N request outcomes.
111/// Used by `with_liveness_window` to distinguish "runner is alive and
112/// healthily returning some errors" from "runner has silently drifted
113/// into a degraded state". See [`RunnerTransportError::RunnerDegraded`]
114/// and [`RunnerTransportError::RunnerDied`].
115#[derive(Debug, Clone)]
116pub(crate) struct LivenessState {
117 window: usize,
118 /// True = success, false = non-success. Front = oldest.
119 outcomes: std::collections::VecDeque<bool>,
120 last_endpoint: String,
121 last_error: String,
122 /// Milliseconds since UNIX epoch of the last successful request.
123 last_seen_ms: u64,
124 /// True once we've fired a RunnerDied. Prevents re-firing until a
125 /// fresh success clears it.
126 died: bool,
127}
128
129impl LivenessState {
130 fn with_window(window: usize) -> Self {
131 Self {
132 window: window.max(2),
133 outcomes: std::collections::VecDeque::with_capacity(window.max(2)),
134 last_endpoint: String::new(),
135 last_error: String::new(),
136 last_seen_ms: 0,
137 died: false,
138 }
139 }
140
141 fn record(&mut self, endpoint: &str, success: bool, error: &str) {
142 while self.outcomes.len() >= self.window {
143 self.outcomes.pop_front();
144 }
145 self.outcomes.push_back(success);
146 self.last_endpoint = endpoint.to_string();
147 if success {
148 self.last_error.clear();
149 self.last_seen_ms = std::time::SystemTime::now()
150 .duration_since(std::time::UNIX_EPOCH)
151 .map(|d| d.as_millis() as u64)
152 .unwrap_or(0);
153 self.died = false;
154 } else {
155 self.last_error = error.to_string();
156 }
157 }
158
159 /// If the last `window` outcomes have a majority-non-success shape,
160 /// return the degraded error variant. Caller wraps into the outer
161 /// [`RunnerTransportError`].
162 fn degraded(&self) -> Option<RunnerTransportError> {
163 if self.outcomes.len() < self.window {
164 return None;
165 }
166 let bad = self.outcomes.iter().filter(|ok| !**ok).count();
167 if bad * 2 > self.window {
168 Some(RunnerTransportError::RunnerDegraded {
169 window: self.window,
170 non_success_recent: bad,
171 last_endpoint: self.last_endpoint.clone(),
172 last_error: self.last_error.clone(),
173 })
174 } else {
175 None
176 }
177 }
178}
179
180/// Per-request context carried across the wire via the
181/// `App-Bundle-Id` and `App-Activate` HTTP headers. Client sets
182/// these via `HttpRunnerClient::with_target_bundle_id` /
183/// `with_auto_activate`, or per-call by cloning the client with a
184/// modified context. The runner side reads them into
185/// `SmixRunnerServer.currentContext` (task-local) and passes to
186/// `resolveApp()` in every app-touching handler.
187///
188/// The two-field split is intentional: `bundle_id` is "which app do I
189/// want to talk to", `activate` is "should we bring it foreground first".
190#[derive(Clone, Debug, Default)]
191pub struct RequestContext {
192 pub bundle_id: Option<String>,
193 pub activate: bool,
194}
195
196/// `POST /tap` returned `404 / not_found` — element matched no node.
197/// Mirrors TS `TapNotFoundError`.
198#[derive(Debug, Error)]
199#[error("tap not_found for selector {selector_json}: {body}")]
200pub struct TapNotFoundError {
201 pub selector_json: String,
202 pub body: String,
203}
204
205/// `POST /scroll` returned `200 / matched:false / swipes:N` — scroll
206/// exhausted N swipes without surfacing the target. Mirrors TS
207/// `RunnerScrollNotMatched`.
208#[derive(Debug, Error)]
209#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
210pub struct RunnerScrollNotMatched {
211 pub selector_json: String,
212 pub swipes: u32,
213}
214
215/// `POST /swipe-once` returned `200 / ok:false / vanished_during_swipe`.
216/// Mirrors TS `RunnerSwipeOnceFailure`.
217#[derive(Debug, Error)]
218#[error("swipeOnce failed: {detail}")]
219pub struct RunnerSwipeOnceFailure {
220 pub detail: String,
221}
222
223/// Normalized OCR bounding box returned by
224/// [`HttpRunnerClient::find_text_by_ocr`]. Coordinates are normalized to
225/// `[0, 1]` in UIKit coord space (top-left origin, y-down). Apple Vision's
226/// native bbox is bottom-left origin + y-up — the swift handler converts
227/// before returning so all consumers see UIKit coords.
228#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
229pub struct OcrFrame {
230 /// Top-left x in `[0, 1]`.
231 pub nx: f64,
232 /// Top-left y in `[0, 1]`.
233 pub ny: f64,
234 /// Width in `[0, 1]`.
235 pub w: f64,
236 /// Height in `[0, 1]`.
237 pub h: f64,
238}
239
240impl OcrFrame {
241 /// Center x in `[0, 1]`.
242 #[must_use]
243 pub fn mid_x(&self) -> f64 {
244 self.nx + self.w * 0.5
245 }
246 /// Center y in `[0, 1]`.
247 #[must_use]
248 pub fn mid_y(&self) -> f64 {
249 self.ny + self.h * 0.5
250 }
251}
252
253// -------------------- Wire types ----------------------------------------
254//
255// Wire types live in the smix-runner-wire stone. The HTTP client
256// (this crate) re-exports them so existing consumers don't break.
257// Anyone needing the wire shapes without the reqwest+tokio HTTP impl
258// should depend on smix-runner-wire directly.
259
260pub use smix_runner_wire::{
261 FindRequest, FindResponse, IncludeScope, KeyboardStages, RecordEventsResponse, RecordedEvent,
262 RunnerIncludeOpts, RunnerKeyboardResult, RunnerScrollSelector, ScrollResponse, SystemPopup,
263 SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
264 TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
265};
266
267// -------------------- Transport retry constants -------------------------
268
269/// Total transport attempts (1 initial + 2 retries) for transient reqwest
270/// connection errors. See `send_with_retry` doc.
271const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
272/// Backoff between transport retry attempts.
273const TRANSPORT_BACKOFF_MS: u64 = 100;
274
275/// Classify a non-2xx runner response body. When the body
276/// carries `snapshot_unavailable` (bare shape) or the enriched
277/// `{"ok":false,"error":"snapshot_unavailable","target":"...","reason":"..."}`
278/// shape, lift it to a first-class
279/// `RunnerTransportError::AppUnavailable` variant. Otherwise fall
280/// through to the generic `NonSuccessStatus`.
281fn classify_error_body(endpoint: &str, status: u16, body: &str) -> RunnerTransportError {
282 if body.contains("snapshot_unavailable") {
283 // Parse enriched body when present. Any parse miss falls back to
284 // just the marker string — still surfaces useful hint upstream.
285 #[derive(Deserialize)]
286 struct Unavailable {
287 #[serde(default)]
288 target: Option<String>,
289 #[serde(default)]
290 reason: Option<String>,
291 }
292 let parsed: Option<Unavailable> = serde_json::from_str(body).ok();
293 return RunnerTransportError::AppUnavailable {
294 endpoint: endpoint.to_string(),
295 target: parsed.as_ref().and_then(|p| p.target.clone()),
296 reason: parsed.as_ref().and_then(|p| p.reason.clone()),
297 };
298 }
299 RunnerTransportError::NonSuccessStatus {
300 endpoint: endpoint.to_string(),
301 status,
302 body: body.chars().take(200).collect(),
303 }
304}
305
306// -------------------- Client --------------------------------------------
307
308/// HTTP client to the swift-side SmixRunnerCore. Async (tokio-based).
309/// Constructed once per Cell; `ensure_reachable` memoizes a successful
310/// `/health` probe so subsequent calls don't re-probe.
311#[derive(Debug)]
312pub struct HttpRunnerClient {
313 base: String,
314 client: reqwest::Client,
315 reachable: Arc<AtomicBool>,
316 /// Target bundle id, sent as `App-Bundle-Id` header on every
317 /// request. `None` = client doesn't say; runner uses its
318 /// boot-time default `app`.
319 target_bundle_id: Option<String>,
320 /// If `true`, sends `App-Activate: true` on every request so the
321 /// runner calls `.activate()` on the resolved target before
322 /// operating. Auto-recovers from cases where XCUITest's implicit
323 /// app-under-test binds to the wrong foreground.
324 auto_activate: bool,
325 /// Sent as `Input-Dispatch-Mode` header on every request.
326 /// `None` = no header sent (runner uses default).
327 input_dispatch_mode: Option<InputDispatchMode>,
328 /// Sent as `Session-Id` header on every request. Set by
329 /// `Session::wrap` after `open_session()`. When present, the
330 /// runner short-circuits its per-request `resolveApp()` and reuses
331 /// the session's cached `XCUIApplication` binding — this is what
332 /// eliminates the activation storm on long-running gates.
333 session_id: Option<String>,
334 /// Rolling-window state for liveness observability. `None` when
335 /// disabled (default). See [`Self::with_liveness_window`].
336 liveness: Arc<std::sync::Mutex<Option<LivenessState>>>,
337}
338
339/// Input dispatch mode for the `Input-Dispatch-Mode` header.
340/// Runner-side interpretation lives in `SmixRunnerCore/FillRoute.swift`.
341#[derive(Clone, Copy, Debug, PartialEq, Eq)]
342pub enum InputDispatchMode {
343 /// a11y-anchored dispatch (default; XCUIElement.typeText).
344 A11y,
345 /// Raw key events via IOHID / daemon; skip a11y-focus resolution.
346 /// Covers the RN hidden-input case where a11y-focus lookup returns
347 /// nothing.
348 KeyEvents,
349 /// Try a11y first; on ElementNotFound fall back to key-events.
350 Auto,
351}
352
353impl InputDispatchMode {
354 fn header_value(self) -> &'static str {
355 match self {
356 InputDispatchMode::A11y => "a11y",
357 InputDispatchMode::KeyEvents => "key-events",
358 InputDispatchMode::Auto => "auto",
359 }
360 }
361}
362
363impl HttpRunnerClient {
364 /// Construct a client targeting `http://127.0.0.1:{port}`.
365 pub fn new(port: u16) -> Self {
366 Self::with_base(format!("http://127.0.0.1:{port}"))
367 }
368
369 /// Construct with an explicit base URL (test / non-localhost cases).
370 pub fn with_base<S: Into<String>>(base: S) -> Self {
371 let client = reqwest::Client::builder()
372 .timeout(Duration::from_secs(15))
373 .build()
374 .expect("reqwest::Client::builder default never fails");
375 HttpRunnerClient {
376 base: base.into(),
377 client,
378 reachable: Arc::new(AtomicBool::new(false)),
379 target_bundle_id: None,
380 auto_activate: false,
381 input_dispatch_mode: None,
382 session_id: None,
383 liveness: Arc::new(std::sync::Mutex::new(None)),
384 }
385 }
386
387 /// Set the target bundle id sent as `App-Bundle-Id` header on
388 /// every subsequent request. Runner rebinds
389 /// `XCUIApplication(bundleIdentifier:)` per request if this differs
390 /// from its boot-time default.
391 #[must_use]
392 pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
393 self.target_bundle_id = Some(bundle.into());
394 self
395 }
396
397 /// Mutable variant of [`Self::with_target_bundle_id`]. Used from
398 /// the driver-trait pass-through where the driver holds the client
399 /// by value and can't easily use the builder shape.
400 pub fn set_target_bundle_id<S: Into<String>>(&mut self, bundle: S) {
401 self.target_bundle_id = Some(bundle.into());
402 }
403
404 /// When set, every subsequent request sends `App-Activate: true`,
405 /// causing the runner to `.activate()` the resolved target before
406 /// operating.
407 #[must_use]
408 pub fn with_auto_activate(mut self, activate: bool) -> Self {
409 self.auto_activate = activate;
410 self
411 }
412
413 /// Mutable variant of [`Self::with_auto_activate`].
414 pub fn set_auto_activate(&mut self, activate: bool) {
415 self.auto_activate = activate;
416 }
417
418 /// Set the input dispatch mode header.
419 /// Sent as `Input-Dispatch-Mode: <mode>` on every request that
420 /// touches text input (currently `/fill` + `/clear`). Runner routes:
421 /// - `a11y` (default) — resolve focus via a11y tree, dispatch to
422 /// XCUIElement.typeText
423 /// - `key-events` — skip a11y-focus resolution, send raw key events
424 /// via IOHID / daemon
425 /// - `auto` — try a11y first, fall back to key-events on
426 /// ElementNotFound
427 ///
428 /// `--force-key-events` on `smix run` sets this to `key-events`.
429 #[must_use]
430 pub fn with_input_dispatch_mode(mut self, mode: InputDispatchMode) -> Self {
431 self.input_dispatch_mode = Some(mode);
432 self
433 }
434
435 /// Mutable variant.
436 pub fn set_input_dispatch_mode(&mut self, mode: InputDispatchMode) {
437 self.input_dispatch_mode = Some(mode);
438 }
439
440 /// Accessor for the current target bundle. Used by callers
441 /// that want to log or diagnose.
442 pub fn target_bundle_id(&self) -> Option<&str> {
443 self.target_bundle_id.as_deref()
444 }
445
446 /// Accessor for the current auto-activate flag.
447 pub fn auto_activate(&self) -> bool {
448 self.auto_activate
449 }
450
451 /// Attach a session id sent as the `Session-Id` header on every
452 /// subsequent request. When the runner sees this header it looks up
453 /// the session's cached `XCUIApplication` and skips the per-request
454 /// `.activate()`, eliminating the activation storm on long-running
455 /// gates. Typically not called directly — use `open_session()` +
456 /// a session wrapper instead.
457 pub fn set_session_id<S: Into<String>>(&mut self, id: S) {
458 self.session_id = Some(id.into());
459 }
460
461 /// Clear a previously-set session id, reverting to legacy per-request
462 /// `resolveApp()` (now itself rate-limited to at most one `.activate()`
463 /// per 5 s per bundle-id).
464 pub fn clear_session_id(&mut self) {
465 self.session_id = None;
466 }
467
468 /// The session id in force for outgoing requests, or `None` if the
469 /// legacy per-request rebind path is in use.
470 pub fn session_id(&self) -> Option<&str> {
471 self.session_id.as_deref()
472 }
473
474 /// Enable liveness observability. The client tracks the last `window`
475 /// request outcomes; if a majority are non-success (5xx, unreachable,
476 /// connection-refused), subsequent calls surface
477 /// [`RunnerTransportError::RunnerDegraded`] instead of returning
478 /// silent stale data. On any `is_connect()` failure the client also
479 /// probes `/health`; if that fails within 1 s the next call surfaces
480 /// [`RunnerTransportError::RunnerDied`].
481 ///
482 /// Idempotent; safe to call more than once (last window size wins).
483 /// Default is disabled (behavior identical to v1.0.1).
484 #[must_use]
485 pub fn with_liveness_window(self, window: usize) -> Self {
486 {
487 let mut guard = self
488 .liveness
489 .lock()
490 .expect("liveness mutex must not be poisoned");
491 *guard = Some(LivenessState::with_window(window));
492 }
493 self
494 }
495
496 /// Apply per-request context headers to a reqwest builder.
497 /// Every method that constructs a request calls this so the
498 /// `App-Bundle-Id` / `App-Activate` / `Session-Id` /
499 /// `Input-Dispatch-Mode` headers are sent uniformly.
500 fn apply_context(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
501 let mut b = builder;
502 if let Some(bundle) = &self.target_bundle_id {
503 b = b.header("App-Bundle-Id", bundle);
504 }
505 if self.auto_activate {
506 b = b.header("App-Activate", "true");
507 }
508 if let Some(mode) = self.input_dispatch_mode {
509 b = b.header("Input-Dispatch-Mode", mode.header_value());
510 }
511 if let Some(sid) = &self.session_id {
512 b = b.header("Session-Id", sid);
513 }
514 b
515 }
516
517 /// Base URL accessor.
518 pub fn base(&self) -> &str {
519 &self.base
520 }
521
522 // ---- low-level helpers ----
523
524 fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
525 match include {
526 Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
527 None => format!("{}{}", self.base, endpoint),
528 }
529 }
530
531 /// Wire-level transport retry for transient reqwest connection
532 /// errors (`is_connect()` / `is_request()` — pre-server-receipt failures
533 /// raised before any bytes leave the local socket, idempotent-safe for
534 /// every route). Server-side `5xx` / `NonJsonBody` are NOT retried here:
535 /// those are semantic responses, retried (or not) by callers like
536 /// `driver.find`'s 500 retry loop or `driver.wait_for`'s selector poll.
537 ///
538 /// Budget: 3 attempts total (1 initial + 2 retries) × `TRANSPORT_BACKOFF_MS`
539 /// between attempts. Catches sim/runner socket hiccups mid-flow
540 /// without inflating happy-path latency: retries only fire on actual failure.
541 async fn send_with_retry<F>(
542 &self,
543 endpoint: &str,
544 builder_fn: F,
545 ) -> Result<reqwest::Response, RunnerTransportError>
546 where
547 F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
548 {
549 // Preflight: if liveness is enabled and previously observed the
550 // runner died, short-circuit until the caller resets it (via a
551 // successful direct `/health`).
552 if let Some(err) = self.preflight_liveness() {
553 return Err(err);
554 }
555 let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
556 loop {
557 attempts_left -= 1;
558 match builder_fn(&self.client).send().await {
559 Ok(res) => {
560 // Record but don't classify yet — status-level handling
561 // happens in the json_* helpers so the "5xx but recovered
562 // via retry" case is still counted as success.
563 return Ok(res);
564 }
565 Err(e) => {
566 let retryable = e.is_connect() || e.is_request();
567 if !retryable || attempts_left == 0 {
568 let is_connect = e.is_connect();
569 let err_str = format!("{e}");
570 self.record_outcome(endpoint, false, &err_str);
571 if is_connect
572 && let Some(died) = self.probe_death(endpoint, &err_str).await
573 {
574 return Err(died);
575 }
576 return Err(RunnerTransportError::FetchFailed {
577 endpoint: endpoint.to_string(),
578 source: e,
579 });
580 }
581 tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
582 }
583 }
584 }
585 }
586
587 fn record_outcome(&self, endpoint: &str, success: bool, err: &str) {
588 let mut guard = self
589 .liveness
590 .lock()
591 .expect("liveness mutex must not be poisoned");
592 if let Some(state) = guard.as_mut() {
593 state.record(endpoint, success, err);
594 }
595 }
596
597 fn preflight_liveness(&self) -> Option<RunnerTransportError> {
598 let guard = self
599 .liveness
600 .lock()
601 .expect("liveness mutex must not be poisoned");
602 if let Some(state) = guard.as_ref() {
603 if state.died {
604 return Some(RunnerTransportError::RunnerDied {
605 last_seen_ms: state.last_seen_ms,
606 last_error: state.last_error.clone(),
607 });
608 }
609 return state.degraded();
610 }
611 None
612 }
613
614 async fn probe_death(&self, endpoint: &str, err_str: &str) -> Option<RunnerTransportError> {
615 // Snapshot `last_seen_ms` before probing so the caller can
616 // report "last successful contact at X".
617 let last_seen_ms = {
618 let guard = self
619 .liveness
620 .lock()
621 .expect("liveness mutex must not be poisoned");
622 guard.as_ref().map(|s| s.last_seen_ms).unwrap_or(0)
623 };
624 let url = format!("{}/health", self.base);
625 let alive = tokio::time::timeout(Duration::from_millis(1000), self.client.get(&url).send())
626 .await
627 .ok()
628 .and_then(|r| r.ok())
629 .map(|r| r.status().is_success())
630 .unwrap_or(false);
631 if alive {
632 return None;
633 }
634 {
635 let mut guard = self
636 .liveness
637 .lock()
638 .expect("liveness mutex must not be poisoned");
639 if let Some(state) = guard.as_mut() {
640 state.died = true;
641 state.last_endpoint = endpoint.to_string();
642 state.last_error = err_str.to_string();
643 }
644 }
645 Some(RunnerTransportError::RunnerDied {
646 last_seen_ms,
647 last_error: err_str.to_string(),
648 })
649 }
650
651 async fn json_get<T: for<'de> Deserialize<'de>>(
652 &self,
653 endpoint: &str,
654 include: Option<IncludeScope>,
655 ) -> Result<T, RunnerTransportError> {
656 let url = self.url(endpoint, include);
657 let res = self
658 .send_with_retry(endpoint, |c| self.apply_context(c.get(&url)))
659 .await?;
660 let status = res.status();
661 if !status.is_success() {
662 let body = res.text().await.unwrap_or_default();
663 let err = classify_error_body(endpoint, status.as_u16(), &body);
664 self.record_outcome(endpoint, false, &err.to_string());
665 return Err(err);
666 }
667 match res.json::<T>().await {
668 Ok(v) => {
669 self.record_outcome(endpoint, true, "");
670 Ok(v)
671 }
672 Err(source) => {
673 let err = RunnerTransportError::NonJsonBody {
674 endpoint: endpoint.to_string(),
675 source,
676 };
677 self.record_outcome(endpoint, false, &err.to_string());
678 Err(err)
679 }
680 }
681 }
682
683 async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
684 &self,
685 endpoint: &str,
686 body: &B,
687 include: Option<IncludeScope>,
688 ) -> Result<T, RunnerTransportError> {
689 let url = self.url(endpoint, include);
690 let res = self
691 .send_with_retry(endpoint, |c| self.apply_context(c.post(&url).json(body)))
692 .await?;
693 let status = res.status();
694 if !status.is_success() {
695 let body = res.text().await.unwrap_or_default();
696 let err = classify_error_body(endpoint, status.as_u16(), &body);
697 self.record_outcome(endpoint, false, &err.to_string());
698 return Err(err);
699 }
700 match res.json::<T>().await {
701 Ok(v) => {
702 self.record_outcome(endpoint, true, "");
703 Ok(v)
704 }
705 Err(source) => {
706 let err = RunnerTransportError::NonJsonBody {
707 endpoint: endpoint.to_string(),
708 source,
709 };
710 self.record_outcome(endpoint, false, &err.to_string());
711 Err(err)
712 }
713 }
714 }
715
716 // ---- public methods (mirrors TS RunnerClient surface) ----
717
718 /// `GET /health` — bare alive probe (no memoization). Returns true on 2xx.
719 pub async fn health(&self) -> bool {
720 let url = self.url("/health", None);
721 match self.client.get(&url).send().await {
722 Ok(res) => res.status().is_success(),
723 Err(_) => false,
724 }
725 }
726
727 /// `GET /health` memoized — first call probes; subsequent are no-ops
728 /// after one success. Failure raises [`RunnerTransportError::Unreachable`].
729 /// Mirrors TS `ensureReachable`.
730 pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
731 if self.reachable.load(Ordering::Acquire) {
732 return Ok(());
733 }
734 if self.health().await {
735 self.reachable.store(true, Ordering::Release);
736 return Ok(());
737 }
738 Err(RunnerTransportError::Unreachable {
739 endpoint: "/health".into(),
740 message: format!("SmixRunner not reachable at {}", self.base),
741 })
742 }
743
744 /// `GET /health` extended — parses the v1.0.2+ JSON body carrying
745 /// uptime / activation counters / open-session count. On pre-v1.0.2
746 /// runners returning a bare 200 with no body, returns a default
747 /// [`smix_runner_wire::HealthResponse`] with only `ok = true` set.
748 pub async fn health_detail(
749 &self,
750 ) -> Result<smix_runner_wire::HealthResponse, RunnerTransportError> {
751 let url = self.url("/health", None);
752 let res = self.client.get(&url).send().await.map_err(|source| {
753 RunnerTransportError::FetchFailed {
754 endpoint: "/health".into(),
755 source,
756 }
757 })?;
758 let status = res.status();
759 if !status.is_success() {
760 let body = res.text().await.unwrap_or_default();
761 return Err(RunnerTransportError::NonSuccessStatus {
762 endpoint: "/health".into(),
763 status: status.as_u16(),
764 body,
765 });
766 }
767 // Legacy runners (< v1.0.2) return an empty body; treat parse
768 // failure as {ok: true} to keep the "health passed" invariant.
769 let text = res.text().await.unwrap_or_default();
770 if text.trim().is_empty() {
771 return Ok(smix_runner_wire::HealthResponse {
772 ok: true,
773 ..Default::default()
774 });
775 }
776 Ok(
777 serde_json::from_str(&text).unwrap_or(smix_runner_wire::HealthResponse {
778 ok: true,
779 ..Default::default()
780 }),
781 )
782 }
783
784 /// `POST /session/open` — reserve a runner-side XCUIApplication
785 /// binding and (optionally) activate it once. The returned session
786 /// id becomes the `Session-Id` header on subsequent requests via
787 /// [`Self::set_session_id`] or a session wrapper.
788 pub async fn open_session(
789 &self,
790 req: &smix_runner_wire::SessionOpenRequest,
791 ) -> Result<smix_runner_wire::SessionOpenResponse, RunnerTransportError> {
792 self.json_post("/session/open", req, None).await
793 }
794
795 /// `POST /session/close` — release a previously-opened session.
796 /// Idempotent; closing an unknown session returns `ok = true`.
797 pub async fn close_session(
798 &self,
799 req: &smix_runner_wire::SessionCloseRequest,
800 ) -> Result<smix_runner_wire::SessionCloseResponse, RunnerTransportError> {
801 self.json_post("/session/close", req, None).await
802 }
803
804 /// `POST /session/renew-activation` — re-issue `.activate()` on the
805 /// session's cached binding, subject to the runner-side per-session
806 /// 5 s rate limit. Escape hatch for consumers that detect drift.
807 pub async fn renew_session_activation(
808 &self,
809 req: &smix_runner_wire::SessionRenewActivationRequest,
810 ) -> Result<smix_runner_wire::SessionRenewActivationResponse, RunnerTransportError> {
811 self.json_post("/session/renew-activation", req, None).await
812 }
813
814 /// `GET /tree?include=` — full a11y tree dump.
815 ///
816 /// Post-deser pass [`derive_roles_recursive`] fills
817 /// `A11yNode.role` from `raw_type` because the Swift `/tree` route
818 /// only emits `rawType` on the wire. Without this, `Selector::Role`
819 /// would never match real-sim payloads.
820 pub async fn get_tree(
821 &self,
822 include: Option<IncludeScope>,
823 ) -> Result<A11yNode, RunnerTransportError> {
824 let mut tree: A11yNode = self.json_get("/tree", include).await?;
825 derive_roles_recursive(&mut tree);
826 Ok(tree)
827 }
828
829 /// `GET /system-popups?include=` — list system popups.
830 pub async fn system_popups(
831 &self,
832 include: Option<IncludeScope>,
833 ) -> Result<Vec<SystemPopup>, RunnerTransportError> {
834 #[derive(Deserialize)]
835 struct Envelope {
836 popups: Vec<SystemPopup>,
837 }
838 let env: Envelope = self.json_get("/system-popups", include).await?;
839 Ok(env.popups)
840 }
841
842 /// `POST /system-popup-action`. Tap a button
843 /// on a previously enumerated popup. `popup_id` and `button_id` are
844 /// the `Popup.id` / `PopupButton.id` strings returned by
845 /// [`Self::system_popups`]; the runner walks the same scan order so
846 /// the round-trip does not need a host-side id map.
847 ///
848 /// Returns `Ok(true)` when the runner matched both ids and dispatched
849 /// the tap, `Ok(false)` when the runner returned a 404 not_found
850 /// (popup id, button id, or synthesize dispatch missed). Transport
851 /// errors and non-2xx-non-404 statuses surface as
852 /// [`RunnerTransportError`].
853 pub async fn system_popup_action(
854 &self,
855 popup_id: &str,
856 button_id: &str,
857 ) -> Result<bool, RunnerTransportError> {
858 let url = self.url("/system-popup-action", None);
859 let body = SystemPopupActionRequest {
860 popup_id: popup_id.to_string(),
861 button_id: button_id.to_string(),
862 };
863 let res = self
864 .send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
865 .await?;
866 let status = res.status();
867 if status.as_u16() == 404 {
868 return Ok(false);
869 }
870 if !status.is_success() {
871 let body = res.text().await.unwrap_or_default();
872 return Err(RunnerTransportError::NonSuccessStatus {
873 endpoint: "/system-popup-action".to_string(),
874 status: status.as_u16(),
875 body: body.chars().take(200).collect(),
876 });
877 }
878 let resp: SystemPopupActionResponse =
879 res.json()
880 .await
881 .map_err(|source| RunnerTransportError::NonJsonBody {
882 endpoint: "/system-popup-action".to_string(),
883 source,
884 })?;
885 Ok(resp.ok)
886 }
887
888 /// `POST /tap` — selector → element tap. Errors:
889 /// - 404 → [`TapNotFoundError`] returned via `Err(.. Other ..)`. We
890 /// keep the simple `RunnerTransportError + body inspection`
891 /// pattern. Driver layer wraps with ExpectationFailure.
892 pub async fn tap(
893 &self,
894 selector: &Selector,
895 mode: TapMode,
896 include: Option<IncludeScope>,
897 ) -> Result<TapResult, RunnerTransportError> {
898 #[derive(Serialize)]
899 struct Req<'a> {
900 selector: &'a Selector,
901 mode: TapMode,
902 }
903 self.json_post("/tap", &Req { selector, mode }, include)
904 .await
905 }
906
907 /// `POST /tap-at-norm-coord` — Apple native UI event coord tap.
908 pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), RunnerTransportError> {
909 #[derive(Serialize)]
910 struct Req {
911 nx: f64,
912 ny: f64,
913 }
914 let _: serde_json::Value = self
915 .json_post("/tap-at-norm-coord", &Req { nx, ny }, None)
916 .await?;
917 Ok(())
918 }
919
920 /// `POST /double-tap-at-norm-coord` — double-tap at
921 /// viewport-normalized coord. Android-specific endpoint backing
922 /// `AndroidDriver::double_tap` after host-resolve. iOS path uses
923 /// selector-based `/double-tap`; this exists for Android where the
924 /// runner does its own host-resolve via tree dump.
925 pub async fn double_tap_at_norm_coord(
926 &self,
927 nx: f64,
928 ny: f64,
929 ) -> Result<(), RunnerTransportError> {
930 #[derive(Serialize)]
931 struct Req {
932 nx: f64,
933 ny: f64,
934 }
935 let _: serde_json::Value = self
936 .json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
937 .await?;
938 Ok(())
939 }
940
941 /// `POST /long-press-at-norm-coord` — long-press at coord
942 /// with explicit duration. Android-specific.
943 pub async fn long_press_at_norm_coord(
944 &self,
945 nx: f64,
946 ny: f64,
947 duration_ms: u64,
948 ) -> Result<(), RunnerTransportError> {
949 #[derive(Serialize)]
950 struct Req {
951 nx: f64,
952 ny: f64,
953 #[serde(rename = "durationMs")]
954 duration_ms: u64,
955 }
956 let _: serde_json::Value = self
957 .json_post(
958 "/long-press-at-norm-coord",
959 &Req {
960 nx,
961 ny,
962 duration_ms,
963 },
964 None,
965 )
966 .await?;
967 Ok(())
968 }
969
970 /// `POST /input-text` — type text into currently-focused
971 /// input. Caller must tap to focus the field first (AndroidDriver
972 /// orchestrates). Android-specific.
973 pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
974 #[derive(Serialize)]
975 struct Req<'a> {
976 text: &'a str,
977 }
978 let _: serde_json::Value = self.json_post("/input-text", &Req { text }, None).await?;
979 Ok(())
980 }
981
982 /// `POST /tap-by-id` — `XCUIElement.tap()` via the XCTest
983 /// gesture-recognizer chain. Drives SwiftUI `.sheet` / `.alert` /
984 /// `.confirmationDialog` / `.fullScreenCover` dismiss bindings that the
985 /// default host-HID-at-coord path can't trigger. Returns
986 /// `Ok(true)` when the element was found and tapped, `Ok(false)` when
987 /// the runner reported `ok=false` (element not found / NSException
988 /// surfaced through `smixGuarded`). Parallel to
989 /// `tap_at_norm_coord`; the existing `/tap` envelope stays untouched.
990 pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
991 #[derive(Serialize)]
992 struct Req<'a> {
993 id: &'a str,
994 }
995 #[derive(Deserialize)]
996 struct Resp {
997 ok: bool,
998 }
999 let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
1000 Ok(resp.ok)
1001 }
1002
1003 /// `POST /find-text-by-ocr` — Apple Vision OCR over the
1004 /// current XCUIScreen screenshot. Returns the matching text
1005 /// observation's bounding box normalized to `[0, 1]` in UIKit coord
1006 /// space (top-left origin, y-down). `Ok(None)` when no observation
1007 /// matches the keyword. Sense layer covering "lib without testID
1008 /// but with visible text" scenarios.
1009 ///
1010 /// `locales` are BCP-47 language subtags (e.g. `["en"]` / `["ja"]`);
1011 /// empty defaults to `["en"]` on the swift side. `recognition_level`
1012 /// is `"accurate"` (default, slower + higher accuracy) or `"fast"`.
1013 pub async fn find_text_by_ocr(
1014 &self,
1015 text: &str,
1016 locales: &[String],
1017 recognition_level: &str,
1018 ) -> Result<Option<OcrFrame>, RunnerTransportError> {
1019 #[derive(Serialize)]
1020 struct Req<'a> {
1021 text: &'a str,
1022 locales: &'a [String],
1023 recognition_level: &'a str,
1024 }
1025 #[derive(Deserialize)]
1026 struct Resp {
1027 found: bool,
1028 frame: Option<[f64; 4]>,
1029 }
1030 let resp: Resp = self
1031 .json_post(
1032 "/find-text-by-ocr",
1033 &Req {
1034 text,
1035 locales,
1036 recognition_level,
1037 },
1038 None,
1039 )
1040 .await?;
1041 if !resp.found {
1042 return Ok(None);
1043 }
1044 match resp.frame {
1045 Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
1046 None => Ok(None),
1047 }
1048 }
1049
1050 /// Eval JS against app-side WKWebView bridge. Does NOT use the
1051 /// XCUITest runner — POSTs directly to the app-side debug bridge on
1052 /// `127.0.0.1:28080/eval` (iOS sim shares host loopback). Returns
1053 /// the JS eval result as JSON Value or runtime error from the bridge.
1054 ///
1055 /// **Scope**: works for any app that exposes the
1056 /// `SmixWebViewBridge`. Bridge port is fixed at 28080 today.
1057 pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
1058 #[derive(Serialize)]
1059 struct Req<'a> {
1060 js: &'a str,
1061 }
1062 #[derive(Deserialize)]
1063 struct Resp {
1064 result: serde_json::Value,
1065 error: String,
1066 }
1067 let url = "http://127.0.0.1:28080/eval";
1068 let resp = reqwest::Client::new()
1069 .post(url)
1070 .json(&Req { js })
1071 .send()
1072 .await
1073 .map_err(|e| RunnerTransportError::Unreachable {
1074 endpoint: "webview-bridge".into(),
1075 message: format!("webview-bridge POST: {e}"),
1076 })?;
1077 let status = resp.status();
1078 let body: Resp = resp
1079 .json()
1080 .await
1081 .map_err(|e| RunnerTransportError::Unreachable {
1082 endpoint: "webview-bridge".into(),
1083 message: format!("webview-bridge JSON decode (status {status}): {e}"),
1084 })?;
1085 if !body.error.is_empty() {
1086 return Err(RunnerTransportError::Unreachable {
1087 endpoint: "webview-bridge".into(),
1088 message: format!("webview-bridge JS error: {}", body.error),
1089 });
1090 }
1091 Ok(body.result)
1092 }
1093
1094 /// `POST /double-tap` — XCUIElement.doubleTap() public API.
1095 /// Mirrors `/tap` envelope but issues two-tap gesture on the resolved
1096 /// element. Same as Maestro `doubleTapOn`.
1097 pub async fn double_tap(
1098 &self,
1099 selector: &Selector,
1100 include: Option<IncludeScope>,
1101 ) -> Result<TapResult, RunnerTransportError> {
1102 #[derive(Serialize)]
1103 struct Req<'a> {
1104 selector: &'a Selector,
1105 }
1106 self.json_post("/double-tap", &Req { selector }, include)
1107 .await
1108 }
1109
1110 /// `POST /long-press` — XCUIElement.press(forDuration:) public
1111 /// API. `duration_ms` comes from maestro yaml `duration:`, default 500.
1112 /// Same as Maestro `longPressOn`.
1113 pub async fn long_press(
1114 &self,
1115 selector: &Selector,
1116 duration_ms: u64,
1117 include: Option<IncludeScope>,
1118 ) -> Result<TapResult, RunnerTransportError> {
1119 #[derive(Serialize)]
1120 struct Req<'a> {
1121 selector: &'a Selector,
1122 #[serde(rename = "durationMs")]
1123 duration_ms: u64,
1124 }
1125 self.json_post(
1126 "/long-press",
1127 &Req {
1128 selector,
1129 duration_ms,
1130 },
1131 include,
1132 )
1133 .await
1134 }
1135
1136 /// `POST /set-orientation` — rotate sim via swift
1137 /// `XCUIDevice.shared.orientation` setter. `orientation` literal:
1138 /// "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight".
1139 pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
1140 #[derive(Serialize)]
1141 struct Req<'a> {
1142 orientation: &'a str,
1143 }
1144 let _: serde_json::Value = self
1145 .json_post("/set-orientation", &Req { orientation }, None)
1146 .await?;
1147 Ok(())
1148 }
1149
1150 /// `POST /swipe-at-norm-coord` — Apple native UI event from-to
1151 /// swipe. Sibling of [`Self::tap_at_norm_coord`] under §9 #3 escape hatch.
1152 pub async fn swipe_at_norm_coord(
1153 &self,
1154 from: (f64, f64),
1155 to: (f64, f64),
1156 ) -> Result<(), RunnerTransportError> {
1157 #[derive(Serialize)]
1158 struct Req {
1159 #[serde(rename = "fromNx")]
1160 from_nx: f64,
1161 #[serde(rename = "fromNy")]
1162 from_ny: f64,
1163 #[serde(rename = "toNx")]
1164 to_nx: f64,
1165 #[serde(rename = "toNy")]
1166 to_ny: f64,
1167 }
1168 let _: serde_json::Value = self
1169 .json_post(
1170 "/swipe-at-norm-coord",
1171 &Req {
1172 from_nx: from.0,
1173 from_ny: from.1,
1174 to_nx: to.0,
1175 to_ny: to.1,
1176 },
1177 None,
1178 )
1179 .await?;
1180 Ok(())
1181 }
1182
1183 /// `POST /find` — boolean existence quick-probe.
1184 pub async fn find(
1185 &self,
1186 selector: &Selector,
1187 include: Option<IncludeScope>,
1188 ) -> Result<bool, RunnerTransportError> {
1189 #[derive(Serialize)]
1190 struct Req<'a> {
1191 selector: &'a Selector,
1192 }
1193 #[derive(Deserialize)]
1194 struct Resp {
1195 /// Wire field name. iOS SmixRunner emits `found`.
1196 #[serde(default)]
1197 found: bool,
1198 /// Legacy alias — historical wire used `exists`.
1199 #[serde(default)]
1200 exists: bool,
1201 }
1202 // Previously read `exists || ok`, but `ok` is the transport-
1203 // level "runner responded 200" flag that is TRUE for both
1204 // found and not-found. That short-circuited the `find`
1205 // predicate to always-true, defeating when-visible logic.
1206 // SmixRunner emits `found`; historical wire had `exists`.
1207 // Accept either; do NOT fall back to `ok`.
1208 let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
1209 Ok(r.found || r.exists)
1210 }
1211
1212 /// `POST /fill` — fill text into focused / matched input.
1213 pub async fn fill(
1214 &self,
1215 selector: &Selector,
1216 text: &str,
1217 include: Option<IncludeScope>,
1218 ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1219 #[derive(Serialize)]
1220 struct Req<'a> {
1221 selector: &'a Selector,
1222 text: &'a str,
1223 }
1224 self.json_post("/fill", &Req { selector, text }, include)
1225 .await
1226 }
1227
1228 /// `POST /clear` — clear focused / matched input.
1229 pub async fn clear(
1230 &self,
1231 selector: &Selector,
1232 include: Option<IncludeScope>,
1233 ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1234 #[derive(Serialize)]
1235 struct Req<'a> {
1236 selector: &'a Selector,
1237 }
1238 self.json_post("/clear", &Req { selector }, include).await
1239 }
1240
1241 /// `POST /press-key` — press named key (Return/Tab/Delete/etc.).
1242 pub async fn press_key(
1243 &self,
1244 key: KeyName,
1245 ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1246 #[derive(Serialize)]
1247 struct Req {
1248 key: KeyName,
1249 }
1250 self.json_post("/press-key", &Req { key }, None).await
1251 }
1252
1253 /// `POST /scroll {selector, direction, include?}` — scroll-until-visible.
1254 pub async fn scroll(
1255 &self,
1256 selector: &RunnerScrollSelector,
1257 direction: SwipeDirection,
1258 include: Option<IncludeScope>,
1259 ) -> Result<u32, RunnerTransportError> {
1260 #[derive(Serialize)]
1261 struct Req<'a> {
1262 selector: &'a RunnerScrollSelector,
1263 direction: SwipeDirection,
1264 }
1265 #[derive(Deserialize)]
1266 struct Resp {
1267 #[serde(default)]
1268 matched: Option<bool>,
1269 #[serde(default)]
1270 swipes: Option<u32>,
1271 }
1272 let r: Resp = self
1273 .json_post(
1274 "/scroll",
1275 &Req {
1276 selector,
1277 direction,
1278 },
1279 include,
1280 )
1281 .await?;
1282 let matched = r.matched.unwrap_or(false);
1283 let swipes = r.swipes.unwrap_or(0);
1284 if !matched {
1285 // Driver layer is responsible for converting matched:false
1286 // to ExpectationFailure(ELEMENT_NOT_FOUND); here we surface
1287 // via MalformedBody when shape is missing entirely, otherwise
1288 // return the swipe count for the caller to inspect.
1289 //
1290 // We return swipes; driver wraps via RunnerScrollNotMatched.
1291 return Err(RunnerTransportError::MalformedBody {
1292 endpoint: "/scroll".into(),
1293 detail: format!("not_matched after {swipes} swipes"),
1294 });
1295 }
1296 Ok(swipes)
1297 }
1298
1299 /// `POST /swipe-once {direction}` — single swipe, no probe.
1300 pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
1301 #[derive(Serialize)]
1302 struct Req {
1303 direction: SwipeDirection,
1304 }
1305 let _: serde_json::Value = self
1306 .json_post("/swipe-once", &Req { direction }, None)
1307 .await?;
1308 Ok(())
1309 }
1310
1311 /// `POST /foreground {bundleId}` — bring app to foreground.
1312 pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
1313 #[derive(Serialize)]
1314 struct Req<'a> {
1315 #[serde(rename = "bundleId")]
1316 bundle_id: &'a str,
1317 }
1318 let _: serde_json::Value = self
1319 .json_post("/foreground", &Req { bundle_id }, None)
1320 .await?;
1321 Ok(())
1322 }
1323
1324 /// `POST /hide-keyboard`.
1325 pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
1326 let _: serde_json::Value = self
1327 .json_post("/hide-keyboard", &serde_json::json!({}), None)
1328 .await?;
1329 Ok(())
1330 }
1331
1332 /// `POST /back` — back gesture.
1333 pub async fn back(&self) -> Result<(), RunnerTransportError> {
1334 let _: serde_json::Value = self
1335 .json_post("/back", &serde_json::json!({}), None)
1336 .await?;
1337 Ok(())
1338 }
1339
1340 // ---- recorder ----
1341
1342 /// `POST /record/start` — begin recording.
1343 pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
1344 let _: serde_json::Value = self
1345 .json_post("/record/start", &serde_json::json!({}), None)
1346 .await?;
1347 Ok(())
1348 }
1349
1350 /// `GET /record/poll` — drain recorded events.
1351 pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1352 #[derive(Deserialize)]
1353 struct Envelope {
1354 events: Vec<RecordedEvent>,
1355 }
1356 let env: Envelope = self.json_get("/record/poll", None).await?;
1357 Ok(env.events)
1358 }
1359
1360 /// `POST /record/stop` — stop recording, drain final events.
1361 pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1362 #[derive(Deserialize)]
1363 struct Envelope {
1364 events: Vec<RecordedEvent>,
1365 }
1366 let env: Envelope = self
1367 .json_post("/record/stop", &serde_json::json!({}), None)
1368 .await?;
1369 Ok(env.events)
1370 }
1371}