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