//! 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<String> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> = 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 <bundle-or-package-id> {target} # or: open <app> <deep-link-url>"),
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 <app> <path-to-.app-or-.apk> {target}"),
format!("Prefer snapshot refs over coordinates. Run {executable} help for workflow guides and {executable} <command> --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'");
}
}