Vectrum
Sign In
183 lines · 9.1 KB · Rust
//! 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(&current));
    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'");
    }
}