From 58e42b50e223081846acb0a5e191bcedbb2496b5 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 12:38:22 +0200 Subject: [PATCH 1/8] feat: move to maestro 2.10.0 (maestro mcp keepers replace studio) Maestro 2.6 removed `maestro studio`, which kept the Android/iOS drivers warm and served the web session's HTTP API. The keepers now run `maestro mcp --no-viewer`, whose per-device session holds the driver for the life of the process: - Android: hierarchy/driver_keeper.rs (was studio.rs), gRPC on :7001 - iOS simulator: runner on :22087, same readiness probes - Web: commands via the MCP `run` tool, hierarchy via `inspect_screen`, live preview via a CDP screencast of the keeper's Chrome (shared with the run mirror in web_session/cdp.rs) Orphan sweeps are scoped by the global flags (--udid / --device / -p web) so a user's own `maestro mcp` is never killed. REQUIRED_MAESTRO is now 2.10.0; the step parser and completions learn the 2.9 dark-mode commands and the swipe relative-point suffix. Physical iPhones still need the patched 2.5.1 jars and are knowingly broken until the bridge is updated. --- README.md | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/device/ios.rs | 2 +- src-tauri/src/device/mod.rs | 2 +- src-tauri/src/env_check/mod.rs | 15 +- src-tauri/src/hierarchy/driver_keeper.rs | 375 ++++++ src-tauri/src/hierarchy/grpc_client.rs | 14 +- src-tauri/src/hierarchy/mod.rs | 11 +- src-tauri/src/hierarchy/studio.rs | 361 ------ src-tauri/src/hierarchy/web.rs | 7 +- src-tauri/src/input/web.rs | 35 +- src-tauri/src/ios_session/mod.rs | 200 ++- src-tauri/src/ipc/commands.rs | 78 +- src-tauri/src/lib.rs | 1 + src-tauri/src/maestro_mcp.rs | 369 ++++++ src-tauri/src/prockill.rs | 195 +-- src-tauri/src/runner/mod.rs | 8 +- src-tauri/src/sim_capture/mod.rs | 2 +- src-tauri/src/state.rs | 22 +- src-tauri/src/tool_paths.rs | 9 +- src-tauri/src/web_session/cdp.rs | 208 +++ src-tauri/src/web_session/mod.rs | 1135 +++++++---------- src-tauri/src/web_session/run_mirror.rs | 158 +-- src/components/QuitConfirmDialog.tsx | 2 +- src/components/SetupPopup.tsx | 6 +- src/components/ToolPathsSettings.tsx | 6 +- .../settings/DevicePerformanceSettings.tsx | 6 +- .../settings/EnvironmentSettings.tsx | 2 +- src/lib/chat/billy-prompt.md | 2 +- src/lib/maestro-commands.json | 4 + src/lib/runStepParser.test.ts | 35 + src/lib/runStepParser.ts | 17 +- src/stores/inspectorStore.ts | 2 +- src/stores/settingsStore.test.ts | 2 +- src/stores/settingsStore.ts | 4 +- 35 files changed, 1829 insertions(+), 1470 deletions(-) create mode 100644 src-tauri/src/hierarchy/driver_keeper.rs delete mode 100644 src-tauri/src/hierarchy/studio.rs create mode 100644 src-tauri/src/maestro_mcp.rs create mode 100644 src-tauri/src/web_session/cdp.rs diff --git a/README.md b/README.md index 91b57a4..9b0d95b 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Maestro is the YAML mobile-testing framework. Maestro Deck is the desktop app th | | Maestro Deck | Maestro Studio | Appium Inspector | | ----------------------- | ---------------------------------------------------- | ---------------------------- | -------------------------- | -| Install | Single signed app (DMG/MSI) | `maestro studio` (browser) | Java + Appium server setup | +| Install | Single signed app (DMG/MSI) | Separate desktop app | Java + Appium server setup | | Footprint | Native Tauri shell (~80 MB RAM idle, system webview) | Electron-based, ~400+ MB RAM | JVM + Chromium inspector | | Cost | Free, source-available (BUSL-1.1) | Free, closed source | Free, open source | | Live mirroring | ✅ scrcpy-grade, 60 fps | ⚠️ Periodic screenshots | ⚠️ Screenshot-based | diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6c3511c..5fc8ef1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,7 +90,7 @@ image = { version = "0.25", default-features = false, features = ["png"] } # gRPC client for talking directly to the on-device Maestro driver # (bypasses the slow `maestro hierarchy` CLI invocation path when the -# driver is kept alive by a background `maestro studio` process). +# driver is kept alive by a background `maestro mcp` process). tonic = { version = "0.12", default-features = false, features = ["codegen", "prost", "transport"] } prost = "0.13" zip = "2" diff --git a/src-tauri/src/device/ios.rs b/src-tauri/src/device/ios.rs index 90603d7..269b7c6 100644 --- a/src-tauri/src/device/ios.rs +++ b/src-tauri/src/device/ios.rs @@ -5,7 +5,7 @@ //! physical iPhones via `xcrun devicectl list devices`. Both surface as //! `Platform::Ios`; physical devices carry `physical: true` so the connect / //! keeper / screenshot paths select the `maestro-ios-device` bridge instead of -//! the simulator's `simctl` + `maestro studio`. +//! the simulator's `simctl` + `maestro mcp`. use std::collections::BTreeMap; use std::process::Command; diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index 7116cfa..4976a4e 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -31,7 +31,7 @@ pub struct Device { pub booted: bool, /// True only for physical iPhones (discovered via `devicectl`). Selects the /// `maestro-ios-device` bridge keeper + HTTP screenshot path instead of the - /// simulator's `simctl`/`maestro studio` path. False for Android, Web, and + /// simulator's `simctl`/`maestro mcp` path. False for Android, Web, and /// iOS simulators. #[serde(default)] pub physical: bool, diff --git a/src-tauri/src/env_check/mod.rs b/src-tauri/src/env_check/mod.rs index ecd845b..5b4dc2a 100644 --- a/src-tauri/src/env_check/mod.rs +++ b/src-tauri/src/env_check/mod.rs @@ -24,7 +24,7 @@ pub(crate) fn parse_java_major(out: &str) -> Option { use serde::Serialize; -pub(crate) const REQUIRED_MAESTRO: &str = "2.5.1"; +pub(crate) const REQUIRED_MAESTRO: &str = "2.10.0"; pub(crate) const MIN_JAVA_MAJOR: u32 = 17; #[derive(Serialize, Clone, Debug, PartialEq)] @@ -353,11 +353,16 @@ mod tests { } #[test] - fn maestro_251_is_ok_other_versions_are_wrong_version() { - assert_eq!(maestro_check(Some("2.5.1".into()), None).status, "ok"); - let c = maestro_check(Some("2.6.0".into()), None); + fn maestro_2_10_0_is_ok_other_versions_are_wrong_version() { + assert_eq!(maestro_check(Some("2.10.0".into()), None).status, "ok"); + // 2.5.1 still ships `maestro studio`, which the app no longer uses. + let c = maestro_check(Some("2.5.1".into()), None); assert_eq!(c.status, "wrong-version"); - assert_eq!(c.detail.as_deref(), Some("need 2.5.1")); + assert_eq!(c.detail.as_deref(), Some("need 2.10.0")); + assert_eq!( + maestro_check(Some("2.9.0".into()), None).status, + "wrong-version" + ); assert_eq!(maestro_check(None, None).status, "missing"); } diff --git a/src-tauri/src/hierarchy/driver_keeper.rs b/src-tauri/src/hierarchy/driver_keeper.rs new file mode 100644 index 0000000..e9e6bb8 --- /dev/null +++ b/src-tauri/src/hierarchy/driver_keeper.rs @@ -0,0 +1,375 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +//! Keep a background `maestro mcp` process alive so the on-device +//! driver stays installed and its gRPC server stays bound to port 7001. +//! `maestro mcp` caches one driver session per device: the warm-up tool +//! call below makes it install the driver APKs and start the +//! instrumentation, and the session then lives as long as the process. +//! (Maestro ≤ 2.5 did this through `maestro studio`, removed in 2.6.) +//! +//! Once `start()` returns, callers can connect a tonic client to +//! `http://127.0.0.1:7001` and issue `MaestroDriver` RPCs directly — +//! bypassing the slow one-shot `maestro hierarchy` CLI path entirely. +//! +//! Lifecycle: the returned `DriverKeeper` owns the child process. +//! `stop()` is the preferred graceful shutdown; dropping the keeper +//! falls back to `kill_on_drop(true)` (SIGKILL) to guarantee the +//! subprocess never outlives the app. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::net::TcpStream; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +use crate::error::{AppError, AppResult}; +use crate::maestro_mcp::McpClient; +use crate::process_ext::CommandExtNoWindow; + +/// The gRPC port the on-device Maestro driver binds to. The Maestro +/// CLI hardcodes this in `DefaultDriverHostPort`; we forward the same +/// port from localhost with `adb forward`. +pub const DRIVER_PORT: u16 = 7001; + +/// How long we wait for the driver to be installed and listening, after +/// the MCP handshake. Dominated by the APK install (~5-8 s) + the +/// instrumentation start; 40 s gives a comfortable margin on slow machines. +const DRIVER_READY_TIMEOUT: Duration = Duration::from_secs(40); +/// Poll interval while probing the on-device port. +const READY_POLL_INTERVAL: Duration = Duration::from_millis(250); +/// Budget for the warm-up tool call (driver install + first hierarchy). +const WARMUP_TIMEOUT: Duration = Duration::from_secs(120); + +/// Command-line needles of an Android keeper: `maestro --udid mcp +/// --no-viewer`. The `--udid` global flag is what tells our keepers apart +/// from a user's own `maestro mcp` server (see `maestro_mcp`). +const ANDROID_KEEPER_NEEDLES: &[&str] = &["maestro", "--udid", "mcp", "--no-viewer"]; + +pub struct DriverKeeper { + mcp: Arc, + serial: String, + /// Error from the warm-up tool call (device not found, install + /// refused…) — lets `await_ready` fail fast instead of timing out. + warmup_error: Arc>>, +} + +impl DriverKeeper { + /// Spawn `maestro --udid mcp` and wait until the gRPC driver + /// is listening on the device. Returns as soon as it is — the caller + /// can immediately issue RPCs against `localhost:7001`. + pub async fn start(serial: &str) -> AppResult { + // If a previous maestro-deck session was SIGKILLed or crashed, + // `kill_on_drop` never fires and the keeper outlives the app, + // holding a stale driver session that returns an empty + // `` blob — the user sees "Empty hierarchy" forever. + // Detect and cull the orphan before we spawn. + if is_port_listening(DRIVER_PORT).await { + warn!( + port = DRIVER_PORT, + "driver port already in use — killing orphan driver keepers" + ); + kill_orphan_keepers().await; + // Also nuke the on-device driver: after an abnormal shutdown + // (SIGKILL / crash / laptop sleep), the Android-side + // instrumentation can be in a zombie state where a fresh + // session will happily reattach but subsequent RPCs return + // empty (`bytes=84, has_root=false`). force-stop guarantees + // the on-device side starts cold. + force_stop_driver(serial).await; + remove_adb_forward(serial).await; + // Give the kernel a beat to release TIME_WAIT on the socket. + tokio::time::sleep(Duration::from_millis(250)).await; + } + + info!( + serial, + port = DRIVER_PORT, + "spawning maestro mcp to keep driver warm" + ); + let mcp = Arc::new(McpClient::spawn(&["--udid", serial], &[]).await?); + + // Maestro 2.5+ talks to the driver through an in-process + // adb-socket factory, so nothing is exposed on localhost:7001. + // Our gRPC client wants plain TCP, so we set up the forward + // ourselves. It's held by the adb-server, independent of any + // maestro process — `maestro test`'s adb-socket talks via a + // different path and won't collide with this forward. + let port_spec = format!("tcp:{DRIVER_PORT}"); + let adb = crate::device::adb::adb_bin(); + let _ = Command::new(&adb) + .no_window() + .args(["-s", serial, "forward", &port_spec, &port_spec]) + .output() + .await; + + let keeper = Self { + mcp, + serial: serial.to_string(), + warmup_error: Arc::default(), + }; + keeper.spawn_warmup(); + keeper.await_ready().await?; + Ok(keeper) + } + + /// Fire the tool call that opens the device session (installs + starts + /// the driver). Runs in the background: readiness is observed on the + /// device itself, and the call's result only matters if it fails. + fn spawn_warmup(&self) { + let mcp = self.mcp.clone(); + let err_slot = self.warmup_error.clone(); + let args = serde_json::json!({ "device_id": self.serial }); + tokio::spawn(async move { + if let Err(e) = mcp.call_tool("inspect_screen", args, WARMUP_TIMEOUT).await { + warn!(error = %e, "driver keeper warm-up failed"); + *err_slot.lock() = Some(e.to_string()); + } + }); + } + + /// Wait until the on-device instrumentation is actually serving on + /// port 7001. With our manual `adb forward`, a plain TCP connect to + /// `localhost:7001` would succeed immediately (adb-server holds the + /// listener) regardless of whether the device side is ready — so we + /// poll the device directly via `ss -tlnp`. Bails early if the keeper + /// exits or its warm-up call fails. + async fn await_ready(&self) -> AppResult<()> { + let deadline = Instant::now() + DRIVER_READY_TIMEOUT; + let start = Instant::now(); + let adb = crate::device::adb::adb_bin(); + + loop { + if Instant::now() >= deadline { + return Err(AppError::RunnerFailed(format!( + "the maestro driver did not open port {DRIVER_PORT} within {:?}", + DRIVER_READY_TIMEOUT + ))); + } + + if let Some(status) = self.mcp.exit_status().await { + warn!(?status, "maestro mcp exited before binding {}", DRIVER_PORT); + return Err(AppError::RunnerFailed(format!( + "maestro mcp exited before port {DRIVER_PORT} was ready (status: {status})" + ))); + } + if let Some(e) = self.warmup_error.lock().clone() { + return Err(AppError::RunnerFailed(format!( + "maestro could not start its driver on {}: {e}", + self.serial + ))); + } + + // Probe the on-device side: `ss -tlnp` lists listening TCP + // sockets. Port 7001 (0x1B59) appears in the local-addr + // column once the maestro instrumentation has bound it. + // We grep with both decimal and hex forms because `ss` on + // some Android builds renders local addr in hex (proc-net + // style: `0100007F:1B59`). + let listening = match Command::new(&adb) + .no_window() + .args(["-s", &self.serial, "shell", "ss", "-tln"]) + .output() + .await + { + Ok(out) => { + let s = String::from_utf8_lossy(&out.stdout); + s.lines().any(|line| { + line.contains(":7001 ") + || line.contains(":7001\t") + || line.contains(":1B59 ") + || line.contains(":1B59\t") + }) + } + Err(_) => false, + }; + + if listening { + info!( + elapsed_ms = start.elapsed().as_millis(), + "driver keeper ready — instrumentation listening on device {}", DRIVER_PORT + ); + return Ok(()); + } + + tokio::time::sleep(READY_POLL_INTERVAL).await; + } + } + + /// Graceful shutdown: kill the child, reap it, and tear down the + /// adb forward we set up. Safe to call multiple times (idempotent). + /// + /// Removing the adb forward is critical: SIGKILL'ing the keeper + /// leaves adb's own `tcp:7001 → device:7001` forward in place, so + /// `localhost:7001` still accepts TCP connections even though + /// there's no driver on the device side. Any subsequent code that + /// uses "is port 7001 open?" as a driver-readiness signal (our + /// `await_ready`, the maestro CLI's own pre-flight check) would + /// then race against a dead driver and fail mysteriously. Removing + /// the forward here guarantees the next path — whether that's a + /// CLI fallback or a fresh `DriverKeeper::start` — starts from a + /// genuinely empty state. + pub async fn stop(&self) { + debug!(serial = %self.serial, "stopping driver keeper"); + self.mcp.stop().await; + remove_adb_forward(&self.serial).await; + force_stop_driver(&self.serial).await; + } + + /// Soft pause: kill only the host-side `maestro mcp` subprocess. + /// The on-device driver (`dev.mobile.maestro` + `.test` instrumentation) + /// and the `adb forward tcp:7001` are intentionally left in place so a + /// concurrent `maestro test` can talk to the driver immediately + /// without paying a reinstall cost. + /// + /// Use this instead of `stop()` when you need the host-side keeper + /// out of the way (e.g. its dadb forwarder was conflicting with the + /// test process) but still want the device-side driver hot. + pub async fn pause(&self) { + debug!(serial = %self.serial, "pausing driver keeper (soft)"); + self.mcp.stop().await; + } + + pub fn serial(&self) -> &str { + &self.serial + } +} + +// Tokio's `Child` was spawned with `kill_on_drop(true)`, so when a +// `DriverKeeper` is dropped the runtime will SIGKILL the subprocess +// automatically — no explicit Drop impl needed here. + +/// Best-effort check whether something is already listening on `port` +/// on localhost. A successful connect means yes; any error means we +/// should try to spawn (and let the bind surface the real error). +async fn is_port_listening(port: u16) -> bool { + TcpStream::connect(("127.0.0.1", port)).await.is_ok() +} + +/// Package name of the on-device Maestro driver APK. Maestro installs +/// this package + a `.test` instrumentation to host the gRPC server. +/// Hardcoded because maestro hardcodes it too (see `maestro-android/…` +/// in mobile-dev-inc/maestro). +const DRIVER_PACKAGE: &str = "dev.mobile.maestro"; + +/// Nuke any in-memory state the on-device driver is holding. Kills both +/// the driver package and its test instrumentation, so the next spawn +/// (keeper or `maestro hierarchy` CLI) re-installs from scratch instead +/// of reattaching to a zombie instrumentation left over after sleep / +/// USB disconnect / crash. `force-stop` is idempotent and cheap (~100 ms) +/// so we call it unconditionally on stop. +async fn force_stop_driver(serial: &str) { + let bin = crate::device::adb::adb_bin(); + let test_pkg = format!("{DRIVER_PACKAGE}.test"); + for pkg in [DRIVER_PACKAGE, test_pkg.as_str()] { + let _ = Command::new(&bin) + .no_window() + .args(["-s", serial, "shell", "am", "force-stop", pkg]) + .output() + .await; + } +} + +/// Remove the host-side adb forward set up in `DriverKeeper::start`. +/// Idempotent — if no forward exists `adb forward --remove` returns +/// non-zero and we silently ignore it. We use `ADB_BIN` (matching the +/// `device::adb` module) so a user with a non-default adb path still +/// works. +async fn remove_adb_forward(serial: &str) { + let bin = crate::device::adb::adb_bin(); + let port_spec = format!("tcp:{DRIVER_PORT}"); + let _ = Command::new(&bin) + .no_window() + .args(["-s", serial, "forward", "--remove", &port_spec]) + .output() + .await; +} + +/// Kill any Android driver keeper lingering from a previous session +/// (`maestro --udid mcp --no-viewer`). Matched on the full +/// command line, so it catches the backing JVM too (its classpath +/// includes `maestro.cli.AppKt`), and never a user's own `maestro mcp` +/// (no `--udid`). iOS keepers use `--device`, so they are never hit. +async fn kill_orphan_keepers() { + crate::prockill::kill_matching(ANDROID_KEEPER_NEEDLES, "orphan Android driver keeper").await; +} + +/// Re-warm the driver keeper for the currently-connected device on a +/// background tokio task. Safe to call from anywhere with an +/// `AppHandle`; never blocks and never returns an error to the caller. +/// +/// Use this after a `maestro test` run completes so the next inspect +/// call hits the fast gRPC path instead of paying the ~10–15 s keeper +/// startup cost. +/// +/// If no device is connected when the task runs, it logs and returns — +/// the keeper is meaningless without a target device. +pub fn schedule_driver_restart(app: tauri::AppHandle) { + use tauri::Manager; + + tauri::async_runtime::spawn(async move { + let state = app.state::(); + let serial = match state.connected_device.read().as_ref() { + Some(d) => d.serial.clone(), + None => { + tracing::debug!("no device connected — skipping driver keeper restart"); + return; + } + }; + match DriverKeeper::start(&serial).await { + Ok(keeper) => { + *state.driver_keeper.lock().await = Some(std::sync::Arc::new(keeper)); + tracing::info!(serial = %serial, "driver keeper re-warmed after test"); + } + Err(e) => { + tracing::warn!(serial = %serial, error = ?e, "driver keeper restart failed"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Live end-to-end check on a connected Android device: the keeper + /// brings the driver up and a gRPC dump returns a real hierarchy. + /// MAESTRO_BIN=/path/to/maestro-2.10.0 ANDROID_SERIAL= cargo test \ + /// --manifest-path src-tauri/Cargo.toml android_keeper_end_to_end -- --ignored --nocapture + #[tokio::test(flavor = "multi_thread")] + #[ignore] + async fn android_keeper_end_to_end() { + let serial = std::env::var("ANDROID_SERIAL").expect("set ANDROID_SERIAL"); + let t = Instant::now(); + let keeper = DriverKeeper::start(&serial).await.expect("start keeper"); + eprintln!("driver ready in {:?}", t.elapsed()); + for n in 0..3 { + let t = Instant::now(); + let tree = crate::hierarchy::grpc_client::dump_hierarchy() + .await + .expect("gRPC dump"); + eprintln!( + "dump {n}: {} bytes in {:?}", + tree.xml_raw.len(), + t.elapsed() + ); + assert!(tree.root.is_some()); + } + keeper.stop().await; + assert!(!keeper.mcp.is_alive().await); + } + + #[test] + fn orphan_sweep_targets_only_android_keepers() { + use crate::prockill::cmdline_matches; + let jvm = "/usr/bin/java -classpath /opt/maestro/lib/* maestro.cli.AppKt --udid emulator-5554 mcp --no-viewer"; + assert!(cmdline_matches(jvm, ANDROID_KEEPER_NEEDLES)); + // A user's own MCP server (e.g. an AI assistant's) carries no --udid. + let user_mcp = "/usr/bin/java -classpath /opt/maestro/lib/* maestro.cli.AppKt mcp"; + assert!(!cmdline_matches(user_mcp, ANDROID_KEEPER_NEEDLES)); + // iOS simulator keepers use --device. + let ios = "/usr/bin/java -classpath /opt/maestro/lib/* maestro.cli.AppKt --device ABC mcp --no-viewer"; + assert!(!cmdline_matches(ios, ANDROID_KEEPER_NEEDLES)); + } +} diff --git a/src-tauri/src/hierarchy/grpc_client.rs b/src-tauri/src/hierarchy/grpc_client.rs index d2ea1ee..00537e5 100644 --- a/src-tauri/src/hierarchy/grpc_client.rs +++ b/src-tauri/src/hierarchy/grpc_client.rs @@ -3,7 +3,7 @@ //! Direct gRPC client for the on-device Maestro driver. //! -//! Once a background `maestro studio` process (see `studio` submodule) +//! Once a background `maestro mcp` process (see `driver_keeper` submodule) //! has installed + started the driver and set up the adb forward, //! `localhost:7001` exposes the `maestro_android.MaestroDriver` service. //! This module connects a tonic client to that endpoint and exposes a @@ -12,7 +12,7 @@ //! the two paths based on a user setting. //! //! Why this is fast: the CLI invocation pays JVM cold-start (~3 s) and -//! driver (re)install (~5-7 s) on every call. The studio process pays +//! driver (re)install (~5-7 s) on every call. The keeper process pays //! those once up-front; subsequent gRPC calls just roundtrip the //! accessibility-tree XML through an already-warm pipe. @@ -24,7 +24,7 @@ use tracing::{debug, info}; use crate::error::{AppError, AppResult}; use crate::hierarchy::proto::{maestro_driver_client::MaestroDriverClient, ViewHierarchyRequest}; -use crate::hierarchy::{parse_xml, studio::DRIVER_PORT, HierarchyTree}; +use crate::hierarchy::{driver_keeper::DRIVER_PORT, parse_xml, HierarchyTree}; /// Per-RPC deadline. The driver normally responds in <300 ms; 10 s is /// a generous ceiling that still fails fast if the driver hangs (e.g. @@ -35,7 +35,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); /// localhost so this should be sub-second. const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); /// A real UiAutomator dump is at least a few hundred bytes (header + -/// one window node with bounds). An orphan studio whose on-device +/// one window node with bounds). An orphan keeper whose on-device /// driver has died still accepts the RPC but replies with the bare /// `` wrapper (~84 bytes). Below this /// threshold we treat the response as "driver is a zombie" and fail @@ -48,9 +48,9 @@ async fn connect() -> AppResult> { .map_err(|e| AppError::HierarchyParse(format!("invalid driver uri {uri}: {e}")))? .connect_timeout(CONNECT_TIMEOUT) .timeout(RPC_TIMEOUT) - // Studio keeps the driver alive for the whole inspect session; + // The keeper holds the driver for the whole inspect session; // HTTP/2 keepalive lets us notice socket death (device unplug, - // studio crash) without waiting for the first failed RPC. + // keeper crash) without waiting for the first failed RPC. .keep_alive_while_idle(true) .http2_keep_alive_interval(Duration::from_secs(30)); @@ -65,7 +65,7 @@ async fn connect() -> AppResult> { /// return it in the same `HierarchyTree` shape the CLI path produces, /// so callers can drop-in swap between the two implementations. /// -/// Expects `studio::StudioKeeper::start` to have completed recently — +/// Expects `driver_keeper::DriverKeeper::start` to have completed recently — /// i.e. the driver is up, listening, and the adb forward is in place. pub async fn dump_hierarchy() -> AppResult { let overall_start = Instant::now(); diff --git a/src-tauri/src/hierarchy/mod.rs b/src-tauri/src/hierarchy/mod.rs index 75e3298..99b527c 100644 --- a/src-tauri/src/hierarchy/mod.rs +++ b/src-tauri/src/hierarchy/mod.rs @@ -8,7 +8,7 @@ //! React Native widgets, and accessibility metadata that raw `uiautomator dump` //! does not surface, and stays in sync with whatever Maestro CLI is installed. //! -//! Fast path (opt-in): spawn `maestro studio` once to install+start the +//! Fast path (opt-in): spawn a `maestro mcp` keeper once to install+start the //! on-device driver, then talk gRPC directly to it (port 7001 via adb //! forward). Bindings for that gRPC service are generated from //! `proto/maestro_android.proto` and re-exported via the `proto` submodule. @@ -16,10 +16,10 @@ //! `parse_xml` is kept for the unit-test fixture (UIAutomator XML format) //! and is also what consumes the driver's direct `ViewHierarchyResponse`. +pub mod driver_keeper; pub mod grpc_client; pub mod ios; pub mod proto; -pub mod studio; pub mod web; use std::collections::HashMap; @@ -111,9 +111,10 @@ pub fn dump_hierarchy(serial: &str) -> AppResult { // Each ADB install triggers MIUI/HyperOS's "Install via USB" // Accept/Refuse popup on Xiaomi devices, so per-dump sessions // turn inspect mode into a popup storm. With the flag, the - // driver installs once if missing and then persists; `maestro - // studio` (which has no such flag) still reinstalls on its own - // start, keeping the driver fresh after maestro upgrades. + // driver installs once if missing and then persists; the + // `maestro mcp` keeper (which has no such flag) still reinstalls + // on its own start, keeping the driver fresh after maestro + // upgrades. .args(["--udid", serial, "hierarchy", "--no-reinstall-driver"]) .output() .map_err(|e| { diff --git a/src-tauri/src/hierarchy/studio.rs b/src-tauri/src/hierarchy/studio.rs deleted file mode 100644 index bb77275..0000000 --- a/src-tauri/src/hierarchy/studio.rs +++ /dev/null @@ -1,361 +0,0 @@ -// Copyright (c) 2026 Ethan Morisset -// SPDX-License-Identifier: BUSL-1.1 - -//! Keep a background `maestro studio` process alive so the on-device -//! driver stays installed and its gRPC server stays bound to -//! `localhost:7001`. Studio does the heavy lifting (install driver APK, -//! start instrumentation, set up adb forward); we piggyback on it -//! to avoid reimplementing that flow ourselves. -//! -//! Once `start()` returns, callers can connect a tonic client to -//! `http://127.0.0.1:7001` and issue `MaestroDriver` RPCs directly — -//! bypassing the slow one-shot `maestro hierarchy` CLI path entirely. -//! -//! Lifecycle: the returned `StudioKeeper` owns the child process. -//! `stop()` is the preferred graceful shutdown; dropping the keeper -//! falls back to `kill_on_drop(true)` (SIGKILL) to guarantee the -//! subprocess never outlives the app. - -use std::process::Stdio; -use std::time::{Duration, Instant}; - -use tokio::net::TcpStream; -use tokio::process::{Child, Command}; -use tokio::sync::Mutex; -use tracing::{debug, info, warn}; - -use crate::error::{AppError, AppResult}; -use crate::process_ext::CommandExtNoWindow; - -/// The gRPC port the on-device Maestro driver binds to. The Maestro -/// CLI hardcodes this in `DefaultDriverHostPort` and sets up an adb -/// forward from `localhost:7001` to the same port on-device. -pub const DRIVER_PORT: u16 = 7001; - -/// How long we wait for `maestro studio` to install the driver APK, -/// start instrumentation, and open the forwarded port. Cold start is -/// dominated by JVM spin-up (~3 s) + APK install (~5-8 s); 30 s gives -/// a comfortable margin on slow machines. -const STUDIO_READY_TIMEOUT: Duration = Duration::from_secs(30); -/// Poll interval while probing the forwarded port. -const READY_POLL_INTERVAL: Duration = Duration::from_millis(250); - -pub struct StudioKeeper { - child: Mutex>, - serial: String, -} - -impl StudioKeeper { - /// Spawn `maestro --device studio` and wait until the - /// gRPC driver is reachable on `localhost:7001`. Returns as soon - /// as a TCP connection to the port succeeds — the caller can - /// immediately issue RPCs against it. - pub async fn start(serial: &str) -> AppResult { - // If a previous maestro-deck session was SIGKILLed or crashed, - // `kill_on_drop` never fires and the studio child outlives the - // app. On next launch, `await_ready` would connect instantly to - // that orphan's port 7001, but its on-device driver state is - // stale and returns an empty `` blob — the user - // sees "Empty hierarchy" forever. Detect and cull the orphan - // before we spawn so the new studio actually binds the port. - if is_port_listening(DRIVER_PORT).await { - warn!( - port = DRIVER_PORT, - "driver port already in use — killing orphan maestro studio" - ); - kill_orphan_studios().await; - // Also nuke the on-device driver: after an abnormal shutdown - // (SIGKILL / crash / laptop sleep), the Android-side - // instrumentation can be in a zombie state where a fresh - // studio will happily reattach but subsequent RPCs return - // empty (`bytes=84, has_root=false`). force-stop guarantees - // the on-device side starts cold. - force_stop_driver(serial).await; - remove_adb_forward(serial).await; - // Give the kernel a beat to release TIME_WAIT on the socket - // before the fresh studio tries to bind. - tokio::time::sleep(Duration::from_millis(250)).await; - } - - let bin = super::maestro_bin(); - info!( - serial, - bin, - port = DRIVER_PORT, - "spawning maestro studio to keep driver warm" - ); - - let mut cmd = Command::new(&bin); - cmd.no_window(); - // `--no-window` tells studio to skip opening a browser tab on - // start; we only need the side-effect (driver installed + - // gRPC port forwarded), not the web UI. - cmd.args(["--udid", serial, "studio", "--no-window"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .kill_on_drop(true); - - let child = cmd.spawn().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - AppError::RunnerNotFound - } else { - AppError::Io(e) - } - })?; - - // Maestro 2.5.x replaced the host-side `adb forward` with an - // in-process adb-socket factory, so studio no longer exposes - // anything on localhost:7001. Our gRPC client still wants plain - // TCP, so we set up the forward ourselves. It's held by the - // adb-server, independent of any maestro process — `maestro - // test`'s adb-socket talks via a different path and won't - // collide with this forward. - let port_spec = format!("tcp:{DRIVER_PORT}"); - let adb = crate::device::adb::adb_bin(); - let _ = Command::new(&adb) - .no_window() - .args(["-s", serial, "forward", &port_spec, &port_spec]) - .output() - .await; - - let keeper = Self { - child: Mutex::new(Some(child)), - serial: serial.to_string(), - }; - - keeper.await_ready().await?; - Ok(keeper) - } - - /// Wait until the on-device instrumentation is actually serving on - /// port 7001. With our manual `adb forward`, a plain TCP connect to - /// `localhost:7001` would succeed immediately (adb-server holds the - /// listener) regardless of whether the device side is ready — so we - /// poll the device directly via `ss -tlnp`. Bails early if studio - /// exits prematurely. - async fn await_ready(&self) -> AppResult<()> { - let deadline = Instant::now() + STUDIO_READY_TIMEOUT; - let start = Instant::now(); - let adb = crate::device::adb::adb_bin(); - - loop { - if Instant::now() >= deadline { - return Err(AppError::RunnerFailed(format!( - "maestro studio did not open port {DRIVER_PORT} within {:?}", - STUDIO_READY_TIMEOUT - ))); - } - - if let Some(child) = self.child.lock().await.as_mut() { - if let Ok(Some(status)) = child.try_wait() { - warn!( - ?status, - "maestro studio exited before binding {}", DRIVER_PORT - ); - return Err(AppError::RunnerFailed(format!( - "maestro studio exited before port {DRIVER_PORT} was ready (status: {status})" - ))); - } - } - - // Probe the on-device side: `ss -tlnp` lists listening TCP - // sockets. Port 7001 (0x1B59) appears in the local-addr - // column once the maestro instrumentation has bound it. - // We grep with both decimal and hex forms because `ss` on - // some Android builds renders local addr in hex (proc-net - // style: `0100007F:1B59`). - let listening = match Command::new(&adb) - .no_window() - .args(["-s", &self.serial, "shell", "ss", "-tln"]) - .output() - .await - { - Ok(out) => { - let s = String::from_utf8_lossy(&out.stdout); - s.lines().any(|line| { - line.contains(":7001 ") - || line.contains(":7001\t") - || line.contains(":1B59 ") - || line.contains(":1B59\t") - }) - } - Err(_) => false, - }; - - if listening { - info!( - elapsed_ms = start.elapsed().as_millis(), - "maestro studio ready — instrumentation listening on device {}", DRIVER_PORT - ); - return Ok(()); - } - - tokio::time::sleep(READY_POLL_INTERVAL).await; - } - } - - /// Graceful shutdown: kill the child, reap it, and tear down the - /// adb forward the studio set up. Safe to call multiple times - /// (idempotent). - /// - /// Removing the adb forward is critical: SIGKILL'ing the studio - /// leaves adb's own `tcp:7001 → device:7001` forward in place, so - /// `localhost:7001` still accepts TCP connections even though - /// there's no driver on the device side. Any subsequent code that - /// uses "is port 7001 open?" as a driver-readiness signal (our - /// `await_ready`, the maestro CLI's own pre-flight check) would - /// then race against a dead driver and fail mysteriously. Removing - /// the forward here guarantees the next path — whether that's a - /// CLI fallback or a fresh `StudioKeeper::start` — starts from a - /// genuinely empty state. - pub async fn stop(&self) { - if let Some(mut child) = self.child.lock().await.take() { - debug!(serial = %self.serial, "stopping maestro studio"); - let _ = child.kill().await; - let _ = child.wait().await; - } - remove_adb_forward(&self.serial).await; - force_stop_driver(&self.serial).await; - } - - /// Soft pause: kill only the host-side `maestro studio` subprocess. - /// The on-device driver (`dev.mobile.maestro` + `.test` instrumentation) - /// and the `adb forward tcp:7001` are intentionally left in place so a - /// concurrent `maestro test` can talk to the driver immediately - /// without paying a reinstall cost. - /// - /// Use this instead of `stop()` when you need the host-side studio - /// out of the way (e.g. its dadb forwarder was conflicting with the - /// test process) but still want the device-side driver hot. - pub async fn pause(&self) { - if let Some(mut child) = self.child.lock().await.take() { - debug!(serial = %self.serial, "pausing maestro studio (soft)"); - let _ = child.kill().await; - let _ = child.wait().await; - } - } - - pub fn serial(&self) -> &str { - &self.serial - } -} - -// Tokio's `Child` was spawned with `kill_on_drop(true)`, so when a -// `StudioKeeper` is dropped the runtime will SIGKILL the subprocess -// automatically — no explicit Drop impl needed here. - -/// Best-effort check whether something is already listening on `port` -/// on localhost. A successful connect means yes; any error means we -/// should try to spawn (and let the bind surface the real error). -async fn is_port_listening(port: u16) -> bool { - TcpStream::connect(("127.0.0.1", port)).await.is_ok() -} - -/// Package name of the on-device Maestro driver APK. Maestro installs -/// this package + a `.test` instrumentation to host the gRPC server. -/// Hardcoded because maestro hardcodes it too (see `maestro-android/…` -/// in mobile-dev-inc/maestro). -const DRIVER_PACKAGE: &str = "dev.mobile.maestro"; - -/// Nuke any in-memory state the on-device driver is holding. Kills both -/// the driver package and its test instrumentation, so the next spawn -/// (studio or `maestro hierarchy` CLI) re-installs from scratch instead -/// of reattaching to a zombie instrumentation left over after sleep / -/// USB disconnect / crash. `force-stop` is idempotent and cheap (~100 ms) -/// so we call it unconditionally on stop. -async fn force_stop_driver(serial: &str) { - let bin = crate::device::adb::adb_bin(); - let test_pkg = format!("{DRIVER_PACKAGE}.test"); - for pkg in [DRIVER_PACKAGE, test_pkg.as_str()] { - let _ = Command::new(&bin) - .no_window() - .args(["-s", serial, "shell", "am", "force-stop", pkg]) - .output() - .await; - } -} - -/// Remove the host-side adb forward that `maestro studio` set up. -/// Idempotent — if no forward exists `adb forward --remove` returns -/// non-zero and we silently ignore it. We use `ADB_BIN` (matching the -/// `device::adb` module) so a user with a non-default adb path still -/// works. -async fn remove_adb_forward(serial: &str) { - let bin = crate::device::adb::adb_bin(); - let port_spec = format!("tcp:{DRIVER_PORT}"); - let _ = Command::new(&bin) - .no_window() - .args(["-s", serial, "forward", "--remove", &port_spec]) - .output() - .await; -} - -/// Kill any `maestro studio` process lingering from a previous session. -/// Matches against the full command line via `pgrep -f` so it catches -/// both the CLI wrapper and the backing JVM (its classpath includes -/// `maestro.cli.AppKt`). Unix-only for now — Tauri targets macOS/Linux -/// first, and the app is not yet shipped on Windows. -async fn kill_orphan_studios() { - #[cfg(unix)] - { - let Ok(output) = Command::new("pgrep") - .args(["-f", "maestro.*studio"]) - .output() - .await - else { - return; - }; - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let Ok(pid) = line.trim().parse::() else { - continue; - }; - warn!(pid, "SIGKILL orphan maestro studio"); - let _ = Command::new("kill") - .args(["-9", &pid.to_string()]) - .output() - .await; - } - } -} - -/// Re-warm the `maestro studio` keeper for the currently-connected -/// device on a background tokio task. Safe to call from anywhere with an -/// `AppHandle`; never blocks and never returns an error to the caller. -/// -/// Use this after a `maestro test` run completes so the next inspect -/// call hits the fast gRPC path instead of paying the ~10–15 s studio -/// startup cost. -/// -/// If no device is connected when the task runs, it logs and returns — -/// the studio is meaningless without a target device. -pub fn schedule_studio_restart(app: tauri::AppHandle) { - use tauri::Manager; - - tauri::async_runtime::spawn(async move { - let state = app.state::(); - let serial = match state.connected_device.read().as_ref() { - Some(d) => d.serial.clone(), - None => { - tracing::debug!("no device connected — skipping studio restart"); - return; - } - }; - match StudioKeeper::start(&serial).await { - Ok(keeper) => { - *state.studio.lock().await = Some(std::sync::Arc::new(keeper)); - tracing::info!(serial = %serial, "studio re-warmed after test"); - } - Err(e) => { - tracing::warn!(serial = %serial, error = ?e, "studio restart failed"); - } - } - }); -} - -/// Public wrapper so the web session can reuse studio orphan-killing. -/// Matches any lingering `maestro studio` (mobile or web) — acceptable for V1. -pub async fn kill_orphan_studios_pub() { - kill_orphan_studios().await; -} diff --git a/src-tauri/src/hierarchy/web.rs b/src-tauri/src/hierarchy/web.rs index 67f209a..11afa6c 100644 --- a/src-tauri/src/hierarchy/web.rs +++ b/src-tauri/src/hierarchy/web.rs @@ -1,9 +1,10 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 -//! Web hierarchy: Maestro Studio's `device-screen` SSE event carries a **flat -//! list** of elements (`{id, bounds:{x,y,width,height}, resourceId?, text?}`), -//! not the nested `{attributes, children}` TreeNode the mobile drivers emit. +//! Web hierarchy: the web keeper (`web_session`) flattens `inspect_screen` +//! into a **flat list** of selector targets +//! (`{bounds:{x,y,width,height}, resourceId?, text?}`), not the nested +//! `{attributes, children}` TreeNode the mobile drivers emit. //! We wrap that list under a synthetic root so the existing R-tree, //! hit-testing, overlay, and selector ranking work unchanged. diff --git a/src-tauri/src/input/web.rs b/src-tauri/src/input/web.rs index afdfaf0..ca07eca 100644 --- a/src-tauri/src/input/web.rs +++ b/src-tauri/src/input/web.rs @@ -1,7 +1,7 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 -//! Web input forwarding via Maestro Studio's `POST /api/run-command`. +//! Web input forwarding via the web keeper's MCP `run` tool. //! Frontend coords arrive in screenshot-pixel space; we convert taps to //! percentage points (`x%,y%`) which Maestro resolves independent of the //! browser viewport size. @@ -10,7 +10,7 @@ use serde::Deserialize; use super::InputEvent; use crate::error::AppResult; -use crate::web_session::WebStudioKeeper; +use crate::web_session::WebDriverKeeper; /// Clamp a 0..=100 percentage with one decimal of precision. fn pct(coord: f32, span: u16) -> f32 { @@ -75,7 +75,7 @@ fn element_selector_at(elements: &serde_json::Value, cx: i32, cy: i32) -> Option /// second SSE consumer, no wait); falls back to a percentage point tap when /// no element resolves or no snapshot is available. async fn tap_command( - keeper: &WebStudioKeeper, + keeper: &WebDriverKeeper, x: f32, y: f32, screen_w: u16, @@ -106,14 +106,6 @@ async fn tap_command( } } -/// Build the run-command body. Maestro Studio expects `{ "yaml": "", -/// "dryRun": bool }` where `` is a SINGLE command line -/// (`": "`) — NOT a flow list. A leading `- ` is rejected with -/// 400 "Invalid command format". -fn command_body(yaml: String) -> serde_json::Value { - serde_json::json!({ "yaml": yaml, "dryRun": false }) -} - fn tap_yaml(x_pct: f32, y_pct: f32) -> String { format!("tapOn: {{point: \"{x_pct}%,{y_pct}%\"}}") } @@ -166,20 +158,19 @@ pub(crate) fn resolve_tap( pub async fn send( event: &InputEvent, - keeper: &WebStudioKeeper, + keeper: &WebDriverKeeper, screen_w: u16, screen_h: u16, app: &tauri::AppHandle, ) -> AppResult<()> { use tauri::Emitter; - let http = keeper.http(); match event { InputEvent::Tap { x, y } => { let tap = tap_command(keeper, *x, *y, screen_w, screen_h).await; if tap.degraded { let _ = app.emit("web:tap_fallback", ()); } - http.run_command(command_body(tap.yaml)).await + keeper.run_command(&tap.yaml).await } InputEvent::Swipe { x1, @@ -195,12 +186,9 @@ pub async fn send( pct(*y2, screen_h), *duration_ms, ); - http.run_command(command_body(yaml)).await - } - InputEvent::Text { text } => { - http.run_command(command_body(format!("inputText: {text}"))) - .await + keeper.run_command(&yaml).await } + InputEvent::Text { text } => keeper.run_command(&format!("inputText: {text}")).await, // No general key-injection over the web driver in V1 (Android-only). InputEvent::Key { .. } => Ok(()), } @@ -222,13 +210,6 @@ mod tests { assert_eq!(pct(100.0, 0), 0.0); } - #[test] - fn command_body_wraps_yaml_with_dryrun() { - let b = command_body("inputText: hi".to_string()); - assert_eq!(b["yaml"], "inputText: hi"); - assert_eq!(b["dryRun"], false); - } - #[test] fn resolves_smallest_element_id_preferred() { let v = serde_json::json!([ @@ -262,7 +243,7 @@ mod tests { #[test] fn commands_are_single_line_not_flows() { - // Studio rejects a leading "- " (flow list) with 400. + // `inline_flow` adds the "- " list marker itself. let tap = tap_yaml(50.0, 50.0); assert_eq!(tap, "tapOn: {point: \"50%,50%\"}"); assert!(!tap.starts_with("- ")); diff --git a/src-tauri/src/ios_session/mod.rs b/src-tauri/src/ios_session/mod.rs index 31ccb6a..581f7a3 100644 --- a/src-tauri/src/ios_session/mod.rs +++ b/src-tauri/src/ios_session/mod.rs @@ -1,8 +1,8 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 -//! iOS Simulator driver session: boots a simulator and lets `maestro studio` -//! install/launch/hold the on-device XCTest HTTP server, which on a simulator +//! iOS Simulator driver session: boots a simulator and lets a `maestro mcp` +//! keeper install/launch/hold the on-device XCTest HTTP server, which on a simulator //! is reachable directly on `127.0.0.1:22087` (the sim shares the host network — //! no forwarding/tunnel). Exposes a typed HTTP client for `/viewHierarchy`, //! `/touch`, `/inputText`, `/swipeV2`, `/screenshot`, `/deviceInfo`, `/status`. @@ -260,43 +260,37 @@ const READY_ATTEMPTS: u32 = 360; const READY_BACKOFF_MS: u64 = 500; /// Passed to maestro as MAESTRO_DRIVER_STARTUP_TIMEOUT so it doesn't give up on /// a cold simulator before the driver is ready (ms). Matched to our own ~180 s -/// readiness budget above — at the old 120 s, maestro studio threw +/// readiness budget above — at the old 120 s, maestro threw /// IOSDriverTimeoutException while we were still happily waiting. const DRIVER_STARTUP_TIMEOUT_MS: &str = "180000"; +/// Budget for the keeper's warm-up tool call (runner install + first hierarchy). +const WARMUP_TIMEOUT: Duration = Duration::from_secs(200); + +/// Global flags that put the UDID in the keeper's command line +/// (`maestro --device mcp --no-viewer`) — the MCP server ignores +/// them, but they scope the orphan sweep to this simulator. +fn keeper_global_args(udid: &str) -> [&str; 2] { + ["--device", udid] +} -fn studio_args(udid: &str) -> Vec<&str> { - vec!["--device", udid, "studio", "--no-window"] +/// Command-line needles of the simulator keeper for `udid`. +fn keeper_needles(udid: &str) -> [String; 4] { + [ + "maestro".to_string(), + format!("--device {udid}"), + "mcp".to_string(), + "--no-viewer".to_string(), + ] } -/// SIGKILL orphan `maestro … --device … studio` processes left behind -/// by a crashed/SIGKILLed session (their `kill_on_drop` never ran). Scoped to -/// this UDID so a legitimate Android studio keeper is never touched. Unix-only -/// (`pgrep`/`kill`); simulators only exist on macOS anyway. -async fn kill_orphan_studios_for(udid: &str) { - #[cfg(unix)] - { - let pattern = format!("maestro.*--device {udid}.*studio"); - let Ok(output) = Command::new("pgrep").args(["-f", &pattern]).output().await else { - return; - }; - for line in String::from_utf8_lossy(&output.stdout).lines() { - let Ok(pid) = line.trim().parse::() else { - continue; - }; - warn!( - pid, - udid, "SIGKILL orphan maestro studio (stale iOS driver)" - ); - let _ = Command::new("kill") - .args(["-9", &pid.to_string()]) - .output() - .await; - } - } - #[cfg(not(unix))] - { - let _ = udid; - } +/// SIGKILL orphan simulator keepers (`maestro --device mcp`) left +/// behind by a crashed/SIGKILLed session (their `kill_on_drop` never ran). +/// Scoped to this UDID so a legitimate Android keeper — or a user's own +/// `maestro mcp` — is never touched. +async fn kill_orphan_keepers_for(udid: &str) { + let needles = keeper_needles(udid); + let needles: Vec<&str> = needles.iter().map(String::as_str).collect(); + crate::prockill::kill_matching(&needles, "orphan iOS simulator keeper").await; } /// SIGKILL orphan `maestro-ios-device … --device ` bridges left behind by @@ -333,7 +327,7 @@ async fn kill_orphan_bridges_for(udid: &str) { /// Which kind of iOS target a keeper drives. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IosTarget { - /// Booted simulator: `simctl boot` + `maestro studio`, driver on :22087, + /// Booted simulator: `simctl boot` + `maestro mcp`, driver on :22087, /// screenshots via `simctl`. Simulator, /// Physical iPhone: the `maestro-ios-device` bridge builds/runs the runner @@ -342,16 +336,20 @@ enum IosTarget { Physical, } -/// Holds the on-device XCTest runner alive (`maestro studio` for simulators, +/// Holds the on-device XCTest runner alive (`maestro mcp` for simulators, /// `maestro-ios-device` for physical devices) and owns the HTTP client + cached /// DeviceInfo for the session. pub struct IosDriverKeeper { udid: String, http: IosHttpClient, device_info: parking_lot::RwLock>, - /// The supervised bridge child: `maestro studio` (sim) or - /// `maestro-ios-device` (physical). + /// Simulators: the `maestro mcp` keeper holding the runner session. + mcp: Option>, + /// Physical devices: the supervised `maestro-ios-device` bridge child. driver_child: AsyncMutex>, + /// Error from the simulator keeper's warm-up call (e.g. device not + /// found) — lets `wait_until_ready` fail fast. + warmup_error: Arc>>, target: IosTarget, /// Last time a `/status` probe succeeded (see [`Self::is_healthy`]). health_checked: parking_lot::Mutex>, @@ -382,8 +380,8 @@ impl IosDriverKeeper { self.device_info.read().is_some() } - /// True while the ON-DEVICE runner still answers `/status`. The bridge - /// process (`maestro studio`) can outlive the XCTest runner it launched — + /// True while the ON-DEVICE runner still answers `/status`. The keeper + /// process (`maestro mcp`) can outlive the XCTest runner it launched — /// the JVM stays up while nothing listens on :22087 anymore, so a keeper /// that only checks `is_process_alive` becomes a permanent zombie (every /// tap / Home press fails with "driver unreachable" forever). A warming @@ -421,18 +419,19 @@ impl IosDriverKeeper { } } - /// Boot the simulator and spawn `maestro studio` (installs/launches the XCTest - /// runner on :22087). + /// Boot the simulator and spawn a `maestro mcp` keeper, then open its + /// device session in the background (installs/launches the XCTest runner + /// on :22087). async fn spawn_simulator(udid: &str) -> AppResult> { - // After a crash / SIGKILL, `kill_on_drop` never fires and the studio + // After a crash / SIGKILL, `kill_on_drop` never fires and the keeper // JVM (which holds the XCTest driver on :22087) outlives the app. // Stale instances then fight the fresh one for the driver, and the // inspector hangs on a zombie runner for the whole readiness budget. - // Any studio targeting this UDID at spawn time is an orphan (the + // Any keeper targeting this UDID at spawn time is an orphan (the // keeper for the current session is stopped before respawning), so - // cull them first — mirrors `hierarchy::studio::kill_orphan_studios` - // on the Android path. - kill_orphan_studios_for(udid).await; + // cull them first — mirrors `hierarchy::driver_keeper` on the + // Android path. + kill_orphan_keepers_for(udid).await; // Boot the sim (idempotent — `simctl boot` errors if already booted, ignored). let _ = Command::new("xcrun") @@ -440,25 +439,41 @@ impl IosDriverKeeper { .output() .await; - let maestro = crate::tool_paths::maestro_bin(); - let studio = Command::new(&maestro) - .args(studio_args(udid)) - .env("MAESTRO_DRIVER_STARTUP_TIMEOUT", DRIVER_STARTUP_TIMEOUT_MS) - .kill_on_drop(true) - .spawn() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - AppError::RunnerNotFound - } else { - AppError::IosCommandFailed(format!("maestro studio: {e}")) + let mcp = Arc::new( + crate::maestro_mcp::McpClient::spawn( + &keeper_global_args(udid), + &[("MAESTRO_DRIVER_STARTUP_TIMEOUT", DRIVER_STARTUP_TIMEOUT_MS)], + ) + .await + .map_err(|e| match e { + AppError::RunnerNotFound => e, + other => AppError::IosCommandFailed(format!("maestro mcp: {other}")), + })?, + ); + + // Opening the session is what installs + launches the runner. It can + // take minutes on a cold simulator, so it runs in the background — + // `wait_until_ready` observes the runner itself on :22087. + let warmup_error: Arc>> = Arc::default(); + { + let mcp = mcp.clone(); + let slot = warmup_error.clone(); + let args = serde_json::json!({ "device_id": udid }); + tokio::spawn(async move { + if let Err(e) = mcp.call_tool("inspect_screen", args, WARMUP_TIMEOUT).await { + warn!(error = %e, "iOS simulator keeper warm-up failed"); + *slot.lock() = Some(e.to_string()); } - })?; + }); + } Ok(Arc::new(Self { udid: udid.to_string(), http: IosHttpClient::new(DRIVER_PORT)?, device_info: parking_lot::RwLock::new(None), - driver_child: AsyncMutex::new(Some(studio)), + mcp: Some(mcp), + driver_child: AsyncMutex::new(None), + warmup_error, target: IosTarget::Simulator, health_checked: parking_lot::Mutex::new(None), })) @@ -474,7 +489,7 @@ impl IosDriverKeeper { "Set your Apple Team ID in Settings to drive a physical iOS device".into(), ) })?; - // Same zombie problem as simulator studios: after a crash/SIGKILL the + // Same zombie problem as simulator keepers: after a crash/SIGKILL the // bridge outlives the app AND keeps its port. Each leftover instance // holds 600x, so a fresh bridge binds the NEXT free port (observed: 9 // orphans on 6001-6009) while our HTTP client polls PHYSICAL_BRIDGE_PORT @@ -503,7 +518,9 @@ impl IosDriverKeeper { udid: udid.to_string(), http: IosHttpClient::new(PHYSICAL_BRIDGE_PORT)?, device_info: parking_lot::RwLock::new(None), + mcp: None, driver_child: AsyncMutex::new(Some(bridge)), + warmup_error: Arc::default(), target: IosTarget::Physical, health_checked: parking_lot::Mutex::new(None), })) @@ -517,12 +534,16 @@ impl IosDriverKeeper { return true; } for attempt in 0..READY_ATTEMPTS { - // Bail out promptly if `maestro studio` exited (e.g. the session was - // torn down on disconnect/run) instead of probing a dead socket for - // the full budget. + // Bail out promptly if the keeper exited (e.g. the session was torn + // down on disconnect/run) or failed to open the device, instead of + // probing a dead socket for the full budget. if !self.is_process_alive().await { return false; } + if let Some(e) = self.warmup_error.lock().clone() { + warn!(udid = %self.udid, error = %e, "iOS driver keeper failed to start"); + return false; + } if self.http.status().await { if let Ok(di) = self.http.device_info().await { *self.device_info.write() = Some(di); @@ -541,16 +562,22 @@ impl IosDriverKeeper { } pub async fn stop(&self) { + if let Some(mcp) = &self.mcp { + mcp.stop().await; + } if let Some(mut c) = self.driver_child.lock().await.take() { let _ = c.kill().await; } // Leave the simulator booted for reuse (no-op for physical devices). } - /// True while the bridge child (`maestro studio` / `maestro-ios-device`) is - /// still running (regardless of driver readiness). Used to decide whether a + /// True while the keeper (`maestro mcp` / `maestro-ios-device`) is still + /// running (regardless of driver readiness). Used to decide whether a /// cached keeper is reusable. pub async fn is_process_alive(&self) -> bool { + if let Some(mcp) = &self.mcp { + return mcp.is_alive().await; + } match self.driver_child.lock().await.as_mut() { Some(child) => matches!(child.try_wait(), Ok(None)), None => false, @@ -779,11 +806,44 @@ mod tests { assert_eq!(s, r#"{"button":"home"}"#); } + /// Live end-to-end check on a booted simulator: the `maestro mcp` keeper + /// brings the XCTest runner up and its hierarchy parses. + /// MAESTRO_BIN=/path/to/maestro-2.10.0 IOS_UDID= cargo test \ + /// --manifest-path src-tauri/Cargo.toml ios_sim_keeper_end_to_end -- --ignored --nocapture + #[tokio::test(flavor = "multi_thread")] + #[ignore] + async fn ios_sim_keeper_end_to_end() { + let udid = std::env::var("IOS_UDID").expect("set IOS_UDID"); + let t = std::time::Instant::now(); + let keeper = IosDriverKeeper::spawn(&udid, false).await.expect("spawn"); + assert!(keeper.wait_until_ready().await, "driver never became ready"); + let di = keeper.device_info().expect("device info"); + eprintln!("ready in {:?}: {di:?}", t.elapsed()); + let t = std::time::Instant::now(); + let json = keeper.http().view_hierarchy().await.expect("hierarchy"); + let screen = (di.width_points as i32, di.height_points as i32); + let tree = crate::hierarchy::ios::parse_ios_axelement(&json, Some(screen)).expect("parse"); + eprintln!("hierarchy {} bytes in {:?}", json.len(), t.elapsed()); + assert!(tree.root.is_some_and(|r| !r.children.is_empty())); + assert!(keeper.is_healthy().await); + keeper.stop().await; + assert!(!keeper.is_process_alive().await); + } + #[test] - fn builds_studio_args() { - assert_eq!( - studio_args("UDID-1"), - vec!["--device", "UDID-1", "studio", "--no-window"] + fn keeper_command_line_is_scoped_to_its_udid() { + let args = crate::maestro_mcp::mcp_args(&keeper_global_args("UDID-1")); + assert_eq!(args, vec!["--device", "UDID-1", "mcp", "--no-viewer"]); + let jvm = format!( + "java -classpath /opt/maestro/lib/* maestro.cli.AppKt {}", + args.join(" ") ); + let needles = keeper_needles("UDID-1"); + let needles: Vec<&str> = needles.iter().map(String::as_str).collect(); + assert!(crate::prockill::cmdline_matches(&jvm, &needles)); + let other = jvm.replace("UDID-1", "UDID-2"); + assert!(!crate::prockill::cmdline_matches(&other, &needles)); + let user_mcp = "java -classpath /opt/maestro/lib/* maestro.cli.AppKt mcp"; + assert!(!crate::prockill::cmdline_matches(user_mcp, &needles)); } } diff --git a/src-tauri/src/ipc/commands.rs b/src-tauri/src/ipc/commands.rs index 34daf2f..629113a 100644 --- a/src-tauri/src/ipc/commands.rs +++ b/src-tauri/src/ipc/commands.rs @@ -153,9 +153,9 @@ pub async fn connect_device( } Platform::Web => { // `url` is read from the open flow's `url:` header on the frontend - // and navigated to on a fresh studio spawn. Seed the remembered - // page so an early respawn restores it even before the first SSE - // event lands. + // and navigated to on a fresh keeper spawn. Seed the remembered + // page so an early respawn restores it even before the first + // preview frame lands. if let Some(u) = url.as_deref() { *state.web_last_url.write() = Some(u.to_string()); } @@ -396,15 +396,15 @@ async fn setup_scrcpy(serial: &str, app: AppHandle, state: &AppState) { #[tauri::command] pub async fn disconnect_device(state: State<'_, AppState>) -> AppResult<()> { - // Keep a simulator's studio warm so reconnecting the same sim is fast. + // Keep a simulator's keeper warm so reconnecting the same sim is fast. teardown_all_sessions(state.inner(), true).await; Ok(()) } /// Tear down every running session and its spawned subprocesses: the -/// background `maestro studio` keeper plus the connected platform's stream / +/// background `maestro mcp` driver keeper plus the connected platform's stream / /// driver / browser. Shared by `disconnect_device` and the quit handler so a -/// fast Cmd+Q doesn't leave orphaned studio / chromedriver / Chrome / iproxy +/// fast Cmd+Q doesn't leave orphaned maestro / chromedriver / Chrome / iproxy /// processes behind. pub async fn teardown_all_sessions(state: &AppState, keep_ios_sim_warm: bool) { let (serial, platform) = { @@ -418,11 +418,11 @@ pub async fn teardown_all_sessions(state: &AppState, keep_ios_sim_warm: bool) { *state.last_hierarchy.write() = None; *state.spatial_index.write() = None; - // Tear down the background studio process if one was spawned for + // Tear down the background driver keeper if one was spawned for // fast-hierarchy mode — it holds an adb forward + instrumentation // session that must be released before another device can take // over the forwarded port. - if let Some(keeper) = state.studio.lock().await.take() { + if let Some(keeper) = state.driver_keeper.lock().await.take() { keeper.stop().await; } @@ -470,9 +470,9 @@ async fn teardown_scrcpy(serial: &str, state: &AppState) { } /// Tear down the iOS session: stop the screenshot poller, the native preview -/// stream (if active), and the keeper (kills `maestro studio` + `iproxy`). +/// stream (if active), and the keeper (kills `maestro mcp` + `iproxy`). /// `keep_sim_warm`: on a plain disconnect we leave a **simulator** keeper's -/// `maestro studio` running so reconnecting the same booted sim reuses the +/// `maestro mcp` running so reconnecting the same booted sim reuses the /// already-installed XCTest driver (seconds instead of the ~1-2 min cold /// start). The keeper is retired later by `ensure_ios_keeper` (different /// device) or by `connect_device` (switching to a non-iOS target). On quit we @@ -494,13 +494,13 @@ async fn teardown_ios(state: &AppState, keep_sim_warm: bool) { } } -/// Ensure a `WebStudioKeeper` is running, returning it. Respawns if the cached +/// Ensure a `WebDriverKeeper` is running, returning it. Respawns if the cached /// keeper has died. `url` is navigated to on a fresh spawn (from the open flow). async fn ensure_web_keeper( url: Option<&str>, app: Option<&AppHandle>, state: &AppState, -) -> AppResult> { +) -> AppResult> { use std::sync::atomic::Ordering::SeqCst; if state.web_run_active.load(SeqCst) { return Err(AppError::Other( @@ -510,12 +510,12 @@ async fn ensure_web_keeper( )); } let mut slot = state.web_driver.lock().await; - // Liveness = the Studio HTTP API answering (sub-second), NOT a - // device-screen SSE event: the SSE stream stalls on busy/navigating - // pages, and treating that as "dead" tore down the whole session - // (Chrome relaunch + reload onto Studio's SPA) on every hiccup. + // Liveness = keeper process up + its Chrome answering DevTools + // (sub-second), NOT a fresh hierarchy: `inspect_screen` stalls on + // busy/navigating pages, and treating that as "dead" would tear down + // the whole session (Chrome relaunch + reload) on every hiccup. let alive = match slot.as_ref() { - Some(k) => k.http().api_ready().await, + Some(k) => k.is_alive().await, None => false, }; if !alive { @@ -530,16 +530,10 @@ async fn ensure_web_keeper( tokio::time::sleep(std::time::Duration::from_secs(delay)).await; } // Respawn on the page the user was on: explicit url (connect) wins, - // else the last real page seen by the poller — never Studio's SPA. + // else the last real page seen by the poller — never a blank tab. let remembered = state.web_last_url.read().clone(); let effective = url.or(remembered.as_deref()); - match crate::web_session::WebStudioKeeper::start( - crate::web_session::STUDIO_PORT, - effective, - app, - ) - .await - { + match crate::web_session::WebDriverKeeper::start(effective, app).await { Ok(keeper) => { state.web_respawn_fails.store(0, SeqCst); *slot = Some(keeper); @@ -554,7 +548,7 @@ async fn ensure_web_keeper( } /// Tear down the web session: stop the poller, the run mirror (if a run is -/// in flight) and kill the studio process. +/// in flight) and kill the keeper process. async fn teardown_web(state: &AppState) { if let Some(abort) = state.web_screenshot_abort.lock().await.take() { let _ = abort.send(()); @@ -650,11 +644,11 @@ pub async fn enter_inspect_mode( .await .ok(); - // Fast path: reuse a long-lived `maestro studio` subprocess that + // Fast path: reuse a long-lived `maestro mcp` keeper that // keeps the on-device driver installed and listening on port 7001, // then talk gRPC directly to fetch the hierarchy. First call pays - // the studio startup cost (~10-15 s); subsequent calls return in - // <500 ms. Falls back to the CLI path if studio fails to start or + // the keeper startup cost (~10-15 s); subsequent calls return in + // <500 ms. Falls back to the CLI path if the keeper fails to start or // the gRPC RPC itself errors, so the app stays usable even when // the fast path is broken on a given setup. if fast_mode { @@ -696,7 +690,7 @@ async fn ensure_ios_keeper( .unwrap_or(false); let mut slot = state.ios_driver.lock().await; // Reuse the keeper for the same device while its bridge process - // (`maestro studio` / `maestro-ios-device`) is still running — even if the + // (`maestro mcp` / `maestro-ios-device`) is still running — even if the // driver isn't *ready* yet (it may be warming). Respawn for a different // device, a dead process, OR a zombie bridge whose on-device runner died // (JVM alive, nothing listening on :22087 — `is_healthy` probes /status @@ -708,7 +702,7 @@ async fn ensure_ios_keeper( if !reuse { // A simulator run owns the :22087 driver exclusively. Re-warming a // keeper now (e.g. an inspector auto-dump or a tap) would spawn a - // second `maestro studio` that fights `maestro test` for the driver — + // second `maestro mcp` session that fights `maestro test` for the driver — // and both hang forever. Refuse until the run clears the flag. if !physical && state @@ -728,13 +722,13 @@ async fn ensure_ios_keeper( Ok(slot.as_ref().unwrap().clone()) } -/// Fast-mode helper: ensure a `maestro studio` keeper is running for +/// Fast-mode helper: ensure a driver keeper is running for /// the given device, then fetch the hierarchy over gRPC. Reuses an -/// existing keeper if one is already up to avoid paying studio's +/// existing keeper if one is already up to avoid paying its /// 10-15 s startup cost on every inspect call. async fn dump_via_grpc(serial: &str, state: &AppState) -> AppResult { { - let mut slot = state.studio.lock().await; + let mut slot = state.driver_keeper.lock().await; let needs_spawn = match slot.as_ref() { Some(k) => k.serial() != serial, None => true, @@ -743,7 +737,7 @@ async fn dump_via_grpc(serial: &str, state: &AppState) -> AppResult AppResult Ok(tree), Err(e @ AppError::StaleDriver(_)) => { - // Driver is a zombie (orphan studio from a previous session, + // Driver is a zombie (orphan keeper from a previous session, // or on-device instrumentation died after sleep/wake). Drop // the keeper so the next call respawns cleanly via the - // orphan-kill path in `StudioKeeper::start`. - if let Some(existing) = state.studio.lock().await.take() { + // orphan-kill path in `DriverKeeper::start`. + if let Some(existing) = state.driver_keeper.lock().await.take() { existing.stop().await; } Err(e) @@ -1043,7 +1037,7 @@ pub async fn run_flow( if device.platform == crate::device::Platform::Web { // Web flows run with no `--udid`; maestro targets the browser via the // flow's `url:` header, in its own HEADLESS Chrome — which coexists - // fine with the studio keeper's hidden browser (verified live). Keep + // fine with the web keeper's hidden browser (verified live). Keep // the keeper warm for instant post-run recovery; pause only its // preview poller and mirror the run's Chrome over CDP instead, so the // canvas shows the test executing live. @@ -1101,7 +1095,7 @@ pub async fn run_flow( ) .await; } - // iOS simulator: `maestro --udid test`. Stop the studio keeper + // iOS simulator: `maestro --udid test`. Stop the driver keeper // first — it holds the XCTest driver on :22087, which `maestro test` // needs to bring up itself; running both contends for the simulator. // But KEEP the screenshot poller running: it captures the framebuffer @@ -1141,9 +1135,9 @@ pub async fn run_flow( .await .ok(); - // With maestro 2.5.x, `maestro test` uses an adb-socket + // Since maestro 2.5, `maestro test` uses an adb-socket // (AdbSocketFactory) instead of a host TCP forward, so it cohabits - // peacefully with our running studio. No cleanup needed. + // peacefully with our running driver keeper. No cleanup needed. runner::spawn_runner(app, &serial, &file_path, app_id, None).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a8e4c5..70514c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ pub mod ios_capture; pub mod ios_session; pub mod ipc; pub mod maestro_health; +pub mod maestro_mcp; pub mod metrics; pub mod onboarding; pub mod process_ext; diff --git a/src-tauri/src/maestro_mcp.rs b/src-tauri/src/maestro_mcp.rs new file mode 100644 index 0000000..52d6a63 --- /dev/null +++ b/src-tauri/src/maestro_mcp.rs @@ -0,0 +1,369 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +//! Long-lived `maestro mcp` child speaking JSON-RPC (MCP) over stdio. +//! +//! Maestro 2.6 removed `maestro studio`, which we used to keep a device +//! driver warm (Android gRPC on :7001, iOS XCTest on :22087) and, on web, as +//! the browser's HTTP API. `maestro mcp` is its replacement: it caches one +//! driver session per device for as long as the process lives, so the first +//! tool call targeting a device installs/starts its driver and every later +//! call — or any direct client of that driver — reuses it. +//! +//! The server exits when its stdin closes, so the client holds stdin for its +//! whole life. `--no-viewer` skips the Maestro Viewer HTTP server (it would +//! otherwise grab a local port we never use). +//! +//! Global flags (`--device `, `-p web`) are placed before `mcp`: the +//! server ignores them, but they land in the process command line, which is +//! what lets orphan sweeps target *our* keeper for one device without ever +//! touching a user's own `maestro mcp` (e.g. an AI assistant's). + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{oneshot, Mutex as AsyncMutex}; +use tracing::{debug, warn}; + +use crate::error::{AppError, AppResult}; +use crate::process_ext::CommandExtNoWindow; + +/// `initialize` is answered once the JVM is up (~2-3 s); a cold machine can +/// be much slower, so be generous. +const INIT_TIMEOUT: Duration = Duration::from_secs(60); + +type Pending = Arc>>>>; + +pub struct McpClient { + child: AsyncMutex>, + stdin: AsyncMutex>, + pending: Pending, + next_id: AtomicU64, + /// Last stderr lines, to explain an early exit. + stderr_tail: Arc>>, +} + +/// Command-line arguments for `maestro mcp --no-viewer`. +pub fn mcp_args(global_args: &[&str]) -> Vec { + global_args + .iter() + .map(|s| s.to_string()) + .chain(["mcp".to_string(), "--no-viewer".to_string()]) + .collect() +} + +/// Text of the first content item of a `tools/call` result, and whether the +/// tool flagged it as an error. +fn tool_result_text(result: &Value) -> (String, bool) { + let text = result["content"] + .as_array() + .and_then(|items| items.iter().find_map(|i| i["text"].as_str())) + .unwrap_or_default() + .to_string(); + let is_error = result["isError"].as_bool().unwrap_or(false); + (text, is_error) +} + +/// Route one stdout line to the request waiting for it. Non-JSON lines +/// (kotlin-logging prints a banner on stdout) and notifications are ignored. +fn route_line(line: &str, pending: &Pending) { + let Ok(msg) = serde_json::from_str::(line) else { + return; + }; + let Some(id) = msg["id"].as_u64() else { + return; + }; + let Some(tx) = pending.lock().remove(&id) else { + return; + }; + let outcome = if let Some(err) = msg.get("error") { + Err(err["message"] + .as_str() + .unwrap_or("unknown MCP error") + .to_string()) + } else { + Ok(msg["result"].clone()) + }; + let _ = tx.send(outcome); +} + +impl McpClient { + /// Spawn `maestro mcp --no-viewer` and complete the MCP + /// handshake. No device is touched until the first tool call. + pub async fn spawn(global_args: &[&str], envs: &[(&str, &str)]) -> AppResult { + let bin = crate::tool_paths::maestro_bin(); + let mut cmd = Command::new(&bin); + cmd.no_window() + .args(mcp_args(global_args)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + for (k, v) in envs { + cmd.env(k, v); + } + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + AppError::RunnerNotFound + } else { + AppError::Io(e) + } + })?; + + let pending: Pending = Arc::default(); + let stderr_tail: Arc>> = Arc::default(); + + if let Some(out) = child.stdout.take() { + let pending = pending.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(out).lines(); + while let Ok(Some(line)) = lines.next_line().await { + route_line(&line, &pending); + } + // EOF: the server is gone — fail every in-flight request. + for (_, tx) in pending.lock().drain() { + let _ = tx.send(Err("maestro mcp exited".into())); + } + }); + } + if let Some(err) = child.stderr.take() { + let tail = stderr_tail.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(err).lines(); + while let Ok(Some(line)) = lines.next_line().await { + debug!(target: "maestro_mcp", "{line}"); + let mut t = tail.lock(); + t.push(line); + if t.len() > 20 { + t.remove(0); + } + } + }); + } + + let client = Self { + stdin: AsyncMutex::new(child.stdin.take()), + child: AsyncMutex::new(Some(child)), + pending, + next_id: AtomicU64::new(1), + stderr_tail, + }; + + client + .request( + "initialize", + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "maestro-deck", "version": env!("CARGO_PKG_VERSION") }, + }), + INIT_TIMEOUT, + ) + .await?; + client + .send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })) + .await?; + Ok(client) + } + + async fn send(&self, msg: &Value) -> AppResult<()> { + let mut line = msg.to_string(); + line.push('\n'); + let mut guard = self.stdin.lock().await; + let stdin = guard + .as_mut() + .ok_or_else(|| AppError::RunnerFailed("maestro mcp is stopped".into()))?; + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| AppError::RunnerFailed(format!("maestro mcp stdin: {e}")))?; + stdin + .flush() + .await + .map_err(|e| AppError::RunnerFailed(format!("maestro mcp stdin: {e}"))) + } + + async fn request(&self, method: &str, params: Value, timeout: Duration) -> AppResult { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().insert(id, tx); + let sent = self + .send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + if let Err(e) = sent { + self.pending.lock().remove(&id); + return Err(e); + } + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(Ok(v))) => Ok(v), + Ok(Ok(Err(msg))) => Err(AppError::RunnerFailed(self.explain(&msg))), + Ok(Err(_)) => Err(AppError::RunnerFailed(self.explain("maestro mcp exited"))), + Err(_) => { + self.pending.lock().remove(&id); + Err(AppError::RunnerFailed(format!( + "maestro mcp: `{method}` timed out after {timeout:?}" + ))) + } + } + } + + /// Append the tail of stderr to a failure message — the only clue when + /// the JVM dies on startup (bad Java, broken install…). + fn explain(&self, msg: &str) -> String { + let tail = self.stderr_tail.lock(); + let last = tail + .iter() + .rev() + .find(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()); + match last { + Some(l) if msg.contains("exited") => format!("{msg}: {l}"), + _ => msg.to_string(), + } + } + + /// Call an MCP tool and return its text output. A tool-level failure + /// (`isError: true`) becomes `RunnerFailed` carrying the tool's message. + pub async fn call_tool(&self, name: &str, args: Value, timeout: Duration) -> AppResult { + let result = self + .request( + "tools/call", + json!({ "name": name, "arguments": args }), + timeout, + ) + .await?; + let (text, is_error) = tool_result_text(&result); + if is_error { + return Err(AppError::RunnerFailed(text)); + } + Ok(text) + } + + /// True while the `maestro mcp` process is running. + pub async fn is_alive(&self) -> bool { + match self.child.lock().await.as_mut() { + Some(child) => matches!(child.try_wait(), Ok(None)), + None => false, + } + } + + /// Exit status if the process has already exited. + pub async fn exit_status(&self) -> Option { + self.child + .lock() + .await + .as_mut() + .and_then(|c| c.try_wait().ok().flatten()) + } + + /// Kill the process. Idempotent. The device-side driver is left as-is + /// (a SIGKILLed server never runs its session teardown). + pub async fn stop(&self) { + self.stdin.lock().await.take(); + if let Some(mut child) = self.child.lock().await.take() { + if let Err(e) = child.kill().await { + warn!(error = %e, "maestro mcp kill failed"); + } + let _ = child.wait().await; + } + } + + /// A client with no process behind it (every call fails). + #[cfg(test)] + pub fn stub_for_tests() -> Self { + Self { + child: AsyncMutex::new(None), + stdin: AsyncMutex::new(None), + pending: Arc::default(), + next_id: AtomicU64::new(1), + stderr_tail: Arc::default(), + } + } + + /// PID of the running process, if any. + pub async fn pid(&self) -> Option { + self.child.lock().await.as_ref().and_then(|c| c.id()) + } +} + +/// Wrap bare commands into the inline flow `run` expects: MCP rejects YAML +/// without a config section ("Config Section Required"). The `appId` value +/// is irrelevant for web and for commands that don't target an app. +pub fn inline_flow(app_id: &str, commands: &[String]) -> String { + let mut yaml = format!("appId: {app_id}\n---\n"); + for c in commands { + yaml.push_str("- "); + yaml.push_str(c); + yaml.push('\n'); + } + yaml +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_mcp_args_with_global_flags_first() { + assert_eq!( + mcp_args(&["--device", "ABC"]), + vec!["--device", "ABC", "mcp", "--no-viewer"] + ); + assert_eq!(mcp_args(&[]), vec!["mcp", "--no-viewer"]); + } + + #[test] + fn extracts_tool_text_and_error_flag() { + // Shapes captured from maestro 2.10.0 `maestro mcp`. + let ok = json!({"content":[{"text":"{\"success\":true}","type":"text"}],"isError":false}); + assert_eq!(tool_result_text(&ok), ("{\"success\":true}".into(), false)); + let err = json!({"content":[{"text":"Failed to run flow: Element not found","type":"text"}],"isError":true}); + assert_eq!( + tool_result_text(&err), + ("Failed to run flow: Element not found".into(), true) + ); + assert_eq!(tool_result_text(&json!({})), (String::new(), false)); + } + + #[test] + fn routes_responses_by_id_and_ignores_noise() { + let pending: Pending = Arc::default(); + let (tx, mut rx) = oneshot::channel(); + pending.lock().insert(7, tx); + route_line("kotlin-logging: initializing...", &pending); + route_line(r#"{"jsonrpc":"2.0","method":"notifications/x"}"#, &pending); + route_line(r#"{"jsonrpc":"2.0","id":99,"result":{}}"#, &pending); + assert!( + rx.try_recv().is_err(), + "unrelated lines must not resolve id 7" + ); + route_line(r#"{"jsonrpc":"2.0","id":7,"result":{"ok":1}}"#, &pending); + assert_eq!(rx.try_recv().unwrap().unwrap(), json!({"ok":1})); + } + + #[test] + fn routes_jsonrpc_errors() { + let pending: Pending = Arc::default(); + let (tx, mut rx) = oneshot::channel(); + pending.lock().insert(3, tx); + route_line( + r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"no such method"}}"#, + &pending, + ); + assert_eq!(rx.try_recv().unwrap().unwrap_err(), "no such method"); + } + + #[test] + fn wraps_commands_in_inline_flow() { + assert_eq!( + inline_flow("web", &["tapOn: \"OK\"".into(), "inputText: hi".into()]), + "appId: web\n---\n- tapOn: \"OK\"\n- inputText: hi\n" + ); + } +} diff --git a/src-tauri/src/prockill.rs b/src-tauri/src/prockill.rs index 611e069..83623e3 100644 --- a/src-tauri/src/prockill.rs +++ b/src-tauri/src/prockill.rs @@ -1,9 +1,9 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 -//! Cross-platform "kill processes matching a command line" + "who owns this -//! port" helpers. Used only by the web session. Unlike the Unix-only -//! `pgrep`-based sweeps elsewhere, matching happens in Rust over a full +//! Cross-platform "kill processes matching a command line" + "find a +//! process's descendants" helpers, used by the maestro keepers' orphan +//! sweeps and the web session. Matching happens in Rust over a full //! process listing, so the exact same semantics apply on macOS, Linux and //! Windows — and the matcher is unit-testable without spawning anything. @@ -30,11 +30,11 @@ pub fn cmdline_matches(cmdline: &str, needles: &[&str]) -> bool { true } -/// One `(pid, command line)` row of the process table. -async fn list_processes() -> Vec<(u32, String)> { +/// One `(pid, parent pid, command line)` row per process. +async fn list_processes_with_parents() -> Vec<(u32, u32, String)> { #[cfg(unix)] let output = tokio::process::Command::new("ps") - .args(["-axo", "pid=,command="]) + .args(["-axo", "pid=,ppid=,command="]) .output() .await; #[cfg(windows)] @@ -44,7 +44,7 @@ async fn list_processes() -> Vec<(u32, String)> { "-NoProfile", "-Command", // CSV keeps parsing dependency-free (no JSON shape surprises). - "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)\u{1f}$($_.CommandLine)\" }", + "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)\u{1f}$($_.ParentProcessId)\u{1f}$($_.CommandLine)\" }", ]) .output() .await; @@ -54,19 +54,69 @@ async fn list_processes() -> Vec<(u32, String)> { }; String::from_utf8_lossy(&output.stdout) .lines() - .filter_map(|line| { - #[cfg(unix)] - { - let t = line.trim_start(); - let (pid, cmd) = t.split_once(' ')?; - Some((pid.trim().parse().ok()?, cmd.trim().to_string())) - } - #[cfg(windows)] - { - let (pid, cmd) = line.split_once('\u{1f}')?; - Some((pid.trim().parse().ok()?, cmd.trim().to_string())) - } + .filter_map(parse_process_row) + .collect() +} + +fn parse_process_row(line: &str) -> Option<(u32, u32, String)> { + #[cfg(unix)] + { + let t = line.trim_start(); + let (pid, rest) = t.split_once(' ')?; + let rest = rest.trim_start(); + let (ppid, cmd) = rest.split_once(' ').unwrap_or((rest, "")); + Some(( + pid.trim().parse().ok()?, + ppid.trim().parse().ok()?, + cmd.trim().to_string(), + )) + } + #[cfg(windows)] + { + let mut parts = line.splitn(3, '\u{1f}'); + let pid = parts.next()?.trim().parse().ok()?; + let ppid = parts.next()?.trim().parse().ok()?; + Some((pid, ppid, parts.next().unwrap_or("").trim().to_string())) + } +} + +/// One `(pid, command line)` row of the process table. +async fn list_processes() -> Vec<(u32, String)> { + list_processes_with_parents() + .await + .into_iter() + .map(|(pid, _, cmd)| (pid, cmd)) + .collect() +} + +/// True if `pid` descends from `ancestor` (at any depth) in `table`. +fn descends_from(table: &[(u32, u32, String)], mut pid: u32, ancestor: u32) -> bool { + // Bounded walk: a pid-reuse cycle in a stale table must not spin forever. + for _ in 0..32 { + let Some((_, ppid, _)) = table.iter().find(|(p, _, _)| *p == pid) else { + return false; + }; + if *ppid == ancestor { + return true; + } + if *ppid == 0 || *ppid == pid { + return false; + } + pid = *ppid; + } + false +} + +/// `(pid, command line)` of every descendant of `ancestor` whose command line +/// matches `needles` (in order). +pub async fn descendants_matching(ancestor: u32, needles: &[&str]) -> Vec<(u32, String)> { + let table = list_processes_with_parents().await; + table + .iter() + .filter(|(pid, _, cmd)| { + cmdline_matches(cmd, needles) && descends_from(&table, *pid, ancestor) }) + .map(|(pid, _, cmd)| (*pid, cmd.clone())) .collect() } @@ -108,84 +158,57 @@ pub async fn kill_matching(needles: &[&str], reason: &str) { } } -/// The process LISTENing on a local TCP port, if any. -pub struct PortOwner { - pub pid: u32, - pub cmdline: String, -} - -pub async fn port_owner(port: u16) -> Option { - #[cfg(unix)] - let pid: u32 = { - let out = tokio::process::Command::new("lsof") - .args([ - "-nP", - &format!("-iTCP:{port}"), - "-sTCP:LISTEN", - "-t", // terse: PIDs only - ]) - .output() - .await - .ok()?; - String::from_utf8_lossy(&out.stdout) - .lines() - .next()? - .trim() - .parse() - .ok()? - }; - #[cfg(windows)] - let pid: u32 = { - let out = tokio::process::Command::new("powershell") - .no_window() - .args([ - "-NoProfile", - "-Command", - &format!( - "(Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess" - ), - ]) - .output() - .await - .ok()?; - String::from_utf8_lossy(&out.stdout).trim().parse().ok()? - }; - - let cmdline = list_processes() - .await - .into_iter() - .find(|(p, _)| *p == pid) - .map(|(_, c)| c) - .unwrap_or_default(); - Some(PortOwner { pid, cmdline }) -} - #[cfg(test)] mod tests { use super::*; - // Real command lines observed on this machine (2026-07-10). - const IOS_STUDIO: &str = "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java -classpath /opt/homebrew/Cellar/maestro/2.5.1/libexec/lib/* maestro.cli.AppKt --device 1D5972C2-89CA-4F33-AE3D-7A9A8CD6094C studio --no-window"; - const WEB_STUDIO: &str = "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java -classpath /opt/homebrew/Cellar/maestro/2.5.1/libexec/lib/* maestro.cli.AppKt -p web studio --no-window"; + // Real command lines observed on this machine (2026-09-24, maestro 2.10.0). + const IOS_KEEPER: &str = "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java --enable-native-access=ALL-UNNAMED -classpath /opt/homebrew/Cellar/maestro/2.10.0/libexec/lib/* maestro.cli.AppKt --device 1D5972C2-89CA-4F33-AE3D-7A9A8CD6094C mcp --no-viewer"; + const WEB_KEEPER: &str = "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java --enable-native-access=ALL-UNNAMED -classpath /opt/homebrew/Cellar/maestro/2.10.0/libexec/lib/* maestro.cli.AppKt -p web mcp --no-viewer"; #[test] fn ordered_needles_all_present_matches() { - assert!(cmdline_matches( - WEB_STUDIO, - &["maestro", "-p web", "studio"] - )); + assert!(cmdline_matches(WEB_KEEPER, &["maestro", "-p web", "mcp"])); } #[test] - fn web_needles_never_match_an_ios_studio() { + fn web_needles_never_match_an_ios_keeper() { // THE regression this module exists to prevent: a web sweep must not - // catch a mobile studio session. - assert!(!cmdline_matches( - IOS_STUDIO, - &["maestro", "-p web", "studio"] - )); - // …but a generic studio needle set matches both. - assert!(cmdline_matches(IOS_STUDIO, &["maestro", "studio"])); + // catch a mobile keeper session. + assert!(!cmdline_matches(IOS_KEEPER, &["maestro", "-p web", "mcp"])); + // …but a generic mcp needle set matches both. + assert!(cmdline_matches(IOS_KEEPER, &["maestro", "mcp"])); + } + + #[test] + fn walks_the_parent_chain() { + // java(10) → chromedriver(20) → chrome(30); unrelated(40) under init. + let table = vec![ + (10, 1, "java".to_string()), + (20, 10, "chromedriver".to_string()), + (30, 20, "chrome".to_string()), + (40, 1, "chrome".to_string()), + ]; + assert!(descends_from(&table, 30, 10)); + assert!(descends_from(&table, 20, 10)); + assert!(!descends_from(&table, 40, 10)); + assert!(!descends_from(&table, 10, 10)); + assert!(!descends_from(&table, 99, 10)); + } + + #[cfg(unix)] + #[test] + fn parses_ps_rows_with_parent() { + assert_eq!( + parse_process_row(" 123 45 /usr/bin/java -cp x maestro.cli.AppKt mcp"), + Some(( + 123, + 45, + "/usr/bin/java -cp x maestro.cli.AppKt mcp".to_string() + )) + ); + assert_eq!(parse_process_row(" 7 1"), Some((7, 1, String::new()))); + assert_eq!(parse_process_row("garbage"), None); } #[test] diff --git a/src-tauri/src/runner/mod.rs b/src-tauri/src/runner/mod.rs index 215ed3b..e1ab840 100644 --- a/src-tauri/src/runner/mod.rs +++ b/src-tauri/src/runner/mod.rs @@ -189,7 +189,7 @@ pub async fn spawn_runner( }; RUNNERS.lock().await.remove(&pid); // Fire the optional post-exit hook BEFORE emitting the exit event. - // The hook may schedule background work (e.g. studio restart) that + // The hook may schedule background work (e.g. keeper restart) that // we want kicked off as early as possible — the frontend doesn't // need to wait for it, since the hook just spawns and returns. if let Some(hook) = on_exit_hook { @@ -308,7 +308,7 @@ pub async fn spawn_web_runner( }; RUNNERS.lock().await.remove(&pid); // Run finished — release the keeper, stop the CDP mirror and hand - // the canvas back to the (still-warm) studio preview. + // the canvas back to the (still-warm) keeper preview. { use tauri::Manager; let state = app_exit.state::(); @@ -318,7 +318,7 @@ pub async fn spawn_web_runner( if let Some(mirror) = state.web_run_mirror_abort.lock().await.take() { let _ = mirror.send(()); } - // Resume the studio preview poller (only if none is running — + // Resume the keeper preview poller (only if none is running — // e.g. the user disconnected mid-run and teardown already ran). let keeper = state.web_driver.lock().await.clone(); if let Some(keeper) = keeper { @@ -339,7 +339,7 @@ pub async fn spawn_web_runner( /// Spawn `maestro --udid test ` for an iOS simulator. Like /// [`spawn_runner`] but without the adb emulator-ghost preamble (irrelevant to -/// iOS). The caller stops the studio keeper first so `maestro test` can bring up +/// iOS). The caller stops the driver keeper first so `maestro test` can bring up /// its own XCTest driver on :22087 without contention. Streams stdout/stderr and /// emits `runner:exit` exactly like the other runners. pub async fn spawn_ios_runner( diff --git a/src-tauri/src/sim_capture/mod.rs b/src-tauri/src/sim_capture/mod.rs index 156b1ef..314578c 100644 --- a/src-tauri/src/sim_capture/mod.rs +++ b/src-tauri/src/sim_capture/mod.rs @@ -757,7 +757,7 @@ mod tests { } /// Full end-to-end repro of the production crash: spawn the real - /// `maestro studio` driver, capture the framebuffer, then inject touches + /// `maestro mcp` driver keeper, capture the framebuffer, then inject touches /// through the XCTest runner mid-capture — exactly what happens when the /// user clicks the device view in the app. Run with a booted sim: /// cargo test --manifest-path src-tauri/Cargo.toml capture_with_touch -- --ignored --nocapture diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5c0402d..cb099c1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; use crate::device::Device; -use crate::hierarchy::studio::StudioKeeper; +use crate::hierarchy::driver_keeper::DriverKeeper; use crate::hierarchy::HierarchyTree; use crate::ios_session::IosDriverKeeper; use crate::selector::SpatialIndex; @@ -25,11 +25,11 @@ pub struct AppState { pub stream_abort: AsyncMutex>>, pub scrcpy_child: AsyncMutex>, - // Background `maestro studio` process that keeps the on-device + // Background `maestro mcp` process that keeps the on-device // gRPC driver alive for the fast-hierarchy path. Lazily spawned // on the first fast-mode inspect request and torn down when the // device disconnects (see `commands::disconnect_device`). - pub studio: AsyncMutex>>, + pub driver_keeper: AsyncMutex>>, pub ios_driver: AsyncMutex>>, pub ios_screenshot_abort: AsyncMutex>>, @@ -39,7 +39,7 @@ pub struct AppState { /// `/status` + `/hierarchy` and inspect hangs. The poller pauses while set. pub ios_inspect_active: std::sync::atomic::AtomicBool, /// True while a `maestro test` run is in flight on an iOS **simulator**. - /// The simulator driver (`maestro studio` on :22087) and `maestro test` + /// The simulator driver (`maestro mcp` keeper, runner on :22087) and `maestro test` /// can't coexist, so the run stops the keeper first — this flag then blocks /// `ensure_ios_keeper` from re-warming a competing keeper (inspector dumps, /// taps) until the run exits, which would otherwise deadlock both on :22087. @@ -47,21 +47,21 @@ pub struct AppState { #[cfg(target_os = "macos")] pub ios_preview_session: AsyncMutex>, - pub web_driver: AsyncMutex>>, + pub web_driver: AsyncMutex>>, pub web_screenshot_abort: AsyncMutex>>, - /// True while a `maestro -p web test` run is in flight. The studio keeper's + /// True while a `maestro -p web test` run is in flight. The web keeper's /// browser and the test's own browser can't coexist, so `run_flow` stops the /// keeper first — this flag then blocks `ensure_web_keeper` from re-spawning /// a competing one (inspect, taps) until the run exits. Mirror of /// `ios_sim_run_active`. pub web_run_active: std::sync::atomic::AtomicBool, - /// Consecutive `WebStudioKeeper::start` failures — drives the exponential + /// Consecutive `WebDriverKeeper::start` failures — drives the exponential /// respawn backoff (1 s / 2 s / 4 s) in `ensure_web_keeper`, reset on success. pub web_respawn_fails: std::sync::atomic::AtomicU32, - /// Last real page URL seen in the web session (from SSE events; never a - /// Studio-local page). Survives keeper teardown so a respawn — after a - /// run, or after a transient driver failure — restores the user's page - /// instead of navigating to Studio's own SPA. + /// Last real page URL seen in the web session (from the preview's CDP + /// target; never a blank/`data:` tab). Survives keeper teardown so a + /// respawn — after a run, or after a transient driver failure — restores + /// the user's page instead of a blank tab. pub web_last_url: RwLock>, /// Abort handle of the CDP run mirror (live view of a headless web run). /// Fired by the runner's exit task — or by teardown if the user diff --git a/src-tauri/src/tool_paths.rs b/src-tauri/src/tool_paths.rs index b431415..ec14a8f 100644 --- a/src-tauri/src/tool_paths.rs +++ b/src-tauri/src/tool_paths.rs @@ -49,8 +49,8 @@ pub struct ToolPaths { pub maestro: Option, #[serde(default)] pub iproxy: Option, - /// Apple Team ID used to code-sign the iOS XCTest runner via `maestro studio` - /// (simulators) or `maestro-ios-device` (physical devices). + /// Apple Team ID used to code-sign the iOS XCTest runner via + /// `maestro-ios-device` (physical devices). #[serde(default)] pub apple_team_id: Option, /// `maestro-ios-device` bridge binary (devicelab) used to build/run the @@ -251,9 +251,8 @@ pub fn set_maestro_ios_device_path(path: &str) -> AppResult<()> { Ok(()) } -/// User-configured Apple Team ID (or None). The keeper passes it to -/// `maestro studio --apple-team-id` (simulators) or `maestro-ios-device -/// --team-id` (physical); if None, maestro uses its own config. +/// User-configured Apple Team ID (or None). The physical-device keeper passes +/// it to `maestro-ios-device --team-id`. pub fn apple_team_id() -> Option { load_overrides() .apple_team_id diff --git a/src-tauri/src/web_session/cdp.rs b/src-tauri/src/web_session/cdp.rs new file mode 100644 index 0000000..8996f84 --- /dev/null +++ b/src-tauri/src/web_session/cdp.rs @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +//! Chrome DevTools Protocol plumbing shared by the web session preview and +//! the run mirror. Maestro drives Chrome through Selenium, and Chrome always +//! writes its DevTools endpoint to `/DevToolsActivePort`, so +//! we can attach a `Page.startScreencast` to any maestro-driven browser +//! without owning it. + +use std::future::Future; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use tokio::sync::oneshot; +use tokio::time::sleep; +use tokio_tungstenite::tungstenite::Message; +use tracing::info; + +use super::WebFramePayload; + +/// `--user-data-dir=` from a Chrome command line. +pub(super) fn user_data_dir(cmdline: &str) -> Option<&str> { + let start = cmdline.find("--user-data-dir=")? + "--user-data-dir=".len(); + cmdline[start..].split_whitespace().next() +} + +/// DevTools HTTP port of the Chrome with this command line, once its +/// `DevToolsActivePort` file exists (Chrome writes it shortly after spawn). +pub(super) async fn devtools_port(cmdline: &str) -> Option { + let dir = user_data_dir(cmdline)?; + let s = tokio::fs::read_to_string(format!("{dir}/DevToolsActivePort")) + .await + .ok()?; + s.lines().next().and_then(|l| l.trim().parse().ok()) +} + +fn http_client() -> Option { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(1)) + .timeout(Duration::from_secs(3)) + .build() + .ok() +} + +/// The first `page` target of a DevTools endpoint. +async fn first_page(port: u16) -> Option { + let targets: serde_json::Value = http_client()? + .get(format!("http://127.0.0.1:{port}/json/list")) + .send() + .await + .ok()? + .json() + .await + .ok()?; + targets + .as_array()? + .iter() + .find(|t| t["type"] == "page") + .cloned() +} + +/// `webSocketDebuggerUrl` of the first `page` target on a DevTools endpoint. +pub(super) async fn page_ws_url(port: u16) -> Option { + first_page(port) + .await + .and_then(|t| t["webSocketDebuggerUrl"].as_str().map(str::to_string)) +} + +/// URL currently loaded in the first `page` target. +pub(super) async fn page_url(port: u16) -> Option { + first_page(port) + .await + .and_then(|t| t["url"].as_str().map(str::to_string)) +} + +/// True while the DevTools endpoint answers — i.e. the browser is up. +pub(super) async fn is_reachable(port: u16) -> bool { + let Some(client) = http_client() else { + return false; + }; + client + .get(format!("http://127.0.0.1:{port}/json/version")) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +/// Attach a CDP screencast to the page found by `find_ws` and hand each frame +/// to `on_frame` until aborted. CDP pushes a frame only when the page actually +/// changes, and `data` is already base64 (zero re-encode). The target is +/// re-discovered whenever the socket drops (tab navigated to a fresh target, +/// browser restarted…). Returns when `abort_rx` fires. +pub(super) async fn screencast( + mut find_ws: F, + mut abort_rx: oneshot::Receiver<()>, + mut on_frame: impl FnMut(WebFramePayload), +) where + F: FnMut() -> Fut, + Fut: Future>, +{ + 'attach: loop { + // The browser may take a few seconds to appear — poll until it does + // or we're aborted. + let ws_url = loop { + if let Some(u) = find_ws().await { + break u; + } + tokio::select! { + biased; + _ = &mut abort_rx => return, + _ = sleep(Duration::from_millis(300)) => {} + } + }; + let Ok((mut ws, _)) = tokio_tungstenite::connect_async(&ws_url).await else { + tokio::select! { + biased; + _ = &mut abort_rx => return, + _ = sleep(Duration::from_millis(500)) => {} + } + continue 'attach; + }; + info!(%ws_url, "CDP screencast attached"); + // JPEG q75: ~10× smaller than PNG per frame (and much faster for + // Chrome to encode) — the difference between a slideshow and a + // real-time feel. The frontend sniffs the 0xFFD8 magic and types + // the blob accordingly. + let _ = ws + .send(Message::Text( + r#"{"id":1,"method":"Page.startScreencast","params":{"format":"jpeg","quality":75,"everyNthFrame":1,"maxWidth":1600,"maxHeight":1600}}"#.into(), + )) + .await; + loop { + let msg = tokio::select! { + biased; + _ = &mut abort_rx => return, + m = ws.next() => m, + }; + match msg { + Some(Ok(Message::Text(txt))) => { + let Some((payload, sid)) = parse_frame(&txt) else { + continue; + }; + on_frame(payload); + let ack = format!( + r#"{{"id":2,"method":"Page.screencastFrameAck","params":{{"sessionId":{sid}}}}}"# + ); + let _ = ws.send(Message::Text(ack)).await; + } + Some(Ok(_)) => {} + Some(Err(_)) | None => { + tokio::select! { + biased; + _ = &mut abort_rx => return, + _ = sleep(Duration::from_millis(300)) => {} + } + continue 'attach; + } + } + } + } +} + +/// A `Page.screencastFrame` event → frame payload + session id to ack. +fn parse_frame(txt: &str) -> Option<(WebFramePayload, i64)> { + let v: serde_json::Value = serde_json::from_str(txt).ok()?; + if v["method"] != "Page.screencastFrame" { + return None; + } + let p = &v["params"]; + let data = p["data"].as_str()?; + let sid = p["sessionId"].as_i64()?; + Some(( + WebFramePayload { + data: data.to_string(), + // CSS pixels — the coordinate space of the element bounds. + width: p["metadata"]["deviceWidth"].as_u64().unwrap_or(0) as u32, + height: p["metadata"]["deviceHeight"].as_u64().unwrap_or(0) as u32, + }, + sid, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_user_data_dir() { + let run = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --allow-pre-commit-input --enable-automation --headless=new --test-type=webdriver --user-data-dir=/var/folders/kc/T/org.chromium.Chromium.scoped_dir.t1KHli data:,"; + assert_eq!( + user_data_dir(run), + Some("/var/folders/kc/T/org.chromium.Chromium.scoped_dir.t1KHli") + ); + assert_eq!(user_data_dir("chrome --no-user-data"), None); + } + + #[test] + fn parses_screencast_frames_only() { + let frame = r#"{"method":"Page.screencastFrame","params":{"data":"/9j/AA==","sessionId":4,"metadata":{"deviceWidth":1200,"deviceHeight":762}}}"#; + let (p, sid) = parse_frame(frame).expect("frame"); + assert_eq!(sid, 4); + assert_eq!(p.data, "/9j/AA=="); + assert_eq!((p.width, p.height), (1200, 762)); + assert!(parse_frame(r#"{"id":1,"result":{}}"#).is_none()); + assert!(parse_frame("not json").is_none()); + } +} diff --git a/src-tauri/src/web_session/mod.rs b/src-tauri/src/web_session/mod.rs index ed63634..acdd09f 100644 --- a/src-tauri/src/web_session/mod.rs +++ b/src-tauri/src/web_session/mod.rs @@ -1,293 +1,133 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 -//! Web driver session: keeps `maestro studio -p web` alive and talks to its -//! HTTP API on 127.0.0.1:9999 (`GET /api/device-screen`, `POST /api/run-command`). -//! The web analogue of `ios_session`. Screen + hierarchy come from one -//! device-screen call; input goes through run-command. - +//! Web driver session: keeps a `maestro -p web mcp` keeper alive — its +//! device session owns a Selenium-driven Chrome — and drives it through MCP +//! tools (`run` for commands, `inspect_screen` for the hierarchy). The live +//! preview is a CDP screencast of that same Chrome. The web analogue of +//! `ios_session`. (Maestro ≤ 2.5 exposed all this through `maestro studio`'s +//! HTTP API, removed in 2.6.) + +pub(crate) mod cdp; pub mod run_mirror; use std::sync::Arc; use std::time::Duration; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tauri::{AppHandle, Emitter}; -use tokio::process::{Child, Command}; +use tokio::process::Command; use tokio::sync::oneshot; -use tokio::sync::Mutex as AsyncMutex; use tokio::time::sleep; use tracing::{info, warn}; use crate::error::{AppError, AppResult}; +use crate::maestro_mcp::McpClient; +#[cfg(windows)] use crate::process_ext::CommandExtNoWindow; -/// Default port the Maestro Studio HTTP server binds to. -pub const STUDIO_PORT: u16 = 9999; +/// MCP device id of the browser session (`McpMaestroSessionManager`). +const WEB_DEVICE_ID: &str = "chromium"; -/// Command-line needles (ordered substrings) identifying a **web** maestro -/// studio — the `-p web` needle is what keeps a mobile studio session safe -/// from this sweep. Matched against the JVM's full command line. -pub(crate) const WEB_STUDIO_NEEDLES: &[&str] = &["maestro", "-p web", "studio"]; +/// Command-line needles (ordered substrings) identifying a **web** keeper +/// (`maestro -p web mcp --no-viewer`) — the `-p web` needle is what keeps a +/// mobile keeper, or a user's own `maestro mcp`, safe from this sweep. +/// Matched against the JVM's full command line. +pub(crate) const WEB_KEEPER_NEEDLES: &[&str] = &["maestro", "-p web", "mcp", "--no-viewer"]; const CHROMEDRIVER_NEEDLES: &[&str] = &["selenium", "chromedriver"]; const WEBDRIVER_CHROME_NEEDLES: &[&str] = &["test-type=webdriver"]; -/// Parsed `GET /api/device-screen` response. -/// VERIFY (Task 1): field names/screenshot encoding against the captured fixture. -#[derive(Debug, Clone, Deserialize)] +/// First navigation launches Chromium, which can take tens of seconds (a cold +/// start may download the browser + driver). +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(120); +/// A single command. Generous: a `tapOn` on a missing element waits out +/// maestro's own ~17 s lookup before failing. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); +const INSPECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// A snapshot of the browser screen, in the flat element format the tap +/// resolver (`input::web`) and the inspector (`hierarchy::web`) consume. +#[derive(Debug, Clone)] pub struct DeviceScreen { - /// Screenshot location. Documented as a URL path (e.g. "/screenshot/.png"). - pub screenshot: String, - #[serde(default)] + /// Viewport size in CSS pixels — the space the element bounds live in. pub width: u32, - #[serde(default)] pub height: u32, - /// Raw hierarchy payload, kept as JSON so `hierarchy::web` can adapt it - /// without this module knowing the tree shape. VERIFY (Task 1): the key - /// is assumed to be `elements`. - #[serde(rename = "elements")] + /// Flat list of `{bounds:{x,y,width,height}, resourceId?, text?}`. pub elements: serde_json::Value, - /// Current page URL, present on every SSE event. Remembered so a - /// respawned keeper can restore the user's page. - #[serde(default)] + /// Current page URL, when known. Remembered so a respawned keeper can + /// restore the user's page. pub url: Option, } -/// True for Studio's own pages (`http://127.0.0.1:/…`, `localhost`). -/// Those must never be "remembered" as the user's page — restoring to them -/// on respawn is exactly the bug: the browser reloads onto the Studio SPA. -fn is_studio_local_url(url: &str, port: u16) -> bool { - url.starts_with(&format!("http://127.0.0.1:{port}")) - || url.starts_with(&format!("http://localhost:{port}")) -} - -/// Typed client for the Maestro Studio web API. -pub struct WebStudioClient { - base: String, - client: reqwest::Client, - /// Connect-timeout only — no total timeout, for held SSE connections. - sse_client: reqwest::Client, +/// Pages that must never be "remembered" as the user's page: Chrome's +/// initial `data:,` and blank pages — restoring to them on respawn would +/// lose the user's real page. +fn is_placeholder_url(url: &str) -> bool { + url.starts_with("data:") || url.starts_with("about:") || url.starts_with("chrome:") } -impl WebStudioClient { - pub fn new(port: u16) -> AppResult { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(1)) - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| AppError::Other(format!("web client build: {e}")))?; - let sse_client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(1)) - .build() - .map_err(|e| AppError::Other(format!("web sse client build: {e}")))?; - Ok(Self { - base: format!("http://127.0.0.1:{port}"), - client, - sse_client, - }) - } - - fn url(&self, path: &str) -> String { - format!("{}/{}", self.base, path.trim_start_matches('/')) +/// Parse maestro's `[left,top][right,bottom]` bounds string. +fn parse_bounds(b: &str) -> Option<(i32, i32, i32, i32)> { + let nums: Vec = b + .split(|c: char| !(c.is_ascii_digit() || c == '-')) + .filter(|s| !s.is_empty()) + .map(|s| s.parse().ok()) + .collect::>()?; + match nums[..] { + [l, t, r, b] => Some((l, t, r, b)), + _ => None, } +} - /// Read one event from the Server-Sent-Events stream `GET - /// /api/device-screen/sse` — Maestro Studio pushes the current screen - /// (screenshot URL + flat element list) as `data: {json}\n\n`. We open the - /// stream, return the first complete event, and drop the connection. - pub async fn device_screen(&self) -> AppResult { - let mut resp = self - .client - .get(self.url("api/device-screen/sse")) - .send() - .await - .map_err(|e| AppError::Other(format!("device-screen/sse: {e}")))? - .error_for_status() - .map_err(|e| AppError::Other(format!("device-screen/sse: {e}")))?; - let mut buf: Vec = Vec::new(); - while let Some(chunk) = resp - .chunk() - .await - .map_err(|e| AppError::Other(format!("device-screen/sse read: {e}")))? - { - buf.extend_from_slice(&chunk); - if let Some(json) = extract_sse_data(&buf) { - return serde_json::from_str(&json) - .map_err(|e| AppError::HierarchyParse(format!("device-screen parse: {e}"))); - } +/// Convert `inspect_screen`'s compact JSON (`{ui_schema, elements:[tree]}`, +/// abbreviated keys, children under `c`) into the flat element list + +/// viewport. Only elements a selector can target (text or id) are kept; the +/// viewport is the root's extent. +fn flatten_inspect_screen(json: &str) -> AppResult<(serde_json::Value, (u32, u32))> { + let v: serde_json::Value = serde_json::from_str(json) + .map_err(|e| AppError::HierarchyParse(format!("inspect_screen parse: {e}")))?; + let roots = v["elements"] + .as_array() + .ok_or_else(|| AppError::HierarchyParse("inspect_screen: no `elements`".into()))?; + + let mut viewport = (0u32, 0u32); + for r in roots { + if let Some((_, _, right, bottom)) = r["b"].as_str().and_then(parse_bounds) { + viewport.0 = viewport.0.max(right.max(0) as u32); + viewport.1 = viewport.1.max(bottom.max(0) as u32); } - Err(AppError::Other( - "device-screen/sse closed before delivering an event".into(), - )) - } - - /// Open (and keep open) the device-screen SSE stream. The caller reads - /// chunks until abort/EOF; Studio pushes a `data:` event per frame. - pub async fn open_screen_stream(&self) -> AppResult { - self.sse_client - .get(self.url("api/device-screen/sse")) - .send() - .await - .map_err(|e| AppError::Other(format!("device-screen/sse: {e}")))? - .error_for_status() - .map_err(|e| AppError::Other(format!("device-screen/sse: {e}"))) } - /// Fetch PNG bytes for a screenshot path returned by `device_screen`. - /// If `screenshot` is already an absolute http URL, it is used as-is. - pub async fn screenshot_png(&self, location: &str) -> AppResult> { - let url = if location.starts_with("http") { - location.to_string() - } else { - self.url(location) + let mut out = Vec::new(); + let mut stack: Vec<&serde_json::Value> = roots.iter().rev().collect(); + while let Some(node) = stack.pop() { + if let Some(children) = node["c"].as_array() { + stack.extend(children.iter().rev()); + } + let Some((l, t, r, b)) = node["b"].as_str().and_then(parse_bounds) else { + continue; }; - let resp = self - .client - .get(url) - .send() - .await - .map_err(|e| AppError::Other(format!("screenshot: {e}")))? - .error_for_status() - .map_err(|e| AppError::Other(format!("screenshot: {e}")))?; - Ok(resp - .bytes() - .await - .map_err(|e| AppError::Other(e.to_string()))? - .to_vec()) - } - - /// `POST /api/run-command` — run a single maestro command. Studio expects - /// `{ "yaml": "", "dryRun": bool }`. - pub async fn run_command(&self, body: serde_json::Value) -> AppResult<()> { - let resp = self - .client - .post(self.url("api/run-command")) - .json(&body) - .send() - .await - .map_err(|e| AppError::Other(format!("run-command: {e}")))?; - let status = resp.status(); - if !status.is_success() { - // Surface the server's explanation (e.g. "Invalid command format") - // and the offending yaml — a bare status code is useless to debug. - let detail = resp.text().await.unwrap_or_default(); - let sent = body - .get("yaml") - .and_then(|v| v.as_str()) - .unwrap_or(""); - return Err(AppError::Other(format!( - "run-command {status}: {} | sent yaml: {sent}", - detail.trim() - ))); + let non_empty = |k: &str| node[k].as_str().filter(|s| !s.is_empty()); + let resource_id = non_empty("rid"); + // `text:` selectors also match accessibility text and hints. + let text = non_empty("txt") + .or_else(|| non_empty("a11y")) + .or_else(|| non_empty("hint")); + if resource_id.is_none() && text.is_none() { + continue; } - Ok(()) - } - - /// Liveness: the SPA serves 200 on every path, so a bare GET can't tell us - /// the driver is ready. Reading a real device-screen event can. - pub async fn is_alive(&self) -> bool { - self.device_screen().await.is_ok() - } - - /// Cheap API-readiness probe: `/api/banner-message` answers 200 as soon as - /// the Studio HTTP server is up — no browser needed. (The device-screen SSE - /// stays silent until Chromium launches, so it can't be the API probe.) - pub async fn api_ready(&self) -> bool { - self.client - .get(self.url("api/banner-message")) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false) - } - - /// Navigate the automated browser. The FIRST navigation after a studio - /// spawn is what launches Chromium and can take tens of seconds (cold - /// start may download the driver) — use a generous per-request timeout - /// overriding the client's 15 s default. - pub async fn trigger_navigation(&self, url: &str) -> AppResult<()> { - let yaml = format!("openLink: {url}"); - let resp = self - .client - .post(self.url("api/run-command")) - .timeout(Duration::from_secs(90)) - .json(&serde_json::json!({ "yaml": yaml, "dryRun": false })) - .send() - .await - .map_err(|e| AppError::Other(format!("run-command: {e}")))?; - if !resp.status().is_success() { - let status = resp.status(); - let detail = resp.text().await.unwrap_or_default(); - return Err(AppError::Other(format!( - "openLink {status}: {} | sent url: {url}", - detail.trim() - ))); + let mut el = serde_json::json!({ + "bounds": { "x": l, "y": t, "width": r - l, "height": b - t }, + }); + if let Some(id) = resource_id { + el["resourceId"] = id.into(); } - Ok(()) - } -} - -/// Extract the JSON payload of the first complete `data: …` line in an SSE -/// buffer. Returns `None` until a full line (terminated by `\n`) is present. -fn extract_sse_data(buf: &[u8]) -> Option { - let s = std::str::from_utf8(buf).ok()?; - let start = s.find("data: ")? + "data: ".len(); - let rel_end = s[start..].find('\n')?; - Some(s[start..start + rel_end].trim().to_string()) -} - -/// Incremental SSE parser for a held `/api/device-screen/sse` connection. -/// `push` appends raw bytes; `latest_event` drains every *complete* event -/// (`data: …\n`) accumulated so far and returns only the newest — frames we -/// fell behind on are intentionally skipped (coalescing), so the preview -/// always shows the current page, never a backlog replay. -#[derive(Default)] -struct SseBuffer { - buf: Vec, -} - -impl SseBuffer { - fn push(&mut self, chunk: &[u8]) { - self.buf.extend_from_slice(chunk); - } - - fn latest_event(&mut self) -> Option { - let s = String::from_utf8_lossy(&self.buf).into_owned(); - let mut latest = None; - let mut consumed = 0; - for line in s.split_inclusive('\n') { - if !line.ends_with('\n') { - break; // trailing partial line — keep for the next push - } - consumed += line.len(); - if let Some(data) = line.strip_prefix("data: ") { - latest = Some(data.trim().to_string()); - } + if let Some(text) = text { + el["text"] = text.into(); } - self.buf.drain(..consumed); - latest - } -} - -/// Classify who (if anyone) is holding the web studio port. -#[derive(Debug)] -pub(crate) enum PortOwnerKind { - /// A leftover `maestro -p web studio` — safe to kill (our sweep does). - OrphanWebStudio, - /// A mobile (iOS/Android) studio — belongs to a live session, never kill. - MobileStudio, - /// Anything else (another tool squatting the port). - Foreign, -} - -pub(crate) fn classify_port_owner(cmdline: &str) -> PortOwnerKind { - if crate::prockill::cmdline_matches(cmdline, WEB_STUDIO_NEEDLES) { - PortOwnerKind::OrphanWebStudio - } else if crate::prockill::cmdline_matches(cmdline, &["maestro", "studio"]) { - PortOwnerKind::MobileStudio - } else { - PortOwnerKind::Foreign + out.push(el); } + Ok((serde_json::Value::Array(out), viewport)) } /// Connect-progress event consumed by the frontend toast layer. @@ -302,45 +142,6 @@ fn emit_status(app: Option<&AppHandle>, stage: &str, message: &str) { } } -const READY_ATTEMPTS: u32 = 240; -const READY_BACKOFF_MS: u64 = 500; - -fn studio_args() -> Vec { - // `-p web` is a GLOBAL flag and MUST precede the `studio` subcommand - // (`maestro -p web studio …`); placing it after `studio` is rejected - // ("Unknown options: '-p', 'web'") on maestro 2.5.1. `--no-window` - // suppresses Studio's own UI tab — we render our own canvas. - // `--no-ansi` keeps the stdout banner parseable (see `parse_studio_port`). - vec![ - "-p".to_string(), - "web".to_string(), - "studio".to_string(), - "--no-window".to_string(), - "--no-ansi".to_string(), - ] -} - -/// Extract the port from Studio's startup banner. Studio **auto-increments** -/// its port when the default is busy (e.g. a mobile studio session on -/// :9999) — assuming the default would silently talk to the wrong server. -/// Banner line (inside a box-drawing frame): -/// `│ Maestro Studio is running at http://localhost:10000 │` -fn parse_studio_port(line: &str) -> Option { - let idx = line.find("running at http://localhost:")?; - let digits: String = line[idx + "running at http://localhost:".len()..] - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - digits.parse().ok() -} - -/// Where to point the browser when the flow has no `url:` — Studio's own -/// interact page. Local, always reachable, and `openLink` requires an -/// http(s) URL (about:blank / chrome:// are rejected with 400). -fn default_trigger_url(port: u16) -> String { - format!("http://127.0.0.1:{port}/interact") -} - /// True for the driven Chrome's MAIN process: it carries the webdriver /// marker but no `--type=` (helpers/renderers/GPU children do). The main /// process is the one owning the window we want to hide. @@ -348,8 +149,8 @@ fn is_main_browser_process(cmdline: &str) -> bool { cmdline.contains("test-type=webdriver") && !cmdline.contains("--type=") } -/// Hide a process's window(s) at the OS level. `maestro studio` has no -/// headless mode (Selenium factory is hardcoded headed for studio), so the +/// Hide a process's window(s) at the OS level. `maestro mcp` has no +/// headless mode (its web session is hardcoded headed), so the /// only way to keep the driven Chrome off the user's screen is to hide it /// after launch. Chrome keeps rendering while hidden — it is launched with /// `--disable-backgrounding-occluded-windows`, so the SSE preview stays live. @@ -442,43 +243,38 @@ fn spawn_window_hider(app: Option) { }); } -/// Kill orphaned Chromium automation processes left behind by -/// `maestro studio -p web`. Maestro drives Chrome through a Selenium-managed -/// `chromedriver`; killing the studio JVM reaps neither the driver nor the -/// browser, so headed Chrome windows pile up across sessions. We match the -/// Selenium chromedriver and the `--test-type=webdriver` Chrome it launches — -/// markers a user's normal Chrome never carries. Cross-platform via -/// `prockill` (`ps`+`kill` / PowerShell+`taskkill`). +/// Kill orphaned Chromium automation processes left behind by a web keeper. +/// Maestro drives Chrome through a Selenium-managed `chromedriver`; killing +/// the keeper JVM reaps neither the driver nor the browser, so headed Chrome +/// windows pile up across sessions. We match the Selenium chromedriver and +/// the `--test-type=webdriver` Chrome it launches — markers a user's normal +/// Chrome never carries. Cross-platform via `prockill`. async fn kill_orphan_web_browsers() { crate::prockill::kill_matching(CHROMEDRIVER_NEEDLES, "orphan chromedriver").await; crate::prockill::kill_matching(WEBDRIVER_CHROME_NEEDLES, "orphan webdriver Chrome").await; } -/// Keeps `maestro studio -p web` alive and owns the HTTP client for the session. -pub struct WebStudioKeeper { - http: WebStudioClient, - studio_child: AsyncMutex>, - port: u16, - /// Latest device-screen event seen by the persistent poller, timestamped. - /// Tap/inspect reuse it when fresh instead of opening a second SSE - /// consumer and waiting (up to 15 s on a busy page) for an event. +/// Keeps a `maestro -p web mcp` keeper (and the Chrome its session owns) +/// alive for the web session. +pub struct WebDriverKeeper { + mcp: McpClient, + /// DevTools port of the keeper's Chrome, found after launch. + devtools_port: parking_lot::Mutex>, + /// Latest screen snapshot, timestamped. Tap/inspect reuse it when fresh + /// instead of paying an `inspect_screen` round-trip per click. latest_screen: std::sync::Mutex>, + /// Viewport (CSS px) from the latest screencast frame — the fallback + /// size when a page exposes no element to measure. + viewport: parking_lot::Mutex<(u32, u32)>, } -impl WebStudioKeeper { - pub fn http(&self) -> &WebStudioClient { - &self.http - } - pub fn port(&self) -> u16 { - self.port - } - - /// Record a device-screen event (called by the poller on every event). +impl WebDriverKeeper { + /// Record a screen snapshot. pub fn note_screen(&self, screen: &DeviceScreen) { *self.latest_screen.lock().unwrap() = Some((std::time::Instant::now(), screen.clone())); } - /// The latest poller event if it is younger than `max_age`. + /// The latest snapshot if it is younger than `max_age`. pub fn recent_screen(&self, max_age: Duration) -> Option { self.latest_screen .lock() @@ -488,147 +284,182 @@ impl WebStudioKeeper { .map(|(_, s)| s.clone()) } - /// A screen snapshot for tap/inspect: the poller's cache when fresh, - /// otherwise one fresh single-shot fetch. + /// A screen snapshot for tap/inspect: the cache when fresh, otherwise one + /// fresh `inspect_screen`. pub async fn snapshot(&self, max_age: Duration) -> AppResult { if let Some(s) = self.recent_screen(max_age) { return Ok(s); } - let s = self.http.device_screen().await?; + let s = self.device_screen().await?; self.note_screen(&s); Ok(s) } - #[cfg(test)] - fn stub_for_tests() -> Self { - Self { - http: WebStudioClient::new(9999).unwrap(), - studio_child: AsyncMutex::new(None), - port: 9999, - latest_screen: std::sync::Mutex::new(None), - } + /// Fetch the current hierarchy + viewport + URL. + pub async fn device_screen(&self) -> AppResult { + let json = self + .mcp + .call_tool( + "inspect_screen", + serde_json::json!({ "device_id": WEB_DEVICE_ID }), + INSPECT_TIMEOUT, + ) + .await?; + let (elements, (w, h)) = flatten_inspect_screen(&json)?; + let (width, height) = if w > 0 && h > 0 { + (w, h) + } else { + *self.viewport.lock() + }; + let port = *self.devtools_port.lock(); + let url = match port { + Some(port) => cdp::page_url(port).await, + None => None, + }; + Ok(DeviceScreen { + width, + height, + elements, + url, + }) } - pub async fn start( - port: u16, - url: Option<&str>, - app: Option<&AppHandle>, - ) -> AppResult> { - // Pre-flight (informational): Studio auto-increments its port when - // the default is busy, and we parse the actual one from its banner - // below — a busy port is no longer fatal, but knowing who holds it - // helps debugging (e.g. a live mobile studio session). - if let Some(owner) = crate::prockill::port_owner(port).await { - info!( - port, - pid = owner.pid, - kind = ?classify_port_owner(&owner.cmdline), - "default studio port busy — studio will pick the next free one" - ); + /// Run one maestro command (a single `": "` line) in the + /// browser. Invalidates the cached snapshot: the page likely changed. + pub async fn run_command(&self, command: &str) -> AppResult<()> { + let yaml = crate::maestro_mcp::inline_flow("web", &[command.to_string()]); + let result = self + .mcp + .call_tool( + "run", + serde_json::json!({ "device_id": WEB_DEVICE_ID, "yaml": yaml }), + COMMAND_TIMEOUT, + ) + .await; + *self.latest_screen.lock().unwrap() = None; + result + .map(|_| ()) + .map_err(|e| AppError::Other(format!("{e} | sent: {command}"))) + } + + /// Liveness: the keeper process is running and its Chrome still answers + /// DevTools. Sub-second; a dead browser means the MCP session is stale. + pub async fn is_alive(&self) -> bool { + if !self.mcp.is_alive().await { + return false; } + let port = *self.devtools_port.lock(); + match port { + Some(port) => cdp::is_reachable(port).await, + None => false, + } + } - // Cull any orphan studio from a crashed prior session. Scoped: only web studios; - // a live iOS/Android studio session belonging to this app (or anything else) - // is never touched. - crate::prockill::kill_matching(WEB_STUDIO_NEEDLES, "orphan web studio").await; - kill_orphan_web_browsers().await; + /// `webSocketDebuggerUrl` of the keeper's page, for the screencast. + async fn page_ws_url(&self) -> Option { + let port = (*self.devtools_port.lock())?; + cdp::page_ws_url(port).await + } - let maestro = crate::tool_paths::maestro_bin(); - let mut studio = Command::new(&maestro) - .no_window() - .args(studio_args()) - .stdout(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - AppError::RunnerNotFound - } else { - AppError::Other(format!("maestro studio -p web: {e}")) - } - })?; - - // Learn the ACTUAL port from the startup banner — Studio silently - // auto-increments when the default is busy, and probing the default - // would then talk to whatever else lives there (a stale or mobile - // studio) instead of ours. - let mut actual_port = port; - if let Some(out) = studio.stdout.take() { - use tokio::io::AsyncBufReadExt; - let mut lines = tokio::io::BufReader::new(out).lines(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(60); - // On EOF or 60 s without a banner the loop ends and we fall back - // to the default port; the API probe below still guards readiness. - while let Ok(Ok(Some(line))) = - tokio::time::timeout_at(deadline, lines.next_line()).await + /// Find the DevTools port of the Chrome launched by *this* keeper — a + /// descendant of the keeper JVM (`maestro` execs java, so the child pid + /// is the JVM's). Another headed webdriver Chrome (e.g. a user's own + /// `maestro mcp`) is never picked. + async fn discover_devtools_port(&self) -> Option { + let root = self.mcp.pid().await?; + for _ in 0..40 { + for (_pid, cmd) in + crate::prockill::descendants_matching(root, WEBDRIVER_CHROME_NEEDLES).await { - if let Some(p) = parse_studio_port(&line) { - if p != port { - info!(requested = port, actual = p, "studio picked another port"); - } - actual_port = p; - break; + if !is_main_browser_process(&cmd) { + continue; + } + if let Some(port) = cdp::devtools_port(&cmd).await { + return Some(port); } } - // Keep draining so the child never blocks on a full stdout pipe. - tokio::spawn(async move { while let Ok(Some(_)) = lines.next_line().await {} }); + sleep(Duration::from_millis(250)).await; } + None + } + #[cfg(test)] + fn stub_for_tests() -> Self { + Self { + mcp: McpClient::stub_for_tests(), + devtools_port: parking_lot::Mutex::new(None), + latest_screen: std::sync::Mutex::new(None), + viewport: parking_lot::Mutex::new((0, 0)), + } + } + + pub async fn start(url: Option<&str>, app: Option<&AppHandle>) -> AppResult> { + // Cull any orphan keeper from a crashed prior session. Scoped: only + // web keepers; a live iOS/Android keeper belonging to this app (or a + // user's own `maestro mcp`) is never touched. + crate::prockill::kill_matching(WEB_KEEPER_NEEDLES, "orphan web keeper").await; + kill_orphan_web_browsers().await; + + emit_status(app, "info", "Starting the web driver…"); + let mcp = McpClient::spawn(&["-p", "web"], &[]).await?; let keeper = Arc::new(Self { - http: WebStudioClient::new(actual_port)?, - studio_child: AsyncMutex::new(Some(studio)), - port: actual_port, + mcp, + devtools_port: parking_lot::Mutex::new(None), latest_screen: std::sync::Mutex::new(None), + viewport: parking_lot::Mutex::new((0, 0)), }); - let port = actual_port; - // Phase 1: wait for the Studio HTTP API (fast — no browser involved). - emit_status(app, "info", "Starting the web driver…"); - let mut api_up = false; - for attempt in 0..READY_ATTEMPTS { - if keeper.http.api_ready().await { - api_up = true; - break; + // The first tool call opens the browser session, launching Chromium. + // Arm the window hider BEFORE the browser exists: it polls fast and + // hides the window within a blink of it appearing. + emit_status(app, "info", "Starting Chromium…"); + spawn_window_hider(app.cloned()); + let slow_hint = { + let app = app.cloned(); + tokio::spawn(async move { + sleep(Duration::from_secs(10)).await; + emit_status( + app.as_ref(), + "info", + "Still starting — first run may download Chromium…", + ); + }) + }; + let launched = match url { + Some(u) => { + let yaml = crate::maestro_mcp::inline_flow("web", &[format!("openLink: {u}")]); + keeper + .mcp + .call_tool( + "run", + serde_json::json!({ "device_id": WEB_DEVICE_ID, "yaml": yaml }), + LAUNCH_TIMEOUT, + ) + .await } - // A studio that died (bad install, port race we lost) will never become - // ready — surface its exit immediately instead of waiting out the budget. - let exited = { - let mut guard = keeper.studio_child.lock().await; - guard.as_mut().and_then(|c| c.try_wait().ok().flatten()) - }; - if let Some(status) = exited { + None => { + keeper + .mcp + .call_tool( + "inspect_screen", + serde_json::json!({ "device_id": WEB_DEVICE_ID }), + LAUNCH_TIMEOUT, + ) + .await + } + }; + slow_hint.abort(); + if let Err(e) = launched { + // A failed `openLink` (bad URL, unreachable site) still leaves a + // usable browser — only a keeper that died is fatal. + if !keeper.mcp.is_alive().await || url.is_none() { keeper.stop().await; return Err(AppError::Other(format!( - "maestro studio -p web exited during startup ({status}). \ - Run `maestro -p web studio` in a terminal to see its error." + "the web browser did not start ({e}). Run `maestro -p web test` on a \ + flow in a terminal to check your maestro + Chrome setup." ))); } - if attempt % 10 == 0 { - info!(port, attempt, "waiting for web studio api..."); - } - sleep(Duration::from_millis(READY_BACKOFF_MS)).await; - } - if !api_up { - keeper.stop().await; - return Err(AppError::Other(format!( - "maestro studio -p web did not bring up the API on :{port} in time. \ - Run `maestro -p web studio` in a terminal to check it works." - ))); - } - - // Phase 2: launch Chromium. Studio starts the browser only on the first - // command — without this the device-screen SSE never emits and readiness - // below would time out (the historical connect flakiness). - emit_status(app, "info", "Starting Chromium…"); - // Arm the window hider BEFORE the browser exists: it polls fast and - // hides the window within a blink of it appearing. Hiding only after - // readiness left the window on screen for the whole page load. - spawn_window_hider(app.cloned()); - let target = url - .map(str::to_string) - .unwrap_or_else(|| default_trigger_url(port)); - if let Err(e) = keeper.http.trigger_navigation(&target).await { - warn!(error = %e, "web navigate failed (continuing — browser may still come up)"); + warn!(error = %e, "web openLink failed (continuing on the current page)"); emit_status( app, "warn", @@ -636,46 +467,22 @@ impl WebStudioKeeper { ); } - // Phase 3: first device-screen event = browser is actually up. - for attempt in 0..READY_ATTEMPTS { - if keeper.http.is_alive().await { - info!(port, "web studio ready"); - emit_status(app, "info", "Web browser ready"); - return Ok(keeper); - } - // A studio that died (bad install, port race we lost) will never become - // ready — surface its exit immediately instead of waiting out the budget. - let exited = { - let mut guard = keeper.studio_child.lock().await; - guard.as_mut().and_then(|c| c.try_wait().ok().flatten()) - }; - if let Some(status) = exited { - keeper.stop().await; - return Err(AppError::Other(format!( - "maestro studio -p web exited during startup ({status}). \ - Run `maestro -p web studio` in a terminal to see its error." - ))); - } - if attempt == 20 { - emit_status( - app, - "info", - "Still starting — first run may download Chromium…", - ); - } - sleep(Duration::from_millis(READY_BACKOFF_MS)).await; + let port = keeper.discover_devtools_port().await; + if port.is_none() { + keeper.stop().await; + return Err(AppError::Other( + "the web browser started but its DevTools endpoint was not found".into(), + )); } - keeper.stop().await; - Err(AppError::Other( - "the web browser did not become ready. Run `maestro -p web studio` in a terminal to check it works.".into(), - )) + *keeper.devtools_port.lock() = port; + info!(devtools_port = ?port, "web keeper ready"); + emit_status(app, "info", "Web browser ready"); + Ok(keeper) } pub async fn stop(&self) { - if let Some(mut c) = self.studio_child.lock().await.take() { - let _ = c.kill().await; - } - // Killing the studio JVM orphans the chromedriver + Chrome it spawned; + self.mcp.stop().await; + // Killing the keeper JVM orphans the chromedriver + Chrome it spawned; // reap them so the window closes instead of lingering. kill_orphan_web_browsers().await; } @@ -693,114 +500,46 @@ pub struct WebFramePayload { pub height: u32, } -/// Hold the device-screen SSE stream open and emit a `web_frame` per new -/// screenshot until aborted. Near-real-time (no fixed poll interval): frames -/// arrive as Studio pushes them; if we fall behind, `SseBuffer` coalesces to -/// the newest. The screenshot URL is a per-frame UUID — we download the PNG -/// only when it changes. Connection drops reconnect with backoff -/// (500 ms → 5 s); the keeper's own `is_alive` handles full respawns. +/// Stream the keeper's Chrome over a CDP screencast and emit a `web_frame` +/// per page change until aborted. Also tracks the viewport (for snapshots of +/// element-less pages) and the page URL (so a respawn restores it). pub fn spawn_screenshot_poller( app: AppHandle, - keeper: Arc, + keeper: Arc, ) -> oneshot::Sender<()> { - let (abort_tx, mut abort_rx) = oneshot::channel::<()>(); + let (abort_tx, abort_rx) = oneshot::channel::<()>(); tokio::spawn(async move { - let mut reconnect_ms: u64 = 500; - let mut last_shot: Option = None; - 'session: loop { - let resp = tokio::select! { - biased; - _ = &mut abort_rx => break 'session, - r = keeper.http().open_screen_stream() => r, - }; - let mut resp = match resp { - Ok(r) => { - reconnect_ms = 500; - r - } - Err(e) => { - warn!(error = %e, "web screen stream connect failed; retrying"); - tokio::select! { - biased; - _ = &mut abort_rx => break 'session, - _ = sleep(Duration::from_millis(reconnect_ms)) => {} - } - reconnect_ms = (reconnect_ms * 2).min(5_000); - continue 'session; - } - }; - let mut sse = SseBuffer::default(); - loop { - let chunk = tokio::select! { - biased; - _ = &mut abort_rx => break 'session, - c = resp.chunk() => c, - }; - match chunk { - Ok(Some(bytes)) => { - sse.push(&bytes); - let Some(json) = sse.latest_event() else { - continue; - }; - let screen: DeviceScreen = match serde_json::from_str(&json) { - Ok(s) => s, - Err(e) => { - warn!(error = %e, "web sse event parse failed"); - continue; - } - }; - // Cache the event for tap/inspect and remember the - // page URL so a later respawn restores it (never - // Studio's own SPA). - keeper.note_screen(&screen); - if let Some(u) = screen - .url - .as_deref() - .filter(|u| !is_studio_local_url(u, keeper.port())) - { - use tauri::Manager; - *app.state::().web_last_url.write() = - Some(u.to_string()); - } - // Same page render → same UUID URL → skip the fetch. - if last_shot.as_deref() == Some(screen.screenshot.as_str()) { - continue; - } - let png = tokio::select! { - biased; - _ = &mut abort_rx => break 'session, - r = keeper.http().screenshot_png(&screen.screenshot) => r, - }; - match png { - Ok(data) => { - use base64::Engine as _; - last_shot = Some(screen.screenshot.clone()); - let payload = WebFramePayload { - data: base64::engine::general_purpose::STANDARD.encode(&data), - width: screen.width, - height: screen.height, - }; - if let Err(e) = app.emit(WEB_FRAME_EVENT, &payload) { - warn!(error = %e, "failed to emit web_frame"); - } - } - Err(e) => warn!(error = %e, "web screenshot fetch failed"), - } - } - Ok(None) | Err(_) => { - // Stream ended (studio restart/kill) — reconnect loop. - warn!("web screen stream closed; reconnecting"); - tokio::select! { - biased; - _ = &mut abort_rx => break 'session, - _ = sleep(Duration::from_millis(reconnect_ms)) => {} - } - reconnect_ms = (reconnect_ms * 2).min(5_000); - continue 'session; + let finder_keeper = keeper.clone(); + let find = move || { + let k = finder_keeper.clone(); + async move { k.page_ws_url().await } + }; + let mut last_url_check: Option = None; + cdp::screencast(find, abort_rx, |payload| { + if payload.width > 0 && payload.height > 0 { + *keeper.viewport.lock() = (payload.width, payload.height); + } + if let Err(e) = app.emit(WEB_FRAME_EVENT, &payload) { + warn!(error = %e, "failed to emit web_frame"); + } + // Frames only arrive when the page changes — a cheap moment to + // refresh the remembered URL (throttled). + if last_url_check.map_or(true, |t| t.elapsed() > Duration::from_secs(1)) { + last_url_check = Some(std::time::Instant::now()); + let keeper = keeper.clone(); + let app = app.clone(); + tokio::spawn(async move { + let Some(port) = *keeper.devtools_port.lock() else { + return; + }; + if let Some(u) = cdp::page_url(port).await.filter(|u| !is_placeholder_url(u)) { + use tauri::Manager; + *app.state::().web_last_url.write() = Some(u); } - } + }); } - } + }) + .await; info!("web screenshot poller aborted"); }); abort_tx @@ -810,44 +549,64 @@ pub fn spawn_screenshot_poller( mod tests { use super::*; + // Trimmed `inspect_screen` output captured from maestro 2.10.0 on + // en.wikipedia.org (2026-09-24). maestro reports `platform: ios` for web. + const INSPECT: &str = r#"{"ui_schema":{"platform":"ios","abbreviations":{"b":"bounds","txt":"text","rid":"resource-id"},"defaults":{"enabled":true}},"elements":[{"b":"[0,0][1200,762]","c":[{"b":"[0,0][1200,66]","c":[{"b":"[44,17][64,49]","rid":"Site","c":[{"b":"[38,17][70,49]","txt":"on","rid":"vector-main-menu-dropdown-checkbox"},{"b":"[44,23][64,43]"},{"b":"[53,108][118,124]","txt":"Main page"},{"b":"[10,10][20,20]","a11y":"Search Wikipedia"}]}]}]}]}"#; + #[test] - fn parses_device_screen_event() { - // Shape captured from a real `maestro -p web studio` SSE event. - let json = r#"{"platform":"WEB","screenshot":"/screenshot/abc.png","width":1200,"height":766,"url":"https://x","elements":[{"id":"Search","bounds":{"x":413,"y":14,"width":338,"height":37},"resourceId":"Search","text":"Search…"}]}"#; - let s: DeviceScreen = serde_json::from_str(json).expect("parse"); - assert_eq!(s.width, 1200); - assert_eq!(s.screenshot, "/screenshot/abc.png"); - assert!(s.elements.is_array()); - // The current page URL rides on every event — it's how a respawned - // keeper restores the user's page instead of Studio's own SPA. - assert_eq!(s.url.as_deref(), Some("https://x")); + fn flattens_inspect_screen_into_selector_targets() { + let (els, viewport) = flatten_inspect_screen(INSPECT).expect("parse"); + assert_eq!(viewport, (1200, 762)); + let els = els.as_array().unwrap(); + // Containers and the bare icon (no text, no id) are dropped. + assert_eq!(els.len(), 4); + assert_eq!(els[0]["resourceId"], "Site"); + assert_eq!( + els[0]["bounds"], + serde_json::json!({"x":44,"y":17,"width":20,"height":32}) + ); + assert_eq!(els[1]["text"], "on"); + assert_eq!(els[1]["resourceId"], "vector-main-menu-dropdown-checkbox"); + assert_eq!(els[2]["text"], "Main page"); + assert!(els[2].get("resourceId").is_none()); + // Accessibility text doubles as the `text:` selector. + assert_eq!(els[3]["text"], "Search Wikipedia"); } #[test] - fn studio_local_urls_are_never_remembered() { - // Remembering Studio's own pages would "restore" the browser to the - // localhost SPA on respawn — the exact bug this exists to prevent. - assert!(is_studio_local_url("http://127.0.0.1:9999/interact", 9999)); - assert!(is_studio_local_url("http://localhost:9999/", 9999)); - assert!(!is_studio_local_url("https://www.bouyguestelecom.fr", 9999)); - assert!(!is_studio_local_url("http://127.0.0.1:8080/app", 9999)); + fn flattened_elements_feed_the_web_hierarchy() { + let (els, viewport) = flatten_inspect_screen(INSPECT).unwrap(); + let tree = crate::hierarchy::web::parse_device_screen_hierarchy(&els, viewport).unwrap(); + let root = tree.root.unwrap(); + assert_eq!(root.bounds.right, 1200); + assert_eq!(root.children.len(), 4); } #[test] - fn parses_port_from_studio_banner() { - // Real banner line (2026-07-10, maestro 2.5.1, --no-ansi) — Studio - // auto-increments when the default port is busy. - let line = "│ Maestro Studio is running at http://localhost:10000 │"; - assert_eq!(parse_studio_port(line), Some(10000)); - assert_eq!( - parse_studio_port("Maestro Studio is running at http://localhost:9999"), - Some(9999) - ); - assert_eq!( - parse_studio_port("Navigate to http://localhost:9999 in your browser"), - None - ); - assert_eq!(parse_studio_port("random noise"), None); + fn empty_page_has_no_elements_and_no_viewport() { + let json = r#"{"ui_schema":{},"elements":[]}"#; + let (els, viewport) = flatten_inspect_screen(json).unwrap(); + assert_eq!(els, serde_json::json!([])); + assert_eq!(viewport, (0, 0)); + assert!(flatten_inspect_screen("Failed to inspect screen: boom").is_err()); + } + + #[test] + fn parses_bounds_strings() { + assert_eq!(parse_bounds("[0,0][1200,762]"), Some((0, 0, 1200, 762))); + assert_eq!(parse_bounds("[-5,10][20,30]"), Some((-5, 10, 20, 30))); + assert_eq!(parse_bounds("[1,2]"), None); + assert_eq!(parse_bounds(""), None); + } + + #[test] + fn placeholder_pages_are_never_remembered() { + // Remembering Chrome's initial page would "restore" the browser to a + // blank tab on respawn instead of the user's real page. + assert!(is_placeholder_url("data:,")); + assert!(is_placeholder_url("about:blank")); + assert!(!is_placeholder_url("https://www.bouyguestelecom.fr")); + assert!(!is_placeholder_url("http://127.0.0.1:8080/app")); } #[test] @@ -864,12 +623,14 @@ mod tests { #[test] fn recent_screen_respects_freshness_window() { - let keeper = WebStudioKeeper::stub_for_tests(); + let keeper = WebDriverKeeper::stub_for_tests(); assert!(keeper.recent_screen(Duration::from_secs(2)).is_none()); - let screen: DeviceScreen = serde_json::from_str( - r#"{"screenshot":"/screenshot/a.png","width":10,"height":10,"url":"https://x","elements":[]}"#, - ) - .unwrap(); + let screen = DeviceScreen { + width: 10, + height: 10, + elements: serde_json::json!([]), + url: Some("https://x".into()), + }; keeper.note_screen(&screen); // Just stored → fresh. assert!(keeper.recent_screen(Duration::from_secs(2)).is_some()); @@ -878,57 +639,95 @@ mod tests { } #[test] - fn extracts_first_sse_data_line() { - // Only a newline-terminated `data:` line counts as complete. - assert_eq!(extract_sse_data(b"data: {\"a\":1}"), None); - assert_eq!( - extract_sse_data(b"data: {\"a\":1}\n\n").as_deref(), - Some("{\"a\":1}") + fn web_sweep_needles_are_scoped_to_web_keepers() { + // Regression: a broad sweep once killed the iOS simulator session. + // The scoped needles must not — nor a user's own `maestro mcp`. + let ios = "java -classpath /opt/homebrew/Cellar/maestro/2.10.0/libexec/lib/* maestro.cli.AppKt --device ABC mcp --no-viewer"; + let web = "java -classpath /opt/homebrew/Cellar/maestro/2.10.0/libexec/lib/* maestro.cli.AppKt -p web mcp --no-viewer"; + let user = "java -classpath /opt/homebrew/Cellar/maestro/2.10.0/libexec/lib/* maestro.cli.AppKt mcp"; + assert!(crate::prockill::cmdline_matches(web, WEB_KEEPER_NEEDLES)); + assert!(!crate::prockill::cmdline_matches(ios, WEB_KEEPER_NEEDLES)); + assert!(!crate::prockill::cmdline_matches(user, WEB_KEEPER_NEEDLES)); + } + + /// Live end-to-end check against a real `maestro mcp` + Chrome: launch on + /// a page, read the hierarchy, stream a frame, run a command, tear down. + /// MAESTRO_BIN=/path/to/maestro-2.10.0 cargo test --manifest-path \ + /// src-tauri/Cargo.toml web_keeper_end_to_end -- --ignored --nocapture + #[tokio::test(flavor = "multi_thread")] + #[ignore] + async fn web_keeper_end_to_end() { + let t = std::time::Instant::now(); + let keeper = WebDriverKeeper::start(Some("https://en.wikipedia.org/wiki/Main_Page"), None) + .await + .expect("start web keeper"); + eprintln!("started in {:?}", t.elapsed()); + assert!(keeper.is_alive().await, "keeper not alive after start"); + + let screen = keeper.device_screen().await.expect("device screen"); + eprintln!( + "screen {}x{}, {} elements, url {:?}", + screen.width, + screen.height, + screen.elements.as_array().map_or(0, Vec::len), + screen.url ); - assert!(extract_sse_data(b":comment\n").is_none()); - } - - #[test] - fn default_trigger_is_local_studio_page() { - assert_eq!(default_trigger_url(9999), "http://127.0.0.1:9999/interact"); - } - - #[test] - fn studio_args_select_web_platform() { - let a = studio_args(); - // `-p web` must come before the `studio` subcommand. - assert_eq!(a[0], "-p"); - assert_eq!(a[1], "web"); - assert_eq!(a[2], "studio"); - } - - #[test] - fn web_sweep_needles_are_scoped_to_web_studios() { - // Regression: the old sweep (`pgrep maestro.*studio`) killed the iOS - // simulator studio session. The scoped needles must not. - let ios = "java -classpath /opt/homebrew/Cellar/maestro/2.5.1/libexec/lib/* maestro.cli.AppKt --device ABC studio --no-window"; - let web = "java -classpath /opt/homebrew/Cellar/maestro/2.5.1/libexec/lib/* maestro.cli.AppKt -p web studio --no-window"; - assert!(crate::prockill::cmdline_matches(web, WEB_STUDIO_NEEDLES)); - assert!(!crate::prockill::cmdline_matches(ios, WEB_STUDIO_NEEDLES)); - } - - #[test] - fn preflight_classifies_port_owners() { - let web = "java -cp maestro/lib maestro.cli.AppKt -p web studio --no-window"; - let ios = "java -cp maestro/lib maestro.cli.AppKt --device ABC studio --no-window"; - let foreign = "/usr/bin/python3 -m http.server 9999"; - assert!(matches!( - classify_port_owner(web), - PortOwnerKind::OrphanWebStudio - )); - assert!(matches!( - classify_port_owner(ios), - PortOwnerKind::MobileStudio - )); - assert!(matches!( - classify_port_owner(foreign), - PortOwnerKind::Foreign + assert!(screen.width > 0 && screen.height > 0); + assert!(screen.elements.as_array().is_some_and(|a| !a.is_empty())); + assert!(screen + .url + .as_deref() + .is_some_and(|u| u.contains("wikipedia"))); + + let (abort_tx, abort_rx) = oneshot::channel(); + let (frame_tx, frame_rx) = std::sync::mpsc::channel(); + let k = keeper.clone(); + let cast = tokio::spawn(cdp::screencast( + move || { + let k = k.clone(); + async move { k.page_ws_url().await } + }, + abort_rx, + move |f| { + let _ = frame_tx.send((f.width, f.height, f.data.len())); + }, )); + let frame = + tokio::task::spawn_blocking(move || frame_rx.recv_timeout(Duration::from_secs(10))) + .await + .unwrap() + .expect("no screencast frame"); + eprintln!("frame {frame:?}"); + let _ = abort_tx.send(()); + let _ = cast.await; + + let t = std::time::Instant::now(); + keeper + .run_command("openLink: https://example.com") + .await + .expect("openLink"); + eprintln!("openLink in {:?}", t.elapsed()); + let t = std::time::Instant::now(); + keeper + .run_command("tapOn: {point: \"50%,50%\"}") + .await + .expect("point tap"); + eprintln!("tap in {:?}", t.elapsed()); + let after = keeper.device_screen().await.expect("screen after"); + assert!(after + .url + .as_deref() + .is_some_and(|u| u.contains("example.com"))); + let err = keeper + .run_command("assertVisible: \"NoSuchElementXYZ\"") + .await + .expect_err("missing element must fail"); + eprintln!("expected failure: {err}"); + + keeper.stop().await; + assert!(!keeper.is_alive().await); + let leftovers = crate::prockill::pids_matching(WEBDRIVER_CHROME_NEEDLES).await; + assert!(leftovers.is_empty(), "Chrome left behind: {leftovers:?}"); } #[test] @@ -940,32 +739,4 @@ mod tests { assert!(state.web_run_active.load(SeqCst)); assert_eq!(state.web_respawn_fails.load(SeqCst), 0); } - - #[test] - fn sse_buffer_yields_last_complete_event_and_drains() { - let mut b = SseBuffer::default(); - b.push(b"data: {\"a\":1}\n\ndata: {\"a\":2}\n\n"); - // Two complete events buffered -> coalesce to the newest. - assert_eq!(b.latest_event().as_deref(), Some("{\"a\":2}")); - // Drained: nothing left until more data arrives. - assert_eq!(b.latest_event(), None); - } - - #[test] - fn sse_buffer_handles_chunked_events() { - let mut b = SseBuffer::default(); - b.push(b"data: {\"a\""); - assert_eq!(b.latest_event(), None); // incomplete — wait for more - b.push(b":1}\n\nda"); - assert_eq!(b.latest_event().as_deref(), Some("{\"a\":1}")); - b.push(b"ta: {\"a\":2}\n\n"); - assert_eq!(b.latest_event().as_deref(), Some("{\"a\":2}")); - } - - #[test] - fn sse_buffer_ignores_comment_lines() { - let mut b = SseBuffer::default(); - b.push(b":keepalive\n\ndata: {\"x\":1}\n\n"); - assert_eq!(b.latest_event().as_deref(), Some("{\"x\":1}")); - } } diff --git a/src-tauri/src/web_session/run_mirror.rs b/src-tauri/src/web_session/run_mirror.rs index a663f98..a443257 100644 --- a/src-tauri/src/web_session/run_mirror.rs +++ b/src-tauri/src/web_session/run_mirror.rs @@ -8,29 +8,18 @@ //! regular `web_frame` event — the canvas shows the test executing live. //! Best-effort: if the mirror can't attach, the run itself is unaffected. -use std::time::Duration; - -use futures::{SinkExt, StreamExt}; use tauri::{AppHandle, Emitter}; use tokio::sync::oneshot; -use tokio::time::sleep; -use tokio_tungstenite::tungstenite::Message; use tracing::{info, warn}; -use super::{WebFramePayload, WEB_FRAME_EVENT}; +use super::{cdp, WEB_FRAME_EVENT}; /// Marker of the run's Chrome: webdriver-driven, main process, AND headless — -/// the studio session's hidden browser is headed, so this never matches it. +/// the session keeper's hidden browser is headed, so this never matches it. fn is_headless_run_chrome(cmdline: &str) -> bool { super::is_main_browser_process(cmdline) && cmdline.contains("--headless") } -/// `--user-data-dir=` from a Chrome command line. -fn user_data_dir(cmdline: &str) -> Option<&str> { - let start = cmdline.find("--user-data-dir=")? + "--user-data-dir=".len(); - cmdline[start..].split_whitespace().next() -} - /// DevTools HTTP port of the run's Chrome, once both the process and its /// `DevToolsActivePort` file exist (Chrome writes it shortly after spawn). async fn find_devtools_port() -> Option { @@ -38,44 +27,17 @@ async fn find_devtools_port() -> Option { if !is_headless_run_chrome(&cmd) { continue; } - let Some(dir) = user_data_dir(&cmd) else { - continue; - }; - if let Ok(s) = tokio::fs::read_to_string(format!("{dir}/DevToolsActivePort")).await { - if let Some(port) = s.lines().next().and_then(|l| l.trim().parse().ok()) { - return Some(port); - } + if let Some(port) = cdp::devtools_port(&cmd).await { + return Some(port); } } None } -/// `webSocketDebuggerUrl` of the first `page` target on a DevTools endpoint. -async fn page_ws_url(port: u16) -> Option { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(1)) - .timeout(Duration::from_secs(3)) - .build() - .ok()?; - let targets: serde_json::Value = client - .get(format!("http://127.0.0.1:{port}/json/list")) - .send() - .await - .ok()? - .json() - .await - .ok()?; - targets - .as_array()? - .iter() - .find(|t| t["type"] == "page") - .and_then(|t| t["webSocketDebuggerUrl"].as_str().map(str::to_string)) -} - /// Kill headless run Chromes left over from a PREVIOUS run. maestro doesn't /// always reap its browser on exit, and a lingering one would win the /// mirror's discovery race: the canvas would show the last run's final frame -/// instead of the new run executing. The studio's browser is headed, so it +/// instead of the new run executing. The web keeper's browser is headed, so it /// never matches; a stale chromedriver (browserless) is harmless and gets /// swept at the next keeper start. pub async fn kill_stale_run_chromes() { @@ -91,91 +53,22 @@ pub async fn kill_stale_run_chromes() { } /// Attach to the run's Chrome and re-emit screencast frames until aborted. -/// CDP pushes a frame only when the page actually changes — ideal cadence -/// for a live test view, and `data` is already base64 PNG (zero re-encode). pub fn spawn_run_mirror(app: AppHandle) -> oneshot::Sender<()> { - let (abort_tx, mut abort_rx) = oneshot::channel::<()>(); + let (abort_tx, abort_rx) = oneshot::channel::<()>(); tokio::spawn(async move { - 'attach: loop { - // The run's Chrome takes a few seconds to appear (maestro boots - // chromedriver first) — poll until it does or we're aborted. - let ws_url = loop { - if let Some(port) = find_devtools_port().await { - if let Some(u) = page_ws_url(port).await { - break u; - } - } - tokio::select! { - biased; - _ = &mut abort_rx => return, - _ = sleep(Duration::from_millis(300)) => {} - } - }; - let Ok((mut ws, _)) = tokio_tungstenite::connect_async(&ws_url).await else { - tokio::select! { - biased; - _ = &mut abort_rx => return, - _ = sleep(Duration::from_millis(500)) => {} - } - continue 'attach; - }; - info!(%ws_url, "run mirror attached (CDP screencast)"); - // JPEG q75: ~10× smaller than PNG per frame (and much faster for - // Chrome to encode) — the difference between a slideshow and a - // real-time feel. The frontend sniffs the 0xFFD8 magic and types - // the blob accordingly. - let _ = ws - .send(Message::Text( - r#"{"id":1,"method":"Page.startScreencast","params":{"format":"jpeg","quality":75,"everyNthFrame":1,"maxWidth":1600,"maxHeight":1600}}"#.into(), - )) - .await; - loop { - let msg = tokio::select! { - biased; - _ = &mut abort_rx => return, - m = ws.next() => m, - }; - match msg { - Some(Ok(Message::Text(txt))) => { - let Ok(v) = serde_json::from_str::(&txt) else { - continue; - }; - if v["method"] != "Page.screencastFrame" { - continue; - } - let p = &v["params"]; - let (Some(data), Some(sid)) = (p["data"].as_str(), p["sessionId"].as_i64()) - else { - continue; - }; - let payload = WebFramePayload { - // CDP delivers base64 PNG — pass through untouched. - data: data.to_string(), - width: p["metadata"]["deviceWidth"].as_u64().unwrap_or(0) as u32, - height: p["metadata"]["deviceHeight"].as_u64().unwrap_or(0) as u32, - }; - if let Err(e) = app.emit(WEB_FRAME_EVENT, &payload) { - warn!(error = %e, "failed to emit run-mirror frame"); - } - let ack = format!( - r#"{{"id":2,"method":"Page.screencastFrameAck","params":{{"sessionId":{sid}}}}}"# - ); - let _ = ws.send(Message::Text(ack)).await; - } - Some(Ok(_)) => {} - Some(Err(_)) | None => { - // Tab closed or navigated to a fresh target, or the - // run ended — try to re-attach until aborted. - tokio::select! { - biased; - _ = &mut abort_rx => return, - _ = sleep(Duration::from_millis(300)) => {} - } - continue 'attach; - } - } + // The run's Chrome takes a few seconds to appear (maestro boots + // chromedriver first) — `screencast` polls until it does. + let find = || async { + let port = find_devtools_port().await?; + cdp::page_ws_url(port).await + }; + cdp::screencast(find, abort_rx, |payload| { + if let Err(e) = app.emit(WEB_FRAME_EVENT, &payload) { + warn!(error = %e, "failed to emit run-mirror frame"); } - } + }) + .await; + info!("run mirror stopped"); }); abort_tx } @@ -187,21 +80,12 @@ mod tests { // Real command line captured 2026-07-10 from a `maestro -p web test // --headless` run. const RUN_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --allow-pre-commit-input --enable-automation --headless=new --test-type=webdriver --user-data-dir=/var/folders/kc/T/org.chromium.Chromium.scoped_dir.t1KHli data:,"; - const STUDIO_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --enable-automation --test-type=webdriver --user-data-dir=/tmp/x data:,"; + const SESSION_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --enable-automation --test-type=webdriver --user-data-dir=/tmp/x data:,"; #[test] fn run_chrome_matcher_requires_headless() { - // The studio's hidden (but headed) browser must never be mirrored. + // The session keeper's hidden (but headed) browser must never be mirrored. assert!(is_headless_run_chrome(RUN_CHROME)); - assert!(!is_headless_run_chrome(STUDIO_CHROME)); - } - - #[test] - fn extracts_user_data_dir() { - assert_eq!( - user_data_dir(RUN_CHROME), - Some("/var/folders/kc/T/org.chromium.Chromium.scoped_dir.t1KHli") - ); - assert_eq!(user_data_dir("chrome --no-user-data"), None); + assert!(!is_headless_run_chrome(SESSION_CHROME)); } } diff --git a/src/components/QuitConfirmDialog.tsx b/src/components/QuitConfirmDialog.tsx index 1c295aa..86d471b 100644 --- a/src/components/QuitConfirmDialog.tsx +++ b/src/components/QuitConfirmDialog.tsx @@ -19,7 +19,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; * and emits `quit-requested`; we either ask the user (default) or, if they * opted out, quit straight away. Either path ends in `ipc.confirmQuit()`, which * tears every session down before the process exits — so a fast quit never - * leaves orphaned studio / chromedriver / Chrome / iproxy processes behind. + * leaves orphaned maestro / chromedriver / Chrome / iproxy processes behind. */ export function QuitConfirmDialog() { const [open, setOpen] = useState(false); diff --git a/src/components/SetupPopup.tsx b/src/components/SetupPopup.tsx index d7ad6d2..981f349 100644 --- a/src/components/SetupPopup.tsx +++ b/src/components/SetupPopup.tsx @@ -10,7 +10,7 @@ import { selectPopupVisible, useEnvStore } from "@/stores/envStore"; import { useRunStore } from "@/stores/runStore"; const LABELS: Record = { - maestro: "maestro CLI 2.5.1", + maestro: "maestro CLI 2.10.0", java: "Java 17+", adb: "adb (Android)", xcode: "Xcode (iOS)", @@ -18,7 +18,7 @@ const LABELS: Record = { /** Fallback copy-paste commands for rows without one-click install. */ const MANUAL_COMMANDS: Record = { - maestro: "MAESTRO_VERSION=2.5.1 curl -Ls 'https://get.maestro.mobile.dev' | bash", + maestro: "MAESTRO_VERSION=2.10.0 curl -Ls 'https://get.maestro.mobile.dev' | bash", java: "brew install --cask temurin@21", adb: "brew install --cask android-platform-tools", xcode: "xcode-select --install # or install Xcode from the App Store", @@ -97,7 +97,7 @@ function CheckRow({ /** * Onboarding environment checker. Fixed bottom-right, above toasts (z-70 vs * their z-60), below the tour (z-100). Collapsible to a badge but NOT - * dismissible until the minimal setup (maestro 2.5.1 + Java 17+) passes; + * dismissible until the minimal setup (maestro 2.10.0 + Java 17+) passes; * once it does, it auto-dismisses forever (Settings → Environment remains). * * We read store state via getState() / useState initialiser (not the hook) so diff --git a/src/components/ToolPathsSettings.tsx b/src/components/ToolPathsSettings.tsx index f9d3383..2052894 100644 --- a/src/components/ToolPathsSettings.tsx +++ b/src/components/ToolPathsSettings.tsx @@ -238,9 +238,9 @@ export function ToolPathsSettings() { className="rounded border border-border bg-background px-2 py-1 font-mono text-xs" />

- Used to code-sign the iOS test driver when launching it via{" "} - maestro studio. Found in your Apple Developer account - (Membership → Team ID). Leave empty if maestro is already configured with it. + Used to code-sign the iOS test driver on a physical iPhone (via{" "} + maestro-ios-device). Found in your Apple Developer + account (Membership → Team ID). Leave empty if maestro is already configured with it.

diff --git a/src/components/settings/DevicePerformanceSettings.tsx b/src/components/settings/DevicePerformanceSettings.tsx index 8422b59..e223443 100644 --- a/src/components/settings/DevicePerformanceSettings.tsx +++ b/src/components/settings/DevicePerformanceSettings.tsx @@ -42,9 +42,9 @@ export function DevicePerformanceSettings() { } description={ <> - Keeps a maestro studio process warm in background - and talks gRPC directly to the on-device driver. First inspect takes ~15 s, subsequent - dumps drop from ~11 s to <1 s. Falls back to the CLI path if studio fails. + Keeps a maestro mcp process warm in background and + talks gRPC directly to the on-device driver. First inspect takes ~15 s, subsequent + dumps drop from ~11 s to <1 s. Falls back to the CLI path if the keeper fails. } checked={fastHierarchyEnabled} diff --git a/src/components/settings/EnvironmentSettings.tsx b/src/components/settings/EnvironmentSettings.tsx index d936efe..0d53a20 100644 --- a/src/components/settings/EnvironmentSettings.tsx +++ b/src/components/settings/EnvironmentSettings.tsx @@ -7,7 +7,7 @@ import { useEffect } from "react"; import { useEnvStore } from "@/stores/envStore"; const LABELS: Record = { - maestro: "maestro CLI (required: 2.5.1)", + maestro: "maestro CLI (required: 2.10.0)", java: "Java runtime (required: 17+)", adb: "adb — Android platform tools", xcode: "Xcode — iOS simulators", diff --git a/src/lib/chat/billy-prompt.md b/src/lib/chat/billy-prompt.md index f3a7c2e..6be7da1 100644 --- a/src/lib/chat/billy-prompt.md +++ b/src/lib/chat/billy-prompt.md @@ -55,7 +55,7 @@ appId: com.example.app - **Inspector** — dumps the device hierarchy and suggests selectors. - **Device view** — live scrcpy stream of the device. - **Performance HUD** — CPU / mem / FPS / jank metrics during runs. -- **Fast hierarchy** (experimental) — keeps a `maestro studio` process warm for sub-second hierarchy dumps. +- **Fast hierarchy** (experimental) — keeps a `maestro mcp` process warm for sub-second hierarchy dumps. ## How to be useful diff --git a/src/lib/maestro-commands.json b/src/lib/maestro-commands.json index 87b2de4..30cab52 100644 --- a/src/lib/maestro-commands.json +++ b/src/lib/maestro-commands.json @@ -3,6 +3,8 @@ "label": "addMedia", "info": "Add images or videos to the device gallery for media picker testing." }, + { "label": "assertDarkMode", "info": "Assert that the device is in dark mode." }, + { "label": "assertLightMode", "info": "Assert that the device is in light mode." }, { "label": "assertNoDefectsWithAI", "info": "AI-powered visual testing to detect UI defects and anomalies." @@ -88,6 +90,7 @@ "label": "setClipboard", "info": "Set the device clipboard content to a specified text value." }, + { "label": "setDarkMode", "info": "Switch the device theme to dark or light mode." }, { "label": "setLocation", "info": "Set device GPS location to specific coordinates for location testing." @@ -122,6 +125,7 @@ "label": "toggleAirplaneMode", "info": "Toggle airplane mode on or off during test execution." }, + { "label": "toggleDarkMode", "info": "Toggle the device theme between dark and light mode." }, { "label": "travel", "info": "Simulate time travel by adjusting the device system clock." }, { "label": "waitForAnimationToEnd", diff --git a/src/lib/runStepParser.test.ts b/src/lib/runStepParser.test.ts index a407ab3..27ed6b0 100644 --- a/src/lib/runStepParser.test.ts +++ b/src/lib/runStepParser.test.ts @@ -146,6 +146,41 @@ describe("parseLine (step events)", () => { }); }); + // maestro 2.8+ appends the element-relative start point (`point:`). + it("parses swipe on an element with a relative point", () => { + expect(parseLine(`Swiping in UP direction on "Card" at 50%,90%... COMPLETED`)).toMatchObject({ + command: "swipe", + arg: "Card", + }); + expect(parseLine(`Swiping in UP direction on id: card at 50%, 90%... COMPLETED`)).toMatchObject( + { + command: "swipe", + arg: "card", + }, + ); + // A quoted text that merely contains " at " is not a point suffix. + expect(parseLine(`Swiping in UP direction on "Look at me"... COMPLETED`)).toMatchObject({ + command: "swipe", + arg: "Look at me", + }); + }); + + // maestro 2.9 dark-mode commands. + it("parses setDarkMode, toggleDarkMode, assertDarkMode and assertLightMode", () => { + expect(parseLine(`Enable dark mode... COMPLETED`)).toMatchObject({ command: "setDarkMode" }); + expect(parseLine(`Disable dark mode... COMPLETED`)).toMatchObject({ command: "setDarkMode" }); + expect(parseLine(`Toggle dark mode... COMPLETED`)).toMatchObject({ + command: "toggleDarkMode", + }); + expect(parseLine(`Assert dark mode is enabled... FAILED`)).toMatchObject({ + command: "assertDarkMode", + kind: "failed", + }); + expect(parseLine(`Assert dark mode is disabled... COMPLETED`)).toMatchObject({ + command: "assertLightMode", + }); + }); + it("parses pressKey (Press {Key} key) and back (Press back)", () => { expect(parseLine(`Press Enter key... COMPLETED`)).toMatchObject({ command: "pressKey", diff --git a/src/lib/runStepParser.ts b/src/lib/runStepParser.ts index d2473a1..2ae45b7 100644 --- a/src/lib/runStepParser.ts +++ b/src/lib/runStepParser.ts @@ -3,8 +3,8 @@ // Parser for the Maestro CLI's *plain-text* result view — the format the CLI // uses whenever stdout is not a TTY, which is always the case here since the -// runner pipes it. Verified against maestro 2.5.1 real output and the -// PlainTextResultView source: +// runner pipes it. Verified against maestro 2.5.1 and 2.10.0 real output +// (identical) and the PlainTextResultView source: // // Running on iPhone 16 Pro - iOS 18.2 - // > Flow settings-check ← flow header (1 space) @@ -48,7 +48,7 @@ interface Pattern { } // Description → command patterns, ordered most-specific-first. Templates come -// from maestro-orchestra-models Commands.kt (v2.5.1) `description()` methods. +// from maestro-orchestra-models Commands.kt (v2.10.0) `description()` methods. // For arg-bearing commands, `re` captures the raw *target* in group 1 — a // quoted text selector (`"Welcome"`), an unquoted id selector // (`id: welcomeMessage`), or an unquoted value (`Alice` for inputText). @@ -103,7 +103,12 @@ const PATTERNS: Pattern[] = [ re: /^Scrolling (?:UP|DOWN|LEFT|RIGHT) until (.+?) is visible/, }, { command: "scrollUntilVisible", re: /^Scroll until (.+?) is visible/ }, - { command: "swipe", re: /^Swiping in (?:UP|DOWN|LEFT|RIGHT) direction on (.+?)$/ }, + // 2.8+ appends ` at {point}` when the swipe starts at an element-relative + // point; strip it so the arg stays the bare selector. + { + command: "swipe", + re: /^Swiping in (?:UP|DOWN|LEFT|RIGHT) direction on (.+?)(?: at [\d.%,\s-]+)?$/, + }, { command: "swipe", re: null, bareRe: /^Swiping in (?:UP|DOWN|LEFT|RIGHT) direction/ }, { command: "swipe", re: null, bareRe: /^Swipe from \(.+?\) to \(.+?\)/ }, { command: "swipe", re: null, bareRe: /^Invalid input to swipe command/ }, @@ -122,6 +127,10 @@ const PATTERNS: Pattern[] = [ { command: "travel", re: null, bareRe: /^Travel path / }, { command: "setAirplaneMode", re: null, bareRe: /^(?:Enable|Disable) airplane mode/ }, { command: "toggleAirplaneMode", re: null, bareRe: /^Toggle airplane mode/ }, + { command: "setDarkMode", re: null, bareRe: /^(?:Enable|Disable) dark mode/ }, + { command: "toggleDarkMode", re: null, bareRe: /^Toggle dark mode/ }, + { command: "assertDarkMode", re: null, bareRe: /^Assert dark mode is enabled/ }, + { command: "assertLightMode", re: null, bareRe: /^Assert dark mode is disabled/ }, { command: "addMedia", re: null, bareRe: /^Adding media files/ }, { command: "setOrientation", re: /^Set orientation (.+?)$/ }, { command: "setPermissions", re: null, bareRe: /^Set permissions/ }, diff --git a/src/stores/inspectorStore.ts b/src/stores/inspectorStore.ts index 4e7e750..f4f77d4 100644 --- a/src/stores/inspectorStore.ts +++ b/src/stores/inspectorStore.ts @@ -18,7 +18,7 @@ import type { HierarchyTree, Selector, UINode } from "@/types"; const fastMode = () => useSettingsStore.getState().fastHierarchyEnabled; // A `maestro test` run owns the iOS simulator driver exclusively; an inspect -// dump mid-run would spawn a competing `maestro studio` and deadlock both on +// dump mid-run would spawn a competing `maestro mcp` keeper and deadlock both on // :22087 (the run then never starts). Pause dumps while a run is in flight. const runInFlight = () => { const s = useRunStore.getState(); diff --git a/src/stores/settingsStore.test.ts b/src/stores/settingsStore.test.ts index d4aa68f..b595a71 100644 --- a/src/stores/settingsStore.test.ts +++ b/src/stores/settingsStore.test.ts @@ -43,7 +43,7 @@ describe("settingsStore defaults", () => { expect(s.theme).toBe("system"); expect(s.streamEnabled).toBe(true); // Fast hierarchy is on by default — it falls back to the CLI path if - // the studio keeper fails, so there's no downside to opting everyone in. + // the driver keeper fails, so there's no downside to opting everyone in. expect(s.fastHierarchyEnabled).toBe(true); expect(s.autoSaveEnabled).toBe(true); expect(s.consoleMode).toBe("simple"); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 7816ce9..52b8e1f 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -13,13 +13,13 @@ interface SettingsState { theme: ThemeMode; streamEnabled: boolean; /** - * When enabled, inspect mode spawns `maestro studio` once at startup + * When enabled, inspect mode spawns a `maestro mcp` keeper once at startup * (slow: 10-15s) and then fetches the hierarchy over direct gRPC on * each subsequent dump (<500ms). When disabled, each dump shells out * to the `maestro hierarchy` CLI (simple but ~11s per dump). * * Experimental flag — depends on an undocumented port contract of - * the Maestro driver + a studio background process. Off by default + * the Maestro driver + a `maestro mcp` background process. Off by default * until we've validated output parity against the CLI path. */ fastHierarchyEnabled: boolean; From 28217c86013d47d0917fa09aa854d69cc34b4a02 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 12:42:49 +0200 Subject: [PATCH 2/8] fix(setup): re-download a managed maestro installed at another version The managed install short-circuited on any recorded maestro path, so a user whose app had installed 2.5.1 would keep it forever. The manifest now records the version maestro was installed at; a mismatch (or a legacy manifest without one) reads as not installed, and the automatic setup fetches the pinned release. --- src-tauri/src/tool_setup/mod.rs | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/tool_setup/mod.rs b/src-tauri/src/tool_setup/mod.rs index df5c23e..c19a17b 100644 --- a/src-tauri/src/tool_setup/mod.rs +++ b/src-tauri/src/tool_setup/mod.rs @@ -170,12 +170,21 @@ pub enum ArchiveKind { /// extracted tree, and doing that on every resolution would be wasteful. The /// path is re-validated on read, so a manifest pointing at a deleted file is /// treated as "not installed" rather than handed out. +/// +/// Maestro also records the version it was installed at: the app pins an +/// exact release, and a managed install from an older app version (e.g. 2.5.1, +/// which still relied on `maestro studio`) must read as "not installed" so the +/// setup downloads the pinned one instead of reusing it forever. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct ToolManifest { #[serde(default)] pub java: Option, #[serde(default)] pub maestro: Option, + /// Version `maestro` was installed at. Absent in manifests written before + /// it was recorded — those installs were 2.5.1. + #[serde(default)] + pub maestro_version: Option, #[serde(default)] pub adb: Option, } @@ -184,7 +193,12 @@ impl ToolManifest { pub fn get(&self, tool: ManagedTool) -> Option<&str> { let raw = match tool { ManagedTool::Java => self.java.as_deref(), - ManagedTool::Maestro => self.maestro.as_deref(), + ManagedTool::Maestro => { + if self.maestro_version.as_deref() != Some(crate::env_check::REQUIRED_MAESTRO) { + return None; + } + self.maestro.as_deref() + } ManagedTool::Adb => self.adb.as_deref(), }?; // A recorded path whose file has since gone is worse than no path: the @@ -196,7 +210,10 @@ impl ToolManifest { let value = Some(path.to_string_lossy().into_owned()); match tool { ManagedTool::Java => self.java = value, - ManagedTool::Maestro => self.maestro = value, + ManagedTool::Maestro => { + self.maestro = value; + self.maestro_version = Some(crate::env_check::REQUIRED_MAESTRO.to_string()); + } ManagedTool::Adb => self.adb = value, } } @@ -305,6 +322,41 @@ mod tests { assert_eq!(m.get(ManagedTool::Maestro), Some(bin.to_str().unwrap())); } + #[test] + fn a_managed_maestro_from_another_version_reads_as_not_installed() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("maestro"); + std::fs::write(&bin, b"#!/bin/sh\n").unwrap(); + + // A manifest written by an app version that installed 2.5.1 had no + // version field at all. + let legacy = format!(r#"{{"maestro":{:?}}}"#, bin.to_str().unwrap()); + let m: ToolManifest = serde_json::from_str(&legacy).unwrap(); + assert_eq!(m.get(ManagedTool::Maestro), None); + + let mut m = m; + m.maestro_version = Some("2.5.1".into()); + assert_eq!(m.get(ManagedTool::Maestro), None); + + // Re-installing records the pinned version, so it is handed out again. + m.set(ManagedTool::Maestro, &bin); + assert_eq!( + m.maestro_version.as_deref(), + Some(crate::env_check::REQUIRED_MAESTRO) + ); + assert_eq!(m.get(ManagedTool::Maestro), Some(bin.to_str().unwrap())); + } + + #[test] + fn other_managed_tools_do_not_depend_on_the_maestro_version() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("adb"); + std::fs::write(&bin, b"#!/bin/sh\n").unwrap(); + let mut m = ToolManifest::default(); + m.set(ManagedTool::Adb, &bin); + assert_eq!(m.get(ManagedTool::Adb), Some(bin.to_str().unwrap())); + } + #[test] fn find_binary_prefers_the_launcher_in_bin() { let dir = tempfile::tempdir().unwrap(); From 04aa8f5b016998c411f6137ede2baf216aa6a524 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 12:47:49 +0200 Subject: [PATCH 3/8] fix(toast): honour a dismiss that lands during the swap delay push() delays inserting a toast by 180 ms when another one is open. A quick operation (driver recovery takes ~100 ms) dismissed its toast inside that window: the dismiss matched nothing, then the toast was inserted and, being persistent, stayed up forever. Pending toasts are now tracked so such a dismiss cancels the insert. --- src/stores/toastStore.test.ts | 29 +++++++++++++++++++++++++++++ src/stores/toastStore.ts | 20 ++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/stores/toastStore.test.ts b/src/stores/toastStore.test.ts index 90b245c..b4bdd68 100644 --- a/src/stores/toastStore.test.ts +++ b/src/stores/toastStore.test.ts @@ -50,6 +50,35 @@ describe("toastStore.push", () => { }); }); +describe("toastStore.dismiss during the swap delay", () => { + // Regression: a quick op (driver recovery ~100 ms) dismissed its toast + // before the delayed insert ran; the dismiss hit nothing and the toast + // then appeared — persistent — and never went away. + it("a toast dismissed before it is inserted never shows up", () => { + useToastStore.getState().push({ title: "inspecting", variant: "action", persistent: true }); + const id = useToastStore + .getState() + .push({ title: "Recovering driver…", variant: "default", persistent: true }); + useToastStore.getState().dismiss(id); + + vi.advanceTimersByTime(180); + + expect(useToastStore.getState().toasts.some((t) => t.id === id && t.open)).toBe(false); + }); + + it("dismissing one pending toast does not drop a later one", () => { + useToastStore.getState().push({ title: "old", variant: "default" }); + const a = useToastStore.getState().push({ title: "a", variant: "default" }); + useToastStore.getState().dismiss(a); + const b = useToastStore.getState().push({ title: "b", variant: "default" }); + + vi.advanceTimersByTime(180); + + const open = useToastStore.getState().toasts.filter((t) => t.open); + expect(open.map((t) => t.id)).toEqual([b]); + }); +}); + describe("toastStore.dismiss / setClosed", () => { it("dismiss flips open=false but keeps the toast", () => { const id = useToastStore.getState().push({ title: "x", variant: "default" }); diff --git a/src/stores/toastStore.ts b/src/stores/toastStore.ts index de67244..d2918a6 100644 --- a/src/stores/toastStore.ts +++ b/src/stores/toastStore.ts @@ -31,6 +31,12 @@ interface ToastState { // its place (SwipeToast's full exit is ~340ms; the overlap is intentional). const SWAP_DELAY_MS = 180; +// Toasts pushed but not yet inserted (waiting out SWAP_DELAY_MS), mapped to +// whether they were dismissed meanwhile. A quick operation can finish — and +// dismiss its toast — inside the delay; without this the dismiss hit nothing +// and the toast appeared afterwards, stuck forever if persistent. +const pending = new Map(); + export const useToastStore = create((set, get) => ({ toasts: [], push: (t) => { @@ -41,7 +47,11 @@ export const useToastStore = create((set, get) => ({ set((s) => ({ toasts: s.toasts.map((x) => (x.open ? { ...x, open: false } : x)), })); + pending.set(id, { dismissed: false }); setTimeout(() => { + const dismissed = pending.get(id)?.dismissed ?? false; + pending.delete(id); + if (dismissed) return; set((s) => ({ toasts: [...s.toasts.filter((x) => x.open), { ...t, id, open: true }], })); @@ -51,10 +61,16 @@ export const useToastStore = create((set, get) => ({ } return id; }, - dismiss: (id) => + dismiss: (id) => { + const waiting = pending.get(id); + if (waiting) { + waiting.dismissed = true; + return; + } set((s) => ({ toasts: s.toasts.map((x) => (x.id === id ? { ...x, open: false } : x)), - })), + })); + }, setClosed: (id) => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) })), })); From e7d81e091de5d796b3e354762c81ebfbdeb4d057 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 13:24:45 +0200 Subject: [PATCH 4/8] fix(runner): put takeScreenshot/startRecording files back next to the flow Since maestro 2.7 these commands write into the run's debug bundle (//takeScreenshot/.png) instead of the working directory, so the screenshot bank reported every capture as missing. Each run now gets its own flattened bundle (--debug-output --flatten-debug-output), and its command outputs are copied back to the flow's directory before runner:exit, where maestro 2.5 used to put them. --- src-tauri/src/runner/mod.rs | 164 ++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src-tauri/src/runner/mod.rs b/src-tauri/src/runner/mod.rs index e1ab840..9ee949c 100644 --- a/src-tauri/src/runner/mod.rs +++ b/src-tauri/src/runner/mod.rs @@ -51,6 +51,99 @@ fn maestro_bin() -> String { crate::tool_paths::maestro_bin() } +/// Per-run maestro output bundle. +/// +/// Since maestro 2.7, `takeScreenshot` / `startRecording` no longer write +/// CWD-relative files: they land in the run's debug bundle, under +/// `//takeScreenshot/.png`. The bank (and users) expect +/// them next to the flow, where maestro ≤ 2.6 put them (every runner sets the +/// CWD to the flow's directory). So each run gets its own bundle directory, +/// and [`RunOutput::restore_command_outputs`] copies those files back next +/// to the flow once the run ends — before `runner:exit`, which is what +/// triggers the bank comparison. +struct RunOutput { + bundle: std::path::PathBuf, + flow_dir: std::path::PathBuf, +} + +/// Bundle sub-folders holding files a flow asked for by path. +const COMMAND_OUTPUT_DIRS: [&str; 2] = ["takeScreenshot", "startRecording"]; + +impl RunOutput { + fn new(flow_dir: &std::path::Path) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default(); + let name = format!( + "{stamp}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + ); + Self { + bundle: std::env::temp_dir().join("maestro-deck-runs").join(name), + flow_dir: flow_dir.to_path_buf(), + } + } + + /// `test`-subcommand args pointing maestro's debug bundle at `bundle`. + /// `--flatten-debug-output` writes it there directly instead of under a + /// timestamped `.maestro/tests/` sub-folder. + fn args(&self) -> Vec { + vec![ + "--debug-output".into(), + self.bundle.clone().into_os_string(), + "--flatten-debug-output".into(), + ] + } + + /// Copy every file under `//{takeScreenshot,startRecording}/` + /// to the flow directory, keeping its relative path (`screens/home` → + /// `/screens/home.png`). Best-effort: failures are logged. + fn restore_command_outputs(&self) { + let Ok(flows) = std::fs::read_dir(&self.bundle) else { + return; + }; + for flow in flows.filter_map(Result::ok).map(|e| e.path()) { + for sub in COMMAND_OUTPUT_DIRS { + let root = flow.join(sub); + if root.is_dir() { + copy_tree(&root, &root, &self.flow_dir); + } + } + } + } +} + +fn copy_tree(root: &std::path::Path, dir: &std::path::Path, dest: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for path in entries.filter_map(Result::ok).map(|e| e.path()) { + if path.is_dir() { + copy_tree(root, &path, dest); + continue; + } + let Ok(rel) = path.strip_prefix(root) else { + continue; + }; + let target = dest.join(rel); + if let Some(parent) = target.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::copy(&path, &target) { + warn!(from = %path.display(), to = %target.display(), error = %e, "could not restore run output"); + } + } +} + +/// Run [`RunOutput::restore_command_outputs`] off the async runtime. +async fn restore_outputs(output: RunOutput) { + let _ = tokio::task::spawn_blocking(move || output.restore_command_outputs()).await; +} + /// Build the `-e APP_ID=` args for a maestro `test` invocation, or an /// empty vec when no app id is configured. This lets a single global APP_ID /// (set in Settings) feed the `${APP_ID}` placeholder users keep in their CI @@ -127,9 +220,11 @@ pub async fn spawn_runner( tokio::time::sleep(std::time::Duration::from_millis(200)).await; } + let output = RunOutput::new(&flow_dir); let mut child = Command::new(&bin) .no_window() .args(["--udid", serial, "test"]) + .args(output.args()) .args(&env_args) .arg(flow_path) .current_dir(&flow_dir) @@ -188,6 +283,7 @@ pub async fn spawn_runner( } }; RUNNERS.lock().await.remove(&pid); + restore_outputs(output).await; // Fire the optional post-exit hook BEFORE emitting the exit event. // The hook may schedule background work (e.g. keeper restart) that // we want kicked off as early as possible — the frontend doesn't @@ -247,10 +343,12 @@ pub async fn spawn_web_runner( .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(".")); + let output = RunOutput::new(&flow_dir); let mut child = Command::new(&bin) .no_window() // `-p web` is a global flag and must precede the `test` subcommand. .args(["-p", "web", "test", "--headless"]) + .args(output.args()) .args(&size_args) .args(&env_args) .arg(flow_path) @@ -307,6 +405,7 @@ pub async fn spawn_web_runner( } }; RUNNERS.lock().await.remove(&pid); + restore_outputs(output).await; // Run finished — release the keeper, stop the CDP mirror and hand // the canvas back to the (still-warm) keeper preview. { @@ -356,9 +455,11 @@ pub async fn spawn_ios_runner( .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(".")); + let output = RunOutput::new(&flow_dir); let mut child = Command::new(&bin) .no_window() .args(["--udid", udid, "test"]) + .args(output.args()) .args(&env_args) .arg(flow_path) .current_dir(&flow_dir) @@ -414,6 +515,7 @@ pub async fn spawn_ios_runner( } }; RUNNERS.lock().await.remove(&pid); + restore_outputs(output).await; // Run finished — let inspect/tap re-warm the simulator keeper again. { use tauri::Manager; @@ -496,9 +598,11 @@ pub async fn spawn_ios_device_runner( .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(".")); + let output = RunOutput::new(&flow_dir); let mut child = Command::new(&bin) .no_window() .args(["--driver-host-port", &port_str, "--device", udid, "test"]) + .args(output.args()) .args(&env_args) .arg(flow_path) .current_dir(&flow_dir) @@ -554,6 +658,7 @@ pub async fn spawn_ios_device_runner( } }; RUNNERS.lock().await.remove(&pid); + restore_outputs(output).await; let _ = app_exit.emit(EVT_EXIT, RunnerExit { pid, code }); }); @@ -578,6 +683,65 @@ pub async fn kill_runner(pid: u32) -> AppResult<()> { mod tests { use super::*; + #[test] + fn run_output_args_flatten_into_a_per_run_bundle() { + let a = RunOutput::new(std::path::Path::new("/flows")); + let b = RunOutput::new(std::path::Path::new("/flows")); + assert_ne!(a.bundle, b.bundle, "each run needs its own bundle"); + let args = a.args(); + assert_eq!(args[0], "--debug-output"); + assert_eq!(args[1], a.bundle.as_os_str()); + assert_eq!(args[2], "--flatten-debug-output"); + } + + #[test] + fn restores_screenshots_and_recordings_next_to_the_flow() { + // Layout written by maestro 2.10.0 (`--debug-output X + // --flatten-debug-output`): X//takeScreenshot/.png. + let tmp = tempfile::tempdir().unwrap(); + let bundle = tmp.path().join("bundle"); + let flow_dir = tmp.path().join("flows"); + std::fs::create_dir_all(&flow_dir).unwrap(); + let shots = bundle.join("login").join("takeScreenshot"); + std::fs::create_dir_all(shots.join("screens")).unwrap(); + std::fs::write(shots.join("home-bytel.png"), b"png").unwrap(); + std::fs::write(shots.join("screens").join("cart.png"), b"png2").unwrap(); + let rec = bundle.join("login").join("startRecording"); + std::fs::create_dir_all(&rec).unwrap(); + std::fs::write(rec.join("demo.mp4"), b"mp4").unwrap(); + // Everything else in the bundle stays put. + std::fs::write(bundle.join("login").join("commands.json"), b"{}").unwrap(); + std::fs::write(flow_dir.join("home-bytel.png"), b"stale").unwrap(); + + let out = RunOutput { + bundle, + flow_dir: flow_dir.clone(), + }; + out.restore_command_outputs(); + + assert_eq!( + std::fs::read(flow_dir.join("home-bytel.png")).unwrap(), + b"png" + ); + assert_eq!( + std::fs::read(flow_dir.join("screens").join("cart.png")).unwrap(), + b"png2" + ); + assert_eq!(std::fs::read(flow_dir.join("demo.mp4")).unwrap(), b"mp4"); + assert!(!flow_dir.join("commands.json").exists()); + } + + #[test] + fn restoring_a_missing_bundle_is_a_no_op() { + let tmp = tempfile::tempdir().unwrap(); + let out = RunOutput { + bundle: tmp.path().join("never-written"), + flow_dir: tmp.path().to_path_buf(), + }; + out.restore_command_outputs(); + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0); + } + #[test] fn web_screen_size_args_pin_the_interactive_viewport() { // Height is padded by the window-chrome allowance so the resulting From 093281e24eeb8facf96dde92a75a0c0e79698c95 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 13:34:51 +0200 Subject: [PATCH 5/8] fix(image-bank): stop freezing the gallery in its shrunken entry state FlowScrollGrid drives its cells from the container's scroll progress. A gallery that fits without scrolling (e.g. 6 web baselines) never moves off progress 0, so every card stayed half-size, offset and faded, leaving huge gaps. It now renders a static grid unless the container actually scrolls. When it does animate, clamping the keyframe offsets into [0, 1] piled the first row's entry keyframes up at 0, so it also started shrunken. Offsets are now re-sampled with toUnitKeyframes, which keeps the curve and computes the real value at both edges. Web baselines also get a globe icon instead of the Android logo. --- src/components/ImageBankPage.tsx | 29 ++++++--- src/components/effects/FlowScrollGrid.tsx | 61 +++++++++++-------- .../effects/keyframeOffsets.test.ts | 32 +++++++--- src/components/effects/keyframeOffsets.ts | 41 ++++++++++--- 4 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/components/ImageBankPage.tsx b/src/components/ImageBankPage.tsx index 538cdde..7894c93 100644 --- a/src/components/ImageBankPage.tsx +++ b/src/components/ImageBankPage.tsx @@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, FolderOpen, + Globe, ImageIcon, Layers, RefreshCw, @@ -29,18 +30,30 @@ import type { BankGroup, BankImage } from "@/types/visualRegression"; // device_key is `_x` (e.g. "iPhone_16_Pro_1179x2556"). // Split it back into something humans read. -function parseDeviceKey(key: string): { name: string; resolution: string; ios: boolean } { +type DeviceKind = "ios" | "android" | "web"; + +function parseDeviceKey(key: string): { name: string; resolution: string; kind: DeviceKind } { const m = key.match(/^(.*)_(\d+)x(\d+)$/); - const name = (m ? m[1] : key).replace(/_/g, " ").trim(); + const name = (m ? m[1] : key).replace(/_+/g, " ").trim(); // Older iOS banks were keyed `_0x0` (device reported no resolution) — don't // surface a meaningless "0×0". const resolution = m && !(m[2] === "0" && m[3] === "0") ? `${m[2]}×${m[3]}` : ""; - const ios = /iphone|ipad|ipod|ios/i.test(name); - return { name, resolution, ios }; + // The web target's model is "Web Browser (Chromium)". + const kind: DeviceKind = /web browser|chromium/i.test(name) + ? "web" + : /iphone|ipad|ipod|ios/i.test(name) + ? "ios" + : "android"; + return { name, resolution, kind }; } -function DeviceGlyph({ ios, className }: { ios: boolean; className?: string }) { - return ios ? : ; +function DeviceGlyph({ kind, className }: { kind: DeviceKind; className?: string }) { + if (kind === "web") return ; + return kind === "ios" ? ( + + ) : ( + + ); } function formatBytes(n: number): string { @@ -535,7 +548,7 @@ export function ImageBankPage() { active ? "text-foreground" : "text-muted-foreground", )} > - + {meta.name} @@ -565,7 +578,7 @@ export function ImageBankPage() {
- +

{activeMeta.name}

diff --git a/src/components/effects/FlowScrollGrid.tsx b/src/components/effects/FlowScrollGrid.tsx index 806fca9..21516fc 100644 --- a/src/components/effects/FlowScrollGrid.tsx +++ b/src/components/effects/FlowScrollGrid.tsx @@ -4,7 +4,7 @@ import { motion, type MotionValue, useScroll, useTransform } from "motion/react"; import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"; -import { toKeyframeOffsets } from "./keyframeOffsets"; +import { toUnitKeyframes } from "./keyframeOffsets"; const GAP_PX = 16; @@ -35,35 +35,30 @@ function FlowScrollCell({ const exitAnimation = nextRow / totalRows + scrollRangePerRow * 2; const offsetToAdd = (scrollRangePerRow / totalItems) * (currentRow + 2); - const range = toKeyframeOffsets([ - 0, + // Entry / resting / exit points; they can fall outside [0, 1] (the first + // row's entry happens "before" the scroll starts), so each property is + // re-sampled onto valid WAAPI keyframe offsets by `toUnitKeyframes`. + const points = [ entryAnimation - offsetToAdd, currPosition - offsetToAdd, currPosition - offsetToAdd, exitAnimation - offsetToAdd, - 1, - ]); - - const scale = useTransform(scrollYProgress, range, [0.5, 0.5, 1, 1, 0.5, 0.5]); + ]; const isLeft = index % ITEMS_PER_ROW === 0; const isRight = index % ITEMS_PER_ROW === ITEMS_PER_ROW - 1; - const xTransform = useTransform(scrollYProgress, range, [ - isLeft ? "60%" : isRight ? "-60%" : "0%", - isLeft ? "60%" : isRight ? "-60%" : "0%", - "0%", - "0%", - "0%", - "0%", - ]); - const rotate = useTransform(scrollYProgress, range, [ - isLeft ? -12 : isRight ? 12 : 0, - isLeft ? -12 : isRight ? 12 : 0, - 0, - 0, - 0, - 0, - ]); - const opacity = useTransform(scrollYProgress, range, [0.4, 0.4, 1, 1, 0.4, 0.4]); + const edgeX = isLeft ? 60 : isRight ? -60 : 0; + const edgeRotate = isLeft ? -12 : isRight ? 12 : 0; + + const s = toUnitKeyframes(points, [0.5, 1, 1, 0.5]); + const xk = toUnitKeyframes(points, [edgeX, 0, 0, 0]); + const rk = toUnitKeyframes(points, [edgeRotate, 0, 0, 0]); + const ok = toUnitKeyframes(points, [0.4, 1, 1, 0.4]); + + const scale = useTransform(scrollYProgress, s.offsets, s.values); + const xPercent = useTransform(scrollYProgress, xk.offsets, xk.values); + const xTransform = useTransform(xPercent, (v) => `${v}%`); + const rotate = useTransform(scrollYProgress, rk.offsets, rk.values); + const opacity = useTransform(scrollYProgress, ok.offsets, ok.values); return ( @@ -94,6 +89,22 @@ export function FlowScrollGrid({ }); const gridRef = useRef(null); const [itemsPerRow, setItemsPerRow] = useState(3); + // The effect is driven by the container's scroll progress. When the grid + // fits without scrolling, that progress stays 0 forever and every cell + // would be frozen in its shrunken, faded entry state — render it static. + const [scrollable, setScrollable] = useState(false); + + useLayoutEffect(() => { + const container = scrollContainerRef.current; + const grid = gridRef.current; + if (!container || !grid) return; + const measure = () => setScrollable(container.scrollHeight > container.clientHeight + 1); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(container); + ro.observe(grid); + return () => ro.disconnect(); + }, [scrollContainerRef]); useLayoutEffect(() => { const el = gridRef.current; @@ -114,7 +125,7 @@ export function FlowScrollGrid({ style: { gridTemplateColumns: `repeat(${itemsPerRow}, minmax(0, 1fr))`, gap: GAP_PX }, }; - if (children.length < itemsPerRow * 2) { + if (!scrollable || children.length < itemsPerRow * 2) { return
{children}
; } diff --git a/src/components/effects/keyframeOffsets.test.ts b/src/components/effects/keyframeOffsets.test.ts index a0a2af5..7d282e9 100644 --- a/src/components/effects/keyframeOffsets.test.ts +++ b/src/components/effects/keyframeOffsets.test.ts @@ -1,14 +1,32 @@ import { describe, expect, it } from "vitest"; -import { toKeyframeOffsets } from "./keyframeOffsets"; +import { toUnitKeyframes } from "./keyframeOffsets"; -describe("toKeyframeOffsets", () => { - it("clamps into [0, 1] and never decreases", () => { - expect(toKeyframeOffsets([0, -0.6, -0.1, -0.1, 0.9, 1])).toEqual([0, 0, 0, 0, 0.9, 1]); - expect(toKeyframeOffsets([0, 0.2, 0.5, 0.5, 1.4, 1])).toEqual([0, 0.2, 0.5, 0.5, 1, 1]); +describe("toUnitKeyframes", () => { + it("keeps an in-range animation as is, framed by holds at 0 and 1", () => { + expect(toUnitKeyframes([0.1, 0.3, 0.3, 0.7], [0.5, 1, 1, 0.5])).toEqual({ + offsets: [0, 0.1, 0.3, 0.3, 0.7, 1], + values: [0.5, 0.5, 1, 1, 0.5, 0.5], + }); }); - it("leaves a valid range untouched", () => { - expect(toKeyframeOffsets([0, 0.1, 0.3, 0.3, 0.7, 1])).toEqual([0, 0.1, 0.3, 0.3, 0.7, 1]); + // Regression: clamping the offsets alone (toKeyframeOffsets) piled the + // entry keyframes up at 0, so the first row started half-size and faded + // instead of fully shown. + it("samples the real value at 0 when the entry happens before the scroll starts", () => { + const { offsets, values } = toUnitKeyframes([-0.667, -0.167, -0.167, 1.333], [0.5, 1, 1, 0.5]); + expect(offsets[0]).toBe(0); + expect(offsets[offsets.length - 1]).toBe(1); + expect(values[0]).toBeCloseTo(1 - (0.167 / 1.5) * 0.5, 3); + expect(values[values.length - 1]).toBeCloseTo(1 - (1.167 / 1.5) * 0.5, 3); + // Every offset is a valid, sorted WAAPI keyframe offset. + expect(offsets.every((o, i) => o >= 0 && o <= 1 && (i === 0 || o >= offsets[i - 1]))).toBe( + true, + ); + }); + + it("holds the edge values when the whole animation is out of range", () => { + expect(toUnitKeyframes([-2, -1], [3, 7])).toEqual({ offsets: [0, 1], values: [7, 7] }); + expect(toUnitKeyframes([2, 3], [3, 7])).toEqual({ offsets: [0, 1], values: [3, 3] }); }); }); diff --git a/src/components/effects/keyframeOffsets.ts b/src/components/effects/keyframeOffsets.ts index e326d4d..07bb088 100644 --- a/src/components/effects/keyframeOffsets.ts +++ b/src/components/effects/keyframeOffsets.ts @@ -2,15 +2,38 @@ // SPDX-License-Identifier: BUSL-1.1 /** - * Clamp a scroll range into [0, 1] and make it non-decreasing. On WebKit with - * ScrollTimeline support, motion hands useTransform's input range to WAAPI as - * keyframe offsets, which throw a TypeError when out of range or unsorted — - * and that error unmounts the whole app. + * Re-express a piecewise-linear animation over `points` (sorted, possibly + * outside [0, 1]; the value holds before the first point and after the last) + * as keyframes whose offsets all sit in [0, 1], sampling the real value at the + * two edges. Unlike clamping the offsets alone, the curve over [0, 1] is + * unchanged — an entry that finishes before the scroll starts still shows its + * end state at 0. */ -export function toKeyframeOffsets(range: number[]): number[] { - let floor = 0; - return range.map((v) => { - floor = Math.max(floor, Math.min(1, v)); - return floor; +export function toUnitKeyframes( + points: number[], + values: number[], +): { offsets: number[]; values: number[] } { + const at = (t: number): number => { + if (t <= points[0]) return values[0]; + for (let i = 1; i < points.length; i++) { + if (t <= points[i]) { + const span = points[i] - points[i - 1]; + if (span <= 0) return values[i]; + const k = (t - points[i - 1]) / span; + return values[i - 1] + (values[i] - values[i - 1]) * k; + } + } + return values[values.length - 1]; + }; + const offsets = [0]; + const out = [at(0)]; + points.forEach((p, i) => { + if (p > 0 && p < 1) { + offsets.push(p); + out.push(values[i]); + } }); + offsets.push(1); + out.push(at(1)); + return { offsets, values: out }; } From 86a2069a7987df65506b6e8fb5303721149e666c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 13:37:20 +0200 Subject: [PATCH 6/8] docs(billy): teach the maestro 2.9 dark-mode commands --- src/lib/chat/billy-prompt.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/chat/billy-prompt.md b/src/lib/chat/billy-prompt.md index 6be7da1..07d0376 100644 --- a/src/lib/chat/billy-prompt.md +++ b/src/lib/chat/billy-prompt.md @@ -33,6 +33,7 @@ appId: com.example.app - `waitForAnimationToEnd`, `extendedWaitUntil` - `runFlow: `, `runScript: ` - `takeScreenshot`, `stopApp`, `clearState`, `pressKey: BACK | HOME | ENTER` +- `setDarkMode: enabled | disabled`, `toggleDarkMode`, `assertDarkMode`, `assertLightMode` — device theme (iOS/Android) ### Selector strategies (in order of preference) From 4774cfd359c644a171e140223582accc5f6790d0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 13:42:58 +0200 Subject: [PATCH 7/8] chore(release): bump version to 0.9.5 --- CHANGELOG_EN.md | 15 +++++++++++++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 357d1f8..d4d30e9 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -1,3 +1,18 @@ +# What's New in v0.9.5 + +## Maestro 2.10.0 +- **Maestro Deck now runs on Maestro CLI 2.10.0** (previously 2.5.1). The app's automatic setup downloads it for you, and replaces a Maestro it installed at an older version. +- **Same features, new engine** — Maestro 2.6 removed `maestro studio`, which the app relied on. The inspector, taps and live preview now go through `maestro mcp` on Android, iOS simulators and the web. +- **Dark mode commands** from Maestro 2.9 (`setDarkMode`, `toggleDarkMode`, `assertDarkMode`, `assertLightMode`) are suggested in the editor, tracked in the run console and known to Billy. +- **Known limitation:** physical iPhones still need the patched Maestro 2.5.1 and don't work with 2.10.0 yet. + +## Fixes +- Screenshots taken with `takeScreenshot` land next to the flow again, so the screenshot bank finds them instead of reporting them as missing. +- The screenshot bank gallery no longer leaves cards small, faded and far apart when it fits without scrolling, no longer crashes into a black screen on WebKit, and web baselines show a globe icon. +- The "Recovering driver…" notice no longer stays on screen after the driver has recovered. + +--- + # What's New in v0.9.0 ## Maestro Deck Cloud diff --git a/package.json b/package.json index 44f5fbb..3a19a17 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "maestro-deck", "private": true, - "version": "0.9.0", + "version": "0.9.5", "type": "module", "description": "Source-available visual IDE for Maestro mobile tests", "license": "BUSL-1.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5fc8ef1..74e5c14 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maestro-deck" -version = "0.9.0" +version = "0.9.5" description = "Source-available visual IDE for Maestro mobile tests" authors = ["Ethan Morisset "] license = "BUSL-1.1" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e83890b..10d98a5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Maestro Deck", - "version": "0.9.0", + "version": "0.9.5", "identifier": "dev.blueshork.maestrodeck", "build": { "beforeDevCommand": "pnpm dev", From b7ab6f54ec0fde97e5da5f182544a0a83b91c850 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 24 Sep 2026 13:51:02 +0200 Subject: [PATCH 8/8] fix(web): gate the Command import to the platforms that hide Chrome Only the macOS/Windows window hiders use it since the keeper stopped spawning maestro itself, so Linux CI failed on an unused import. --- src-tauri/src/web_session/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/web_session/mod.rs b/src-tauri/src/web_session/mod.rs index acdd09f..66e3a98 100644 --- a/src-tauri/src/web_session/mod.rs +++ b/src-tauri/src/web_session/mod.rs @@ -16,6 +16,7 @@ use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter}; +#[cfg(any(target_os = "macos", windows))] use tokio::process::Command; use tokio::sync::oneshot; use tokio::time::sleep;