//! What agents drive devices with: the `agent-device` CLI, through a //! launcher the app writes. `device_open` hands the agent the launcher's //! path and the flags that pin every command to one device, along with how //! to use it, so threads that never open a device pay nothing for it. use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; use super::host::AgentEndpoint; use super::{DeviceSummary, Platform}; /// What the agent's instructions say about devices, when it has them. pub const INSTRUCTIONS: &str = "\ ## Vectrum Code devices Vectrum Code also offers `device_*` tools for iOS Simulators and Android Emulators on this machine. For mobile verification, call `device_list`, then `device_open` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the `agent-device` CLI, using the exact launcher path returned by `device_open`. Keep the host config and session flags returned by `device_open` on every command so concurrent devices stay independent: prefer `agent-device snapshot -i` refs over coordinates, and use `device_screenshot` when you need to see the screen. Prefer these tools and `agent-device` for opening and driving devices. Platform tools such as `xcrun simctl` and `adb` remain available for anything they do not cover, such as builds, logs, or port forwarding. If `device_list` reports a platform as unavailable, say so."; /// A short stable key: FNV-1a over the text, twice with different seeds. fn key(text: &str) -> String { let hash = |seed: u64| { text.bytes().fold(seed, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)) }; format!("{:016x}{:08x}", hash(0xcbf29ce484222325), hash(0x84222325cbf29ce4) as u32) } /// The flags that pin every agent-device command to one device. pub fn target_args(device: &DeviceSummary) -> Vec { match device.platform { Platform::Ios => vec!["--platform".into(), "ios".into(), "--udid".into(), device.id.clone()], Platform::Android => vec!["--platform".into(), "android".into(), "--serial".into(), device.id.clone()], } } /// One config file per host, so the daemon's endpoint can change without /// retargeting commands. fn config_path(host: &str) -> Option { Some(super::toolchain::base_dir()?.join("device").join("hosts").join(format!("{}.json", key(host)))) } /// Writes the host's config: where the daemon is and its token. pub fn write_config(host: &str, endpoint: &AgentEndpoint) -> Result { let path = config_path(host).context("no data folder")?; std::fs::create_dir_all(path.parent().context("no folder")?)?; let content = serde_json::json!({"daemonBaseUrl": endpoint.base_url, "daemonAuthToken": endpoint.token}).to_string(); if std::fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) { return Ok(path); } let temporary = path.with_extension(format!("tmp-{}", std::process::id())); { use std::os::unix::fs::OpenOptionsExt as _; let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&temporary)?; std::io::Write::write_all(&mut file, content.as_bytes())?; } std::fs::rename(&temporary, &path)?; Ok(path) } /// The agent-device session of one thread on one device. pub fn session(thread: &str, host: &str, device: &str) -> String { format!("vectrum-{}", key(&serde_json::json!([thread, host, device]).to_string())) } /// The folder holding the `agent-device` launcher. pub fn shim_dir() -> Option { Some(super::toolchain::base_dir()?.join("device").join("bin")) } /// Puts the launcher's folder first on an agent's PATH, so `agent-device` /// is the pinned version whatever else is installed. pub fn prepare_command(command: &mut std::process::Command) { let Some(dir) = shim_dir() else { return }; let current = command .get_envs() .find(|(key, _)| *key == "PATH") .and_then(|(_, value)| value.map(|value| value.to_os_string())) .or_else(|| std::env::var_os("PATH")) .unwrap_or_default(); let dirs = std::iter::once(dir).chain(std::env::split_paths(¤t)); if let Ok(path) = std::env::join_paths(dirs) { command.env("PATH", path); } } /// Writes the launcher that runs the pinned agent-device with the app's /// Node, refusing commands without the flags `device_open` hands out. /// Returns the launcher's path. pub fn ensure_shim(entry: &Path, node: &Path) -> Result { let dir = shim_dir().context("no data folder")?; std::fs::create_dir_all(&dir)?; let json = |path: &Path| serde_json::Value::String(path.display().to_string()).to_string(); let launcher = dir.join("agent-device-launcher.mjs"); let source = format!( r#"import {{ spawn }} from "node:child_process"; const args = process.argv.slice(2); const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]); const hasValue = flag => {{ const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); }}; if (!informational && !(hasValue("--config") && hasValue("--session"))) {{ console.error("Call device_open first and include its --config and --session flags."); process.exit(1); }} const env = {{ ...process.env }}; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; const child = spawn({node}, [{entry}, ...args], {{ stdio: "inherit", env }}); child.on("error", error => {{ console.error(error.message); process.exitCode = 1; }}); child.on("exit", code => {{ process.exitCode = code ?? 1; }}); "#, node = json(node), entry = json(entry), ); write_if_changed(&launcher, &source)?; let quote = |value: &Path| format!("'{}'", value.display().to_string().replace('\'', "'\"'\"'")); let shim = dir.join("agent-device"); write_if_changed(&shim, &format!("#!/bin/sh\nexec {} {} \"$@\"\n", quote(node), quote(&launcher)))?; use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755))?; Ok(shim) } fn write_if_changed(path: &Path, content: &str) -> Result<()> { if std::fs::read_to_string(path).ok().as_deref() != Some(content) { std::fs::write(path, content)?; } Ok(()) } fn shell_quote(arg: &str) -> String { if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "_./:-".contains(c)) { arg.to_string() } else { format!("'{}'", arg.replace('\'', "'\"'\"'")) } } /// How to drive the device, returned from `device_open`. pub fn quick_start(device: &DeviceSummary, target_args: &[String], command: &str) -> String { let executable = shell_quote(command); let target: Vec = target_args.iter().map(|arg| shell_quote(arg)).collect(); let target = target.join(" "); let platform_note = match device.platform { Platform::Ios => "First use builds an XCTest runner and can take a couple of minutes; later commands are fast.", Platform::Android => "The Android snapshot helper installs itself on first use.", }; [ format!("The user is watching {} ({}) in the Device panel.", device.name, device.version), format!("Drive it with {executable}. Use this exact executable path; login shells may reset PATH. Always pass {target}."), "Typical loop:".into(), format!(" {executable} open {target} # or: open "), format!(" {executable} snapshot -i {target} # accessibility tree with @eN refs"), format!(" {executable} click @e3 {target}"), format!(" {executable} fill @e5 \"text\" {target}"), format!(" {executable} screenshot /tmp/shot.png {target} # or call device_screenshot"), format!(" {executable} install {target}"), format!("Prefer snapshot refs over coordinates. Run {executable} help for workflow guides and {executable} --help for flags."), "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.".into(), "Keep the returned --config and --session flags on every command. Other devices can be used concurrently; opening one does not switch these commands.".into(), platform_note.into(), ] .join("\n") } #[cfg(test)] mod tests { use super::{key, session, shell_quote}; #[::core::prelude::v1::test] fn keys_are_stable_and_distinct() { assert_eq!(key("local"), key("local")); assert_ne!(key("local"), key("other")); assert_eq!(key("local").len(), 24); assert_ne!(session("a", "local", "emulator-5554"), session("b", "local", "emulator-5554")); assert!(session("a", "local", "x").starts_with("vectrum-")); } #[::core::prelude::v1::test] fn quotes_only_what_needs_it() { assert_eq!(shell_quote("--serial"), "--serial"); assert_eq!(shell_quote("/a b/agent-device"), "'/a b/agent-device'"); assert_eq!(shell_quote("it's"), "'it'\"'\"'s'"); } }