Skip to main content

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}