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, 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}