From 3f7308df4ab96a452622faa61dd962514a978b0e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 16:50:50 +0800 Subject: [PATCH 1/4] feat(sim): add iOS Simulator sidecar over framed stdio --- Cargo.lock | 8 + Cargo.toml | 2 +- crates/linkcode-sim/Cargo.toml | 18 ++ crates/linkcode-sim/src/main.rs | 121 ++++++++ crates/linkcode-sim/src/proto.rs | 143 ++++++++++ crates/linkcode-sim/src/rpc.rs | 156 +++++++++++ crates/linkcode-sim/src/simctl.rs | 338 +++++++++++++++++++++++ crates/linkcode-sim/tests/device_loop.rs | 268 ++++++++++++++++++ crates/linkcode-sim/tests/smoke.rs | 148 ++++++++++ 9 files changed, 1201 insertions(+), 1 deletion(-) create mode 100644 crates/linkcode-sim/Cargo.toml create mode 100644 crates/linkcode-sim/src/main.rs create mode 100644 crates/linkcode-sim/src/proto.rs create mode 100644 crates/linkcode-sim/src/rpc.rs create mode 100644 crates/linkcode-sim/src/simctl.rs create mode 100644 crates/linkcode-sim/tests/device_loop.rs create mode 100644 crates/linkcode-sim/tests/smoke.rs diff --git a/Cargo.lock b/Cargo.lock index a616edd68..a5df95936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "linkcode-sim" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "log" version = "0.4.33" diff --git a/Cargo.toml b/Cargo.toml index 28a4ea61e..d07b0de48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/linkcode-pty"] +members = ["crates/linkcode-pty", "crates/linkcode-sim"] resolver = "2" [workspace.package] diff --git a/crates/linkcode-sim/Cargo.toml b/crates/linkcode-sim/Cargo.toml new file mode 100644 index 000000000..a5a7f6d83 --- /dev/null +++ b/crates/linkcode-sim/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "linkcode-sim" +version.workspace = true +edition.workspace = true +publish.workspace = true +authors.workspace = true +description.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true + +[[bin]] +name = "linkcode-sim" +path = "src/main.rs" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" diff --git a/crates/linkcode-sim/src/main.rs b/crates/linkcode-sim/src/main.rs new file mode 100644 index 000000000..f8509998b --- /dev/null +++ b/crates/linkcode-sim/src/main.rs @@ -0,0 +1,121 @@ +//! `linkcode-sim`: the Link Code iOS Simulator host (P0 — public `simctl` only). +//! +//! One long-lived process the daemon spawns; it serves simulator RPCs over a framed stdio +//! protocol (see [`proto`]). Requests arrive on stdin; results go to stdout. Each request runs +//! on its own thread so a slow boot never blocks a screenshot. + +mod proto; +mod rpc; +mod simctl; + +use std::io::{self, BufReader, Write}; +use std::sync::mpsc::{Sender, channel}; +use std::thread; + +use crate::proto::{REQUEST, RESULT, SCREENSHOT, encode_screenshot, read_frame, write_frame}; +use crate::rpc::{ErrorCode, Op, OpError, Request, RequestIdOnly, error_body, success_body}; + +/// A frame bound for the daemon, or the sentinel that tells the writer thread to stop. +enum OutMsg { + Frame { type_byte: u8, body: Vec }, + Stop, +} + +fn main() { + let (tx, rx) = channel::(); + + // Sole stdout owner: serializes frames from every request thread. + let writer = thread::spawn(move || { + let mut stdout = io::stdout().lock(); + while let Ok(OutMsg::Frame { type_byte, body }) = rx.recv() { + if write_frame(&mut stdout, type_byte, &body).is_err() { + // The daemon is gone; frames have nowhere to go. + break; + } + } + let _ = stdout.flush(); + }); + + let mut stdin = BufReader::new(io::stdin()); + loop { + let (type_byte, body) = match read_frame(&mut stdin) { + Ok(Some(frame)) => frame, + // Clean end-of-stream: the daemon closed the pipe. + Ok(None) => break, + // A truncated/corrupt frame is distinct from a graceful close — surface it before exiting. + Err(err) => { + eprintln!("sim protocol read error: {err}"); + break; + } + }; + if type_byte != REQUEST { + continue; + } + match serde_json::from_slice::(&body) { + Ok(request) => { + let tx = tx.clone(); + thread::spawn(move || serve(request, &tx)); + } + Err(err) => { + eprintln!("invalid REQUEST frame: {err}"); + // Fail only this request (recover its id if we can); never kill the whole host. + match serde_json::from_slice::(&body) { + Ok(id) => send_error( + &tx, + &id.request_id, + &OpError::new(ErrorCode::InvalidRequest, err.to_string()), + ), + // No requestId to reply against — the daemon's pending request is reclaimed + // by its own timeout (see PROTOCOL.md). + Err(_) => eprintln!( + "REQUEST frame has no recoverable requestId; the daemon's pending request for it will time out" + ), + } + } + } + } + + // Stop the writer without waiting for in-flight simctl calls: a mid-boot device keeps booting + // server-side in CoreSimulatorService whether or not our child lives to see it. + let _ = tx.send(OutMsg::Stop); + let _ = writer.join(); +} + +/// Run one request to completion and send its RESULT (and, for screenshots, the image frame). +fn serve(request: Request, tx: &Sender) { + let request_id = request.request_id; + let outcome = match request.op { + Op::Probe => simctl::probe(), + Op::List => simctl::list(), + Op::Boot { udid } => simctl::boot(&udid), + Op::Shutdown { udid } => simctl::shutdown(&udid), + Op::Install { udid, app_path } => simctl::install(&udid, &app_path), + Op::Launch { udid, bundle_id } => simctl::launch(&udid, &bundle_id), + Op::Terminate { udid, bundle_id } => simctl::terminate(&udid, &bundle_id), + Op::OpenUrl { udid, url } => simctl::open_url(&udid, &url), + Op::Screenshot { udid, format } => { + match simctl::screenshot(&udid, format).and_then(|image| { + encode_screenshot(&request_id, &image) + .map_err(|e| OpError::new(ErrorCode::Io, e.to_string())) + }) { + Ok(body) => { + send(tx, SCREENSHOT, body); + return; + } + Err(e) => Err(e), + } + } + }; + match outcome { + Ok(result) => send(tx, RESULT, success_body(&request_id, result)), + Err(error) => send_error(tx, &request_id, &error), + } +} + +fn send(tx: &Sender, type_byte: u8, body: Vec) { + let _ = tx.send(OutMsg::Frame { type_byte, body }); +} + +fn send_error(tx: &Sender, request_id: &str, error: &OpError) { + send(tx, RESULT, error_body(request_id, error)); +} diff --git a/crates/linkcode-sim/src/proto.rs b/crates/linkcode-sim/src/proto.rs new file mode 100644 index 000000000..edbdac235 --- /dev/null +++ b/crates/linkcode-sim/src/proto.rs @@ -0,0 +1,143 @@ +//! Framed stdio wire protocol between the Link Code daemon and this sidecar. +//! +//! Same transport as `linkcode-pty`: each frame is `[u32 LE total][u8 type][body]`, where +//! `total = 1 + body.len()`. `REQUEST` and `RESULT` carry JSON bodies; `SCREENSHOT` carries raw +//! image bytes prefixed with the request id, so captures never pay base64 on this private pipe. + +use std::io::{self, Read, Write}; + +/// Largest accepted frame payload including the one-byte frame type. +pub const MAX_FRAME_LEN: usize = 16 * 1024 * 1024; + +/// Maximum request id length in bytes; `SCREENSHOT` frames encode it as `u16`. +pub const MAX_REQUEST_ID_LEN: usize = u16::MAX as usize; + +// Daemon → sidecar. +pub const REQUEST: u8 = 0x01; + +// Sidecar → daemon. +pub const RESULT: u8 = 0x81; +pub const SCREENSHOT: u8 = 0x82; + +/// Read one frame. Returns `Ok(None)` on a clean end-of-stream (the daemon closed the pipe). +pub fn read_frame(reader: &mut impl Read) -> io::Result)>> { + let mut len = [0u8; 4]; + match reader.read_exact(&mut len) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let total = u32::from_le_bytes(len) as usize; + if total == 0 || total > MAX_FRAME_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid frame length", + )); + } + let mut type_byte = [0u8; 1]; + reader.read_exact(&mut type_byte)?; + let mut body = vec![0u8; total - 1]; + reader.read_exact(&mut body)?; + Ok(Some((type_byte[0], body))) +} + +/// Write one frame. Only the single writer thread calls this on stdout, so its several +/// `write_all`s stay contiguous. +pub fn write_frame(writer: &mut impl Write, type_byte: u8, body: &[u8]) -> io::Result<()> { + let total = 1 + body.len(); + if total > MAX_FRAME_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "frame too large", + )); + } + let total = total as u32; + writer.write_all(&total.to_le_bytes())?; + writer.write_all(&[type_byte])?; + writer.write_all(body)?; + writer.flush() +} + +/// Encode a `SCREENSHOT` body: `[u16 LE id_len][request_id][image bytes]`. +pub fn encode_screenshot(request_id: &str, image: &[u8]) -> io::Result> { + let id = request_id.as_bytes(); + if id.is_empty() || id.len() > MAX_REQUEST_ID_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid request id length", + )); + } + let mut out = Vec::with_capacity(2 + id.len() + image.len()); + out.extend_from_slice(&(id.len() as u16).to_le_bytes()); + out.extend_from_slice(id); + out.extend_from_slice(image); + Ok(out) +} + +/// Decode a `SCREENSHOT` body into `(request_id, image bytes)`. The production decoder lives in +/// the TypeScript client (`@linkcode/sim`); this one keeps the encoder honest in tests. +#[cfg(test)] +pub fn decode_screenshot(body: &[u8]) -> io::Result<(String, &[u8])> { + let id_len = body + .get(0..2) + .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "short screenshot frame"))?; + let id = body + .get(2..2 + id_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "truncated request id"))?; + Ok(( + String::from_utf8_lossy(id).into_owned(), + &body[2 + id_len..], + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn frame_roundtrips_and_ends_cleanly() { + let mut buf = Vec::new(); + write_frame(&mut buf, RESULT, b"{}").unwrap(); + let mut cursor = Cursor::new(buf); + let (type_byte, body) = read_frame(&mut cursor).unwrap().unwrap(); + assert_eq!(type_byte, RESULT); + assert_eq!(body, b"{}"); + assert!(read_frame(&mut cursor).unwrap().is_none()); + } + + #[test] + fn screenshot_body_preserves_id_and_raw_bytes() { + let body = encode_screenshot("r-1", b"\xFF\xD8\xFF\xE0jpeg").unwrap(); + let (id, image) = decode_screenshot(&body).unwrap(); + assert_eq!(id, "r-1"); + assert_eq!(image, b"\xFF\xD8\xFF\xE0jpeg"); + } + + #[test] + fn decode_rejects_truncated_bodies() { + assert!(decode_screenshot(&[1]).is_err()); + assert!(decode_screenshot(&[9, 0, b'x']).is_err()); + } + + #[test] + fn oversized_frames_are_rejected() { + let mut encoded = Vec::new(); + encoded.extend_from_slice(&((MAX_FRAME_LEN as u32) + 1).to_le_bytes()); + encoded.push(RESULT); + + let err = read_frame(&mut Cursor::new(encoded)).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + + let err = write_frame(&mut Vec::new(), RESULT, &vec![0; MAX_FRAME_LEN]).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn oversized_request_ids_are_rejected() { + let request_id = "x".repeat(MAX_REQUEST_ID_LEN + 1); + let err = encode_screenshot(&request_id, b"img").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/crates/linkcode-sim/src/rpc.rs b/crates/linkcode-sim/src/rpc.rs new file mode 100644 index 000000000..84cbf343f --- /dev/null +++ b/crates/linkcode-sim/src/rpc.rs @@ -0,0 +1,156 @@ +//! JSON request/response shapes carried by `REQUEST` and `RESULT` frames. +//! +//! Keys are camelCase to match the TypeScript client (`@linkcode/sim`). The op set is the P0 +//! simctl surface; screenshot success bytes travel on the binary `SCREENSHOT` frame instead of +//! a JSON result. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// One daemon request: a unique id (daemon-generated) plus the operation to run. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub request_id: String, + pub op: Op, +} + +/// Minimal projection used to recover a request id from a `REQUEST` frame that failed full +/// parsing, so the failure can be reported for just that request instead of dropped silently. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestIdOnly { + pub request_id: String, +} + +/// The P0 operation set. Everything shells out to `xcrun simctl`; no private API. +#[derive(Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Op { + /// Check that Xcode's simulator tooling is present and report where it lives. + Probe, + /// List available simulator devices with their runtimes. + List, + /// Boot a device, waiting until it finishes booting. + Boot { udid: String }, + /// Shut a device down. + Shutdown { udid: String }, + /// Install an `.app` bundle on a device. + Install { udid: String, app_path: String }, + /// Launch an installed app by bundle id; resolves to the spawned pid. + Launch { udid: String, bundle_id: String }, + /// Terminate a running app by bundle id. + Terminate { udid: String, bundle_id: String }, + /// Open a URL on the device (deep links, Safari). + OpenUrl { udid: String, url: String }, + /// Capture the device screen; bytes come back on a `SCREENSHOT` frame. + Screenshot { + udid: String, + #[serde(default)] + format: ImageFormat, + }, +} + +/// Screenshot encodings supported by `simctl io screenshot --type`. +#[derive(Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ImageFormat { + #[default] + Jpeg, + Png, +} + +impl ImageFormat { + /// The `--type` value and file extension simctl expects. + pub fn simctl_name(self) -> &'static str { + match self { + Self::Jpeg => "jpeg", + Self::Png => "png", + } + } +} + +/// Why an operation failed, as a stable machine-readable code for the TypeScript client. +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ErrorCode { + /// Xcode (or its simulator tooling) is not installed or not selected. + XcodeMissing, + /// simctl ran and reported failure. + SimctlFailed, + /// simctl did not finish within the operation's deadline. + Timeout, + /// The request body could not be parsed. + InvalidRequest, + /// Spawning simctl or handling its output failed at the OS level. + Io, +} + +/// A failed operation: a stable code plus a human-readable message (usually simctl's stderr). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpError { + pub code: ErrorCode, + pub message: String, +} + +impl OpError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +/// Serialize a success `RESULT` body: `{ requestId, ok: true, result }`. +pub fn success_body(request_id: &str, result: Value) -> Vec { + body(json!({ "requestId": request_id, "ok": true, "result": result })) +} + +/// Serialize a failure `RESULT` body: `{ requestId, ok: false, error: { code, message } }`. +pub fn error_body(request_id: &str, error: &OpError) -> Vec { + body(json!({ "requestId": request_id, "ok": false, "error": error })) +} + +fn body(value: Value) -> Vec { + serde_json::to_vec(&value).expect("response body is valid JSON by construction") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_tagged_op() { + let req: Request = serde_json::from_slice( + br#"{"requestId":"r-1","op":{"type":"launch","udid":"U","bundleId":"com.example"}}"#, + ) + .unwrap(); + assert_eq!(req.request_id, "r-1"); + assert!( + matches!(req.op, Op::Launch { udid, bundle_id } if udid == "U" && bundle_id == "com.example") + ); + } + + #[test] + fn screenshot_format_defaults_to_jpeg() { + let req: Request = + serde_json::from_slice(br#"{"requestId":"r-2","op":{"type":"screenshot","udid":"U"}}"#) + .unwrap(); + assert!(matches!(req.op, Op::Screenshot { format, .. } if format == ImageFormat::Jpeg)); + } + + #[test] + fn error_body_carries_camel_case_code() { + let body = error_body("r-3", &OpError::new(ErrorCode::XcodeMissing, "no xcode")); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "xcodeMissing"); + assert_eq!(value["requestId"], "r-3"); + } +} diff --git a/crates/linkcode-sim/src/simctl.rs b/crates/linkcode-sim/src/simctl.rs new file mode 100644 index 000000000..485dfce43 --- /dev/null +++ b/crates/linkcode-sim/src/simctl.rs @@ -0,0 +1,338 @@ +//! The `xcrun simctl` driver: every P0 operation shells out to Apple's public simulator CLI. +//! +//! Each call runs under a deadline; a child that outlives it is killed and reported as +//! [`ErrorCode::Timeout`]. A missing `xcrun` (no Xcode / no Command Line Tools) surfaces as +//! [`ErrorCode::XcodeMissing`] so the daemon can gate the capability instead of retrying. + +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::rpc::{ErrorCode, ImageFormat, OpError}; + +/// Booting waits for the device to finish (`bootstatus -b`), which dominates this deadline. +const BOOT_TIMEOUT: Duration = Duration::from_secs(180); +/// Large `.app` bundles take a while to copy into the device's container. +const INSTALL_TIMEOUT: Duration = Duration::from_secs(120); +const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(30); +/// Everything else is quick command dispatch against CoreSimulatorService. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Apple's OS-provided shims, by absolute path: PATH can carry non-Apple `xcrun` stand-ins +/// (e.g. nix xcbuild's) that fail SDK/utility resolution, and the daemon does not control the +/// environment it was launched from. `/usr/bin/xcrun` ships with macOS itself, not Xcode. +const XCRUN: &str = "/usr/bin/xcrun"; +const XCODE_SELECT: &str = "/usr/bin/xcode-select"; + +/// One available simulator device, flattened from `simctl list -j`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub udid: String, + pub name: String, + /// CoreSimulator state string: `Shutdown`, `Booted`, `Booting`, … + pub state: String, + /// Runtime identifier, e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-5`. + pub runtime: String, + /// Human-readable runtime name, e.g. `iOS 26.5`, when the runtime section lists it. + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_name: Option, + pub device_type: Option, +} + +/// Check that simulator tooling is usable and report where it lives. +pub fn probe() -> Result { + let mut find_simctl = Command::new(XCRUN); + find_simctl.args(["--find", "simctl"]); + let simctl_path = run_ok(find_simctl, DEFAULT_TIMEOUT).map_err(|e| match e.code { + // xcrun exists but cannot find simctl: Xcode's iOS platform is missing. + ErrorCode::SimctlFailed => OpError::new(ErrorCode::XcodeMissing, e.message), + _ => e, + })?; + let mut developer_dir_cmd = Command::new(XCODE_SELECT); + developer_dir_cmd.arg("-p"); + let developer_dir = run_ok(developer_dir_cmd, DEFAULT_TIMEOUT)?; + Ok(json!({ + "simctlPath": simctl_path.trim(), + "developerDir": developer_dir.trim(), + })) +} + +/// List available devices with their runtime names. +pub fn list() -> Result { + let raw = run_ok( + simctl(["list", "-j", "devices", "available"]), + DEFAULT_TIMEOUT, + )?; + let runtimes_raw = run_ok(simctl(["list", "-j", "runtimes"]), DEFAULT_TIMEOUT)?; + let devices = parse_device_list(&raw, &runtimes_raw) + .map_err(|message| OpError::new(ErrorCode::SimctlFailed, message))?; + Ok(json!({ "devices": devices })) +} + +/// Boot a device and wait until it reports fully booted. Already-booted devices succeed. +pub fn boot(udid: &str) -> Result { + match run_ok(simctl(["boot", udid]), DEFAULT_TIMEOUT) { + Ok(_) => {} + // `simctl boot` on a booted device exits non-zero; that state is our goal, not an error. + Err(e) if e.message.contains("current state: Booted") => return Ok(json!({})), + Err(e) => return Err(e), + } + run_ok(simctl(["bootstatus", udid, "-b"]), BOOT_TIMEOUT)?; + Ok(json!({})) +} + +/// Shut a device down. Already-shutdown devices succeed. +pub fn shutdown(udid: &str) -> Result { + match run_ok(simctl(["shutdown", udid]), DEFAULT_TIMEOUT) { + Ok(_) => Ok(json!({})), + Err(e) if e.message.contains("current state: Shutdown") => Ok(json!({})), + Err(e) => Err(e), + } +} + +/// Install an `.app` bundle. +pub fn install(udid: &str, app_path: &str) -> Result { + run_ok(simctl(["install", udid, app_path]), INSTALL_TIMEOUT)?; + Ok(json!({})) +} + +/// Launch an app by bundle id; returns the spawned pid. +pub fn launch(udid: &str, bundle_id: &str) -> Result { + let stdout = run_ok(simctl(["launch", udid, bundle_id]), DEFAULT_TIMEOUT)?; + Ok(json!({ "pid": parse_launch_pid(&stdout) })) +} + +/// Terminate a running app by bundle id. +pub fn terminate(udid: &str, bundle_id: &str) -> Result { + run_ok(simctl(["terminate", udid, bundle_id]), DEFAULT_TIMEOUT)?; + Ok(json!({})) +} + +/// Open a URL on the device. +pub fn open_url(udid: &str, url: &str) -> Result { + run_ok(simctl(["openurl", udid, url]), DEFAULT_TIMEOUT)?; + Ok(json!({})) +} + +/// Capture the device screen and return the encoded image bytes. +/// +/// simctl writes to a file, not a pipe, so this stages through a unique temp path and always +/// removes it — including on the error paths. +pub fn screenshot(udid: &str, format: ImageFormat) -> Result, OpError> { + static SEQ: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "linkcode-sim-{}-{}.{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed), + format.simctl_name(), + )); + let type_arg = format!("--type={}", format.simctl_name()); + let mut cmd = simctl(["io", udid, "screenshot", &type_arg]); + cmd.arg(&path); + let run = run_ok(cmd, SCREENSHOT_TIMEOUT); + let read = run.and_then(|_| { + std::fs::read(&path) + .map_err(|e| OpError::new(ErrorCode::Io, format!("read screenshot {path:?}: {e}"))) + }); + let _ = std::fs::remove_file(&path); + read +} + +fn simctl<'a>(args: impl IntoIterator) -> Command { + let mut cmd = Command::new(XCRUN); + cmd.arg("simctl").args(args); + cmd +} + +/// Run a command to completion under `timeout`; return its stdout on exit code 0, or a +/// classified [`OpError`] otherwise. stderr rides along in the error message. +fn run_ok(mut cmd: Command, timeout: Duration) -> Result { + let program = cmd.get_program().to_string_lossy().into_owned(); + let mut child = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + OpError::new( + ErrorCode::XcodeMissing, + format!("{program} not found; install Xcode with the iOS platform"), + ) + } else { + OpError::new(ErrorCode::Io, format!("spawn {program}: {e}")) + } + })?; + + // Drain both pipes on their own threads so a chatty child can never fill a pipe buffer and + // deadlock against our exit polling. + let stdout = drain(child.stdout.take().expect("stdout piped above")); + let stderr = drain(child.stderr.take().expect("stderr piped above")); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(OpError::new( + ErrorCode::Timeout, + format!("{program} timed out after {}s", timeout.as_secs()), + )); + } + Ok(None) => thread::sleep(Duration::from_millis(20)), + Err(e) => { + return Err(OpError::new(ErrorCode::Io, format!("wait {program}: {e}"))); + } + } + }; + + let stdout = join_drained(stdout); + let stderr = join_drained(stderr); + if status.success() { + Ok(stdout) + } else { + let detail = if stderr.trim().is_empty() { + stdout + } else { + stderr + }; + Err(OpError::new( + ErrorCode::SimctlFailed, + format!("{program} exited with {status}: {}", detail.trim()), + )) + } +} + +fn drain(mut pipe: impl Read + Send + 'static) -> thread::JoinHandle { + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + }) +} + +fn join_drained(handle: thread::JoinHandle) -> String { + handle.join().unwrap_or_default() +} + +/// `simctl launch` prints `: `; splitting on the last `: ` is safe because +/// bundle ids never contain one. Absence of a parsable pid maps to `null`. +fn parse_launch_pid(stdout: &str) -> Option { + stdout.trim().rsplit(": ").next()?.trim().parse().ok() +} + +#[derive(Deserialize)] +struct RawDeviceList { + devices: std::collections::HashMap>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawDevice { + udid: String, + name: String, + state: String, + device_type_identifier: Option, +} + +#[derive(Deserialize)] +struct RawRuntimeList { + runtimes: Vec, +} + +#[derive(Deserialize)] +struct RawRuntime { + identifier: String, + name: String, +} + +fn parse_device_list(devices_json: &str, runtimes_json: &str) -> Result, String> { + let raw: RawDeviceList = + serde_json::from_str(devices_json).map_err(|e| format!("parse device list: {e}"))?; + let runtimes: RawRuntimeList = + serde_json::from_str(runtimes_json).map_err(|e| format!("parse runtime list: {e}"))?; + let runtime_names: std::collections::HashMap<&str, &str> = runtimes + .runtimes + .iter() + .map(|r| (r.identifier.as_str(), r.name.as_str())) + .collect(); + + let mut out = Vec::new(); + for (runtime, devices) in raw.devices { + for device in devices { + out.push(Device { + udid: device.udid, + name: device.name, + state: device.state, + runtime_name: runtime_names.get(runtime.as_str()).map(|s| s.to_string()), + runtime: runtime.clone(), + device_type: device.device_type_identifier, + }); + } + } + // simctl's map ordering is unstable; sort so equal worlds serialize equally. + out.sort_by(|a, b| (&a.runtime, &a.name).cmp(&(&b.runtime, &b.name))); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEVICES: &str = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-5": [ + { + "udid": "AAAA", + "name": "iPhone 17 Pro", + "state": "Shutdown", + "isAvailable": true, + "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro" + } + ] + } + }"#; + const RUNTIMES: &str = r#"{ + "runtimes": [ + { + "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + "name": "iOS 26.5", + "isAvailable": true + } + ] + }"#; + + #[test] + fn flattens_devices_and_resolves_runtime_names() { + let devices = parse_device_list(DEVICES, RUNTIMES).unwrap(); + assert_eq!(devices.len(), 1); + let device = &devices[0]; + assert_eq!(device.udid, "AAAA"); + assert_eq!(device.state, "Shutdown"); + assert_eq!(device.runtime_name.as_deref(), Some("iOS 26.5")); + assert_eq!( + device.device_type.as_deref(), + Some("com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro") + ); + } + + #[test] + fn unknown_runtimes_flatten_without_a_name() { + let devices = parse_device_list(DEVICES, r#"{"runtimes":[]}"#).unwrap(); + assert_eq!(devices[0].runtime_name, None); + } + + #[test] + fn parses_the_launch_pid() { + assert_eq!(parse_launch_pid("com.example.app: 4242\n"), Some(4242)); + assert_eq!(parse_launch_pid("garbage"), None); + } +} diff --git a/crates/linkcode-sim/tests/device_loop.rs b/crates/linkcode-sim/tests/device_loop.rs new file mode 100644 index 000000000..f6c31f270 --- /dev/null +++ b/crates/linkcode-sim/tests/device_loop.rs @@ -0,0 +1,268 @@ +//! The full CODE-392 acceptance loop against a real simulator: boot → install (a fixture app +//! compiled on the fly) → launch → screenshot → terminate → shutdown. +//! +//! Ignored by default: it needs a Mac with full Xcode (iOS SDK + at least one iPhone simulator) +//! and boots a device, which takes minutes. Run explicitly with +//! `cargo test -p linkcode-sim --test device_loop -- --ignored`. +#![cfg(target_os = "macos")] + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +const REQUEST: u8 = 0x01; +const RESULT: u8 = 0x81; +const SCREENSHOT: u8 = 0x82; + +const FIXTURE_BUNDLE_ID: &str = "ai.linkcode.sim.fixture"; + +/// A minimal real UIKit app: install/launch/terminate behave exactly like a user app's. +const FIXTURE_MAIN_M: &str = r#" +#import +@interface AppDelegate : UIResponder +@property (strong, nonatomic) UIWindow *window; +@end +@implementation AppDelegate +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.backgroundColor = UIColor.systemBlueColor; + [self.window makeKeyAndVisible]; + return YES; +} +@end +int main(int argc, char *argv[]) { + return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class)); +} +"#; + +const FIXTURE_INFO_PLIST: &str = r#" + + + + CFBundleExecutableFixture + CFBundleIdentifierai.linkcode.sim.fixture + CFBundleNameLinkCodeSimFixture + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + UIDeviceFamily1 + + +"#; + +fn write_frame(w: &mut impl Write, type_byte: u8, body: &[u8]) { + let total = (1 + body.len()) as u32; + w.write_all(&total.to_le_bytes()).unwrap(); + w.write_all(&[type_byte]).unwrap(); + w.write_all(body).unwrap(); + w.flush().unwrap(); +} + +fn read_frame(r: &mut impl Read) -> Option<(u8, Vec)> { + let mut len = [0u8; 4]; + r.read_exact(&mut len).ok()?; + let total = u32::from_le_bytes(len) as usize; + let mut type_byte = [0u8; 1]; + r.read_exact(&mut type_byte).ok()?; + let mut body = vec![0u8; total - 1]; + r.read_exact(&mut body).ok()?; + Some((type_byte[0], body)) +} + +fn spawn_sidecar() -> (Child, ChildStdin, ChildStdout) { + let mut child = Command::new(env!("CARGO_BIN_EXE_linkcode-sim")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn sidecar"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + (child, stdin, stdout) +} + +fn request(stdin: &mut impl Write, request_id: &str, op: Value) { + let body = serde_json::json!({ "requestId": request_id, "op": op }); + write_frame(stdin, REQUEST, &serde_json::to_vec(&body).unwrap()); +} + +/// Wait for this request's RESULT and assert it succeeded; returns its `result` value. +fn expect_ok(stdout: &mut impl Read, request_id: &str, secs: u64) -> Value { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + let Some((type_byte, body)) = read_frame(stdout) else { + break; + }; + if type_byte != RESULT { + continue; + } + let mut value: Value = serde_json::from_slice(&body).unwrap(); + if value["requestId"] != request_id { + continue; + } + assert_eq!(value["ok"], true, "{request_id} failed: {value}"); + return value["result"].take(); + } + panic!("no RESULT for {request_id} within {secs}s"); +} + +/// Wait for a SCREENSHOT frame for this request; returns the raw image bytes. +fn expect_screenshot(stdout: &mut impl Read, request_id: &str, secs: u64) -> Vec { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + let Some((type_byte, body)) = read_frame(stdout) else { + break; + }; + match type_byte { + SCREENSHOT => { + let id_len = u16::from_le_bytes([body[0], body[1]]) as usize; + let id = String::from_utf8_lossy(&body[2..2 + id_len]).into_owned(); + assert_eq!(id, request_id); + return body[2 + id_len..].to_vec(); + } + RESULT => { + let value: Value = serde_json::from_slice(&body).unwrap(); + if value["requestId"] == request_id { + panic!("screenshot failed: {value}"); + } + } + _ => {} + } + } + panic!("no SCREENSHOT for {request_id} within {secs}s"); +} + +/// Compile the fixture `.app` with the iphonesimulator SDK; panics if the SDK is unavailable. +fn build_fixture_app() -> PathBuf { + let root = std::env::temp_dir().join(format!("linkcode-sim-fixture-{}", std::process::id())); + let app = root.join("Fixture.app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(root.join("main.m"), FIXTURE_MAIN_M).unwrap(); + std::fs::write(app.join("Info.plist"), FIXTURE_INFO_PLIST).unwrap(); + + let arch = if cfg!(target_arch = "aarch64") { + "arm64-apple-ios15.0-simulator" + } else { + "x86_64-apple-ios15.0-simulator" + }; + // `/usr/bin/xcrun` for the same reason as the sidecar itself, plus a clean SDK environment: + // a foreign `DEVELOPER_DIR`/`SDKROOT` (e.g. devenv's nix apple-sdk) breaks `-sdk + // iphonesimulator` resolution even through the real xcrun. + let output = Command::new("/usr/bin/xcrun") + .env_remove("DEVELOPER_DIR") + .env_remove("SDKROOT") + .args([ + "-sdk", + "iphonesimulator", + "clang", + "-fobjc-arc", + "-target", + arch, + ]) + .arg(root.join("main.m")) + .args(["-framework", "UIKit", "-framework", "Foundation", "-o"]) + .arg(app.join("Fixture")) + .output() + .expect("xcrun clang"); + assert!( + output.status.success(), + "fixture build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + app +} + +/// Pick a target device from `list`: a booted one if present, else the first available iPhone. +/// Returns `(udid, already_booted)`. +fn pick_device(devices: &Value) -> (String, bool) { + let devices = devices["devices"].as_array().expect("devices array"); + assert!( + !devices.is_empty(), + "no available simulators; create one in Xcode first" + ); + if let Some(booted) = devices.iter().find(|d| d["state"] == "Booted") { + return (booted["udid"].as_str().unwrap().to_owned(), true); + } + let iphone = devices + .iter() + .find(|d| { + d["name"].as_str().unwrap_or("").contains("iPhone") + && d["runtime"].as_str().unwrap_or("").contains("iOS") + }) + .expect("no available iPhone simulator"); + (iphone["udid"].as_str().unwrap().to_owned(), false) +} + +#[test] +#[ignore = "boots a real simulator; needs full Xcode — run with --ignored"] +fn boot_install_launch_screenshot_loop() { + let (mut child, mut stdin, mut stdout) = spawn_sidecar(); + + request(&mut stdin, "probe", serde_json::json!({ "type": "probe" })); + expect_ok(&mut stdout, "probe", 60); + + request(&mut stdin, "list", serde_json::json!({ "type": "list" })); + let devices = expect_ok(&mut stdout, "list", 60); + let (udid, already_booted) = pick_device(&devices); + + if !already_booted { + request( + &mut stdin, + "boot", + serde_json::json!({ "type": "boot", "udid": udid }), + ); + expect_ok(&mut stdout, "boot", 240); + } + + let app = build_fixture_app(); + request( + &mut stdin, + "install", + serde_json::json!({ "type": "install", "udid": udid, "appPath": app.to_str().unwrap() }), + ); + expect_ok(&mut stdout, "install", 120); + + request( + &mut stdin, + "launch", + serde_json::json!({ "type": "launch", "udid": udid, "bundleId": FIXTURE_BUNDLE_ID }), + ); + let launch = expect_ok(&mut stdout, "launch", 60); + assert!(launch["pid"].as_u64().is_some(), "launch pid: {launch}"); + + request( + &mut stdin, + "shot", + serde_json::json!({ "type": "screenshot", "udid": udid }), + ); + let image = expect_screenshot(&mut stdout, "shot", 60); + assert!( + image.starts_with(&[0xFF, 0xD8]), + "expected JPEG magic, got {:?}", + &image[..4.min(image.len())] + ); + assert!(image.len() > 10_000, "suspiciously small: {}", image.len()); + + request( + &mut stdin, + "term", + serde_json::json!({ "type": "terminate", "udid": udid, "bundleId": FIXTURE_BUNDLE_ID }), + ); + expect_ok(&mut stdout, "term", 60); + + // Only shut down a device this test booted; a developer's own session stays up. + if !already_booted { + request( + &mut stdin, + "shutdown", + serde_json::json!({ "type": "shutdown", "udid": udid }), + ); + expect_ok(&mut stdout, "shutdown", 120); + } + + drop(stdin); + let _ = child.wait(); +} diff --git a/crates/linkcode-sim/tests/smoke.rs b/crates/linkcode-sim/tests/smoke.rs new file mode 100644 index 000000000..f49a58cbd --- /dev/null +++ b/crates/linkcode-sim/tests/smoke.rs @@ -0,0 +1,148 @@ +//! Protocol smoke tests: drive the compiled sidecar over its stdio framing without touching any +//! simulator. Runs on every platform — a machine without Xcode still answers every request with +//! a structured error instead of dying. + +use std::io::{Read, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +const REQUEST: u8 = 0x01; +const RESULT: u8 = 0x81; + +fn write_frame(w: &mut impl Write, type_byte: u8, body: &[u8]) { + let total = (1 + body.len()) as u32; + w.write_all(&total.to_le_bytes()).unwrap(); + w.write_all(&[type_byte]).unwrap(); + w.write_all(body).unwrap(); + w.flush().unwrap(); +} + +fn read_frame(r: &mut impl Read) -> Option<(u8, Vec)> { + let mut len = [0u8; 4]; + r.read_exact(&mut len).ok()?; + let total = u32::from_le_bytes(len) as usize; + let mut type_byte = [0u8; 1]; + r.read_exact(&mut type_byte).ok()?; + let mut body = vec![0u8; total - 1]; + r.read_exact(&mut body).ok()?; + Some((type_byte[0], body)) +} + +fn spawn_sidecar() -> (Child, ChildStdin, ChildStdout) { + let mut child = Command::new(env!("CARGO_BIN_EXE_linkcode-sim")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn sidecar"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + (child, stdin, stdout) +} + +/// Read RESULT frames until one matches `request_id` or the deadline passes. +fn wait_result(stdout: &mut impl Read, request_id: &str, secs: u64) -> Value { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + let Some((type_byte, body)) = read_frame(stdout) else { + break; + }; + if type_byte != RESULT { + continue; + } + let value: Value = serde_json::from_slice(&body).expect("RESULT body is JSON"); + if value["requestId"] == request_id { + return value; + } + } + panic!("no RESULT for {request_id} within {secs}s"); +} + +#[test] +fn malformed_request_with_recoverable_id_gets_a_structured_error() { + let (mut child, mut stdin, mut stdout) = spawn_sidecar(); + + // `type: nope` is not an op; the request must fail alone, with its id echoed back. + write_frame( + &mut stdin, + REQUEST, + br#"{"requestId":"r-bad","op":{"type":"nope"}}"#, + ); + let result = wait_result(&mut stdout, "r-bad", 10); + assert_eq!(result["ok"], false); + assert_eq!(result["error"]["code"], "invalidRequest"); + + // The host must survive: a well-formed request afterwards is still served. + write_frame( + &mut stdin, + REQUEST, + br#"{"requestId":"r-after","op":{"type":"probe"}}"#, + ); + let result = wait_result(&mut stdout, "r-after", 60); + assert_eq!(result["requestId"], "r-after"); + + let _ = child.kill(); + let _ = child.wait(); +} + +#[test] +fn unknown_frame_types_are_ignored() { + let (mut child, mut stdin, mut stdout) = spawn_sidecar(); + + write_frame(&mut stdin, 0x7F, b"garbage"); + write_frame( + &mut stdin, + REQUEST, + br#"{"requestId":"r-1","op":{"type":"probe"}}"#, + ); + let result = wait_result(&mut stdout, "r-1", 60); + assert_eq!(result["requestId"], "r-1"); + + let _ = child.kill(); + let _ = child.wait(); +} + +#[test] +fn probe_reports_tooling_or_a_structured_absence() { + let (mut child, mut stdin, mut stdout) = spawn_sidecar(); + + write_frame( + &mut stdin, + REQUEST, + br#"{"requestId":"r-probe","op":{"type":"probe"}}"#, + ); + let result = wait_result(&mut stdout, "r-probe", 60); + if result["ok"] == true { + // A Mac with Xcode: the probe pinpoints simctl. + let simctl_path = result["result"]["simctlPath"].as_str().unwrap(); + assert!(simctl_path.ends_with("simctl"), "got {simctl_path}"); + } else { + // Everything else: a stable capability-gate code, not a crash. + assert_eq!(result["error"]["code"], "xcodeMissing"); + } + + let _ = child.kill(); + let _ = child.wait(); +} + +#[test] +fn stdin_eof_shuts_the_sidecar_down() { + let (mut child, stdin, _stdout) = spawn_sidecar(); + + drop(stdin); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match child.try_wait().unwrap() { + Some(status) => { + assert!(status.success(), "expected a clean exit, got {status}"); + break; + } + None if Instant::now() >= deadline => { + let _ = child.kill(); + panic!("sidecar did not exit on stdin EOF"); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } +} From 08f965f22b5d0002b2e55cab7429b5c18edd6aa7 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 16:51:01 +0800 Subject: [PATCH 2/4] docs(sim): protocol reference and crate readme --- crates/linkcode-sim/PROTOCOL.md | 99 +++++++++++++++++++++++++++++++++ crates/linkcode-sim/README.md | 14 +++++ 2 files changed, 113 insertions(+) create mode 100644 crates/linkcode-sim/PROTOCOL.md create mode 100644 crates/linkcode-sim/README.md diff --git a/crates/linkcode-sim/PROTOCOL.md b/crates/linkcode-sim/PROTOCOL.md new file mode 100644 index 000000000..8cc63412b --- /dev/null +++ b/crates/linkcode-sim/PROTOCOL.md @@ -0,0 +1,99 @@ +# LinkCode iOS Simulator sidecar protocol + +Private stdin/stdout IPC protocol for `linkcode-sim`. For build and development notes, see [`README.md`](./README.md). + +The Rust implementation is in [`src/proto.rs`](src/proto.rs) and [`src/rpc.rs`](src/rpc.rs); the TypeScript counterpart lives in `@linkcode/sim` (`packages/host/sim`, CODE-393). Stderr is reserved for diagnostics and must not carry protocol data. + +## Transport + +Identical framing to `linkcode-pty`: each frame has a 5-byte header followed by a body: + +```text +[u32 little-endian total][u8 type][body] +``` + +`total = 1 + body.length`, must be non-zero and at most `16 MiB`. Multi-byte integers are little-endian. If frame decoding fails, the stream is considered corrupt — the receiver should stop using that sidecar instance instead of resynchronizing mid-stream. + +## Frame types + +Daemon to sidecar: + +| Type | Name | Body | +| ---: | --- | --- | +| `0x01` | `REQUEST` | JSON [`Request`](#request) | + +Sidecar to daemon: + +| Type | Name | Body | +| ---: | --- | --- | +| `0x81` | `RESULT` | JSON [`Result`](#result) | +| `0x82` | `SCREENSHOT` | binary [`Screenshot body`](#screenshot-body) | + +Unknown frame types are ignored. A malformed `REQUEST` fails only that request — the sidecar replies with an `invalidRequest` `RESULT` for its `requestId` and keeps serving. If the `requestId` itself cannot be recovered, the sidecar logs to stderr and the daemon's pending request is reclaimed by its own timeout. + +## Request + +```json +{ + "requestId": "r-1", + "op": { "type": "boot", "udid": "615920B7-…" } +} +``` + +`requestId` is daemon-generated, non-empty UTF-8, max `65535` bytes, and unique among in-flight requests. Each request runs on its own sidecar thread: a slow `boot` never delays a concurrent `screenshot`, and responses arrive in completion order, not request order. + +### Ops (P0 — everything shells out to `xcrun simctl`) + +| `type` | Params | Result | +| --- | --- | --- | +| `probe` | — | `{ simctlPath, developerDir }` | +| `list` | — | `{ devices: [{ udid, name, state, runtime, runtimeName?, deviceType }] }` | +| `boot` | `udid` | `{}` — waits for full boot (`bootstatus -b`); already-booted succeeds | +| `shutdown` | `udid` | `{}` — already-shutdown succeeds | +| `install` | `udid`, `appPath` | `{}` | +| `launch` | `udid`, `bundleId` | `{ pid }` (`pid` is `null` if simctl's output had no parsable pid) | +| `terminate` | `udid`, `bundleId` | `{}` | +| `openUrl` | `udid`, `url` | `{}` | +| `screenshot` | `udid`, `format?` (`jpeg` default, `png`) | bytes on a [`SCREENSHOT`](#screenshot-body) frame | + +Per-op deadlines are enforced sidecar-side (`boot` 180s, `install` 120s, `screenshot` 30s, others 60s); a child that outlives its deadline is killed and reported as `timeout`. + +## Result + +Success: + +```json +{ "requestId": "r-1", "ok": true, "result": {} } +``` + +Failure: + +```json +{ "requestId": "r-1", "ok": false, "error": { "code": "xcodeMissing", "message": "…" } } +``` + +Error codes: + +| Code | Meaning | +| --- | --- | +| `xcodeMissing` | `xcrun`/simctl not found — Xcode (with the iOS platform) is not installed or not selected. The daemon should gate the simulator capability on this, not retry. | +| `simctlFailed` | simctl ran and reported failure; `message` carries its stderr. | +| `timeout` | The operation's deadline elapsed. | +| `invalidRequest` | The request body could not be parsed. | +| `io` | Spawning simctl or reading its output failed at the OS level. | + +## Screenshot body + +A successful `screenshot` responds with raw image bytes instead of JSON, so captures never pay base64 on this private pipe: + +```text +[u16 little-endian request_id_length][request_id UTF-8 bytes][image bytes] +``` + +A failed `screenshot` responds with a normal `RESULT` error. Exactly one of the two arrives per request. + +## Lifecycle expectations + +- One sidecar process serves many concurrent requests; the daemon lazily starts it on first use. +- When daemon stdin closes, the sidecar stops writing and exits without waiting for in-flight simctl calls — a mid-boot device keeps booting server-side in CoreSimulatorService either way. The daemon owns device cleanup (per-session shutdown, CODE-393). +- If the sidecar exits or its stream becomes corrupt, the daemon rejects pending requests and restarts the sidecar with a fresh frame decoder. diff --git a/crates/linkcode-sim/README.md b/crates/linkcode-sim/README.md new file mode 100644 index 000000000..4815453de --- /dev/null +++ b/crates/linkcode-sim/README.md @@ -0,0 +1,14 @@ +# linkcode-sim + +The Link Code iOS Simulator host: one long-lived process the daemon spawns to drive Apple's iOS Simulator. P0 wraps the public `xcrun simctl` CLI only (lifecycle, install/launch, screenshot); the streaming/HID phase is CODE-396. The stdio wire contract is [`PROTOCOL.md`](./PROTOCOL.md); the TypeScript client is `@linkcode/sim` (`packages/host/sim`). + +macOS-only at runtime (it needs Xcode's simulator tooling); it compiles and answers `probe` with a structured `xcodeMissing` error everywhere else, so workspace-wide `cargo test` stays green on any platform. + +## Development + +```sh +cargo test -p linkcode-sim # unit + protocol smoke tests +cargo test -p linkcode-sim --test device_loop -- --ignored # full boot→install→launch→screenshot loop (needs Xcode, boots a simulator) +``` + +Release binaries are staged by `apps/desktop/scripts/stage-sidecar.mts` into `apps/desktop/sidecar/${arch}` (macOS only) and shipped via electron-builder `extraResources`. From c59e250d5a6076586c98c6aead32dd95f53e0d97 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 16:51:01 +0800 Subject: [PATCH 3/4] ci(desktop): stage the linkcode-sim sidecar on macOS --- .github/actions/build-sidecar/action.yml | 13 +++++++------ apps/desktop/AGENTS.md | 2 +- apps/desktop/electron-builder.yml | 7 ++++--- apps/desktop/scripts/stage-sidecar.mts | 19 ++++++++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-sidecar/action.yml b/.github/actions/build-sidecar/action.yml index c1aa314ef..0a3048f71 100644 --- a/.github/actions/build-sidecar/action.yml +++ b/.github/actions/build-sidecar/action.yml @@ -1,9 +1,10 @@ -# Builds the PTY sidecar (crates/linkcode-pty) for both release arches of the current platform -# and stages the binaries under apps/desktop/sidecar/${arch}, where electron-builder's -# extraResources expects them. Must run in the same job as the packaging step — the staged files -# feed it directly. The per-platform cross-target matrix lives in stage-sidecar.mts. -name: Build PTY sidecar -description: Cargo-build linkcode-pty for both arches and stage it for electron-builder +# Builds the sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS) for +# both release arches of the current platform and stages them under apps/desktop/sidecar/${arch}, +# where electron-builder's extraResources expects them. Must run in the same job as the packaging +# step — the staged files feed it directly. The per-platform cross-target matrix and the +# per-platform crate list live in stage-sidecar.mts. +name: Build sidecars +description: Cargo-build the sidecar crates for both arches and stage them for electron-builder runs: using: composite diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 7c271544d..c079781cc 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -20,7 +20,7 @@ The rules below are desktop-only — the **system plane**. - **`electron-builder.yml` is the packaging config** (YAML, not a `package.json` build key). `node scripts/build.mts` (three plain-Vite builds — `vite.{main,preload,renderer}.config.ts`, in that order; `scripts/dev.mts` is the dev counterpart) compiles into `out/`; electron-builder only wraps `out/**` + `package.json`. `directories.output=release` must equal `OUTPUT_DIR` in `build-desktop.yml`; `buildResources=build-resources` (NOT `build/`, which is a gitignored turbo output). `electronVersion` is hardcoded (electron-builder can't read pnpm's `catalog:` protocol) — the pinned number and its sync rule live in [`docs/RELEASE.md`](../../docs/RELEASE.md). - **Packaging always goes through `scripts/package-app.mts` (CODE-107), never a bare `electron-builder`.** It `pnpm --prod deploy`s the app's production closure into a self-contained staging dir **outside** the workspace, then runs electron-builder with `--projectDir` pointed there so `appDir === projectDir === workspaceRoot`. This is what makes `@electron/rebuild` find better-sqlite3 (pnpm hoists it to the repo-root `node_modules`, which electron-builder's Windows workspace-root probe can't locate — the pre-CODE-107 daemon shipped an un-rebuilt binding and died at boot on Windows) and makes the asar module collector see one importer instead of the whole monorepo. `package:devshell` uses `electron-builder.devshell.yml` + `--dir`; `package` is the production variant; both run through the script, which redirects `directories.output` back to `release/` and the icons to the shared `assets/`. There is no `dist` script (release packaging is CI-only). -- **`extraResources` ship real executables OUTSIDE the asar — the OS cannot exec an asar member:** `sidecar/${arch}` (the `linkcode-pty` PTY sidecar). `sidecar/` and `release/` are gitignored, so a fresh clone has none until staged; `pnpm -F @linkcode/desktop stage:host-runtime` builds the daemon + stages the sidecar. **Agent CLI binaries do not ship** (CODE-114): `files` globs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution in [`packages/host/agent-adapter/AGENTS.md`](../../packages/host/agent-adapter/AGENTS.md)); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the `--prod deploy` staging never contains it — the daemon downloads it into the asset store on first use. `verify-artifacts.mts` fails the build if a platform package, a pi-closure package, or `Resources/agent-bin` sneaks back in, or any artifact exceeds 200 MB. +- **`extraResources` ship real executables OUTSIDE the asar — the OS cannot exec an asar member:** `sidecar/${arch}` (the `linkcode-pty` PTY sidecar everywhere, plus the `linkcode-sim` iOS Simulator sidecar on macOS — the crate list lives in `scripts/stage-sidecar.mts`). `sidecar/` and `release/` are gitignored, so a fresh clone has none until staged; `pnpm -F @linkcode/desktop stage:host-runtime` builds the daemon + stages the sidecars. **Agent CLI binaries do not ship** (CODE-114): `files` globs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution in [`packages/host/agent-adapter/AGENTS.md`](../../packages/host/agent-adapter/AGENTS.md)); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the `--prod deploy` staging never contains it — the daemon downloads it into the asset store on first use. `verify-artifacts.mts` fails the build if a platform package, a pi-closure package, or `Resources/agent-bin` sneaks back in, or any artifact exceeds 200 MB. - **The bundled daemon comes from the `bundle-daemon-artifact` plugin** in `vite.main.config.ts`: it copies `apps/daemon/dist/index.js` → `out/daemon/index.mjs` (and `instrument.js` → `instrument.mjs` for Sentry preload; both renamed `.mjs` because they leave the daemon's `type=module` scope) and `apps/daemon/drizzle` → `out/drizzle`. It throws `` apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first `` if the daemon wasn't built; turbo `^build` guarantees ordering. - **`linkcode://` deep-link registration differs by build path.** Packaged builds register it via the `electron-builder.yml` `protocols` block (macOS Info.plist + Windows installer); dev shells register it at runtime via `setAsDefaultProtocolClient` (`cloud-auth/client.ts`). OAuth callback routing therefore depends on how the app was shipped. diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 287922675..302cc8d8c 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -64,9 +64,10 @@ files: - '!node_modules/@linkcode/**' - '!node_modules/better-sqlite3/deps/**' -# The PTY sidecar (crates/linkcode-pty), staged per arch by scripts/stage-sidecar.mjs. It is a -# real executable, so it must live outside the asar; the daemon supervisor points -# LINKCODE_PTY_SIDECAR_PATH at it under process.resourcesPath (src/main/daemon-supervisor.ts). +# The sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS), staged per +# arch by scripts/stage-sidecar.mts. They are real executables, so they must live outside the +# asar; the daemon supervisor points LINKCODE_PTY_SIDECAR_PATH at the PTY one under +# process.resourcesPath (src/main/daemon-supervisor.ts). extraResources: - from: sidecar/${arch} to: . diff --git a/apps/desktop/scripts/stage-sidecar.mts b/apps/desktop/scripts/stage-sidecar.mts index 45116de7d..c7c9e7061 100644 --- a/apps/desktop/scripts/stage-sidecar.mts +++ b/apps/desktop/scripts/stage-sidecar.mts @@ -1,7 +1,8 @@ #!/usr/bin/env node /** - * Build the PTY sidecar (crates/linkcode-pty) and stage it where electron-builder's - * `extraResources: sidecar/${arch}` (electron-builder.yml) picks it up. Default = host arch + * Build the sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS, + * where Apple's simulator runs) and stage them where electron-builder's + * `extraResources: sidecar/${arch}` (electron-builder.yml) picks them up. Default = host arch * (local `package`); `--all` adds the cross arch (CI, .github/actions/build-sidecar). */ import { execFileSync } from 'node:child_process'; @@ -30,21 +31,25 @@ const CROSS_BUILDS: Partial> = { const desktopDir = join(import.meta.dirname, '..'); const repoRoot = join(desktopDir, '..', '..'); -const binary = process.platform === 'win32' ? 'linkcode-pty.exe' : 'linkcode-pty'; +/** linkcode-sim drives Apple's iOS Simulator, which exists only on macOS. */ +const crates = process.platform === 'darwin' ? ['linkcode-pty', 'linkcode-sim'] : ['linkcode-pty']; function stage(arch: string, cross?: CrossBuild): void { - const cargoArgs = ['build', '-p', 'linkcode-pty', '--release']; + const cargoArgs = ['build', '--release', ...crates.flatMap((crate) => ['-p', crate])]; if (cross) cargoArgs.push('--target', cross.target); execFileSync('cargo', cargoArgs, { cwd: repoRoot, stdio: 'inherit', env: { ...process.env, ...cross?.env }, }); - const built = join(repoRoot, 'target', ...(cross ? [cross.target] : []), 'release', binary); const destDir = join(desktopDir, 'sidecar', arch); mkdirSync(destDir, { recursive: true }); - cpSync(built, join(destDir, binary)); - console.log(`staged ${built} -> ${join(destDir, binary)}`); + for (const crate of crates) { + const binary = process.platform === 'win32' ? `${crate}.exe` : crate; + const built = join(repoRoot, 'target', ...(cross ? [cross.target] : []), 'release', binary); + cpSync(built, join(destDir, binary)); + console.log(`staged ${built} -> ${join(destDir, binary)}`); + } } const { values } = parseArgs({ options: { all: { type: 'boolean' } } }); From c639e5c7570e565de81d65ddb78824c8a6844e62 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 01:50:11 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(sim):=20harden=20P0=20sidecar=20?= =?UTF-8?q?=E2=80=94=20scrub=20Apple=20SDK=20env,=20guard=20oversized=20fr?= =?UTF-8?q?ames,=20bound=20+=20drain=20workers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/linkcode-sim/src/main.rs | 105 ++++++++++++++++++++++++++++-- crates/linkcode-sim/src/simctl.rs | 17 ++++- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/crates/linkcode-sim/src/main.rs b/crates/linkcode-sim/src/main.rs index f8509998b..db4c66cd5 100644 --- a/crates/linkcode-sim/src/main.rs +++ b/crates/linkcode-sim/src/main.rs @@ -10,9 +10,16 @@ mod simctl; use std::io::{self, BufReader, Write}; use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread; +use std::time::{Duration, Instant}; -use crate::proto::{REQUEST, RESULT, SCREENSHOT, encode_screenshot, read_frame, write_frame}; +/// How long shutdown waits for in-flight request workers to finish before abandoning them. +const SHUTDOWN_DRAIN: Duration = Duration::from_secs(3); + +use crate::proto::{ + MAX_FRAME_LEN, REQUEST, RESULT, SCREENSHOT, encode_screenshot, read_frame, write_frame, +}; use crate::rpc::{ErrorCode, Op, OpError, Request, RequestIdOnly, error_body, success_body}; /// A frame bound for the daemon, or the sentinel that tells the writer thread to stop. @@ -21,6 +28,60 @@ enum OutMsg { Stop, } +/// Cap on concurrent request workers. A slow simctl op (a boot waits up to 180s) holds its worker +/// thread and an `xcrun` child the whole time, so spawning one per request unbounded lets a retry +/// burst exhaust the process's threads and file descriptors; over-cap requests park the read loop +/// until a worker frees a slot (backpressure to the daemon). +const MAX_INFLIGHT: usize = 24; + +/// A counting gate bounding concurrent request workers (see [`MAX_INFLIGHT`]). +struct InflightGate { + count: Mutex, + freed: Condvar, + max: usize, +} + +impl InflightGate { + fn new(max: usize) -> Self { + Self { + count: Mutex::new(0), + freed: Condvar::new(), + max, + } + } + /// Block until a worker slot is free, then take it. + fn acquire(&self) { + let mut count = self.count.lock().expect("inflight gate poisoned"); + while *count >= self.max { + count = self.freed.wait(count).expect("inflight gate poisoned"); + } + *count += 1; + } + /// Return a slot and wake one waiter. + fn release(&self) { + *self.count.lock().expect("inflight gate poisoned") -= 1; + self.freed.notify_one(); + } + /// Wait up to `timeout` for every worker to finish (best-effort shutdown drain). + fn wait_idle(&self, timeout: Duration) { + let deadline = Instant::now() + timeout; + let mut count = self.count.lock().expect("inflight gate poisoned"); + while *count > 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + let (guard, timed_out) = self + .freed + .wait_timeout(count, remaining) + .expect("inflight gate poisoned"); + count = guard; + if timed_out.timed_out() { + break; + } + } + } +} + fn main() { let (tx, rx) = channel::(); @@ -28,14 +89,23 @@ fn main() { let writer = thread::spawn(move || { let mut stdout = io::stdout().lock(); while let Ok(OutMsg::Frame { type_byte, body }) = rx.recv() { - if write_frame(&mut stdout, type_byte, &body).is_err() { - // The daemon is gone; frames have nowhere to go. - break; + match write_frame(&mut stdout, type_byte, &body) { + Ok(()) => {} + // An over-limit body is one bad frame, not a dead pipe: drop it and keep the writer + // alive so later responses still reach the daemon. The request that produced an + // oversized screenshot already gets a RESULT error from the guard in `serve`, so in + // practice this only backstops a future unbounded body. + Err(e) if e.kind() == io::ErrorKind::InvalidInput => { + eprintln!("dropping oversized frame ({} bytes)", body.len()); + } + // A real write failure means the daemon is gone; frames have nowhere to go. + Err(_) => break, } } let _ = stdout.flush(); }); + let gate = Arc::new(InflightGate::new(MAX_INFLIGHT)); let mut stdin = BufReader::new(io::stdin()); loop { let (type_byte, body) = match read_frame(&mut stdin) { @@ -53,8 +123,13 @@ fn main() { } match serde_json::from_slice::(&body) { Ok(request) => { + gate.acquire(); let tx = tx.clone(); - thread::spawn(move || serve(request, &tx)); + let gate = Arc::clone(&gate); + thread::spawn(move || { + serve(request, &tx); + gate.release(); + }); } Err(err) => { eprintln!("invalid REQUEST frame: {err}"); @@ -75,8 +150,11 @@ fn main() { } } - // Stop the writer without waiting for in-flight simctl calls: a mid-boot device keeps booting - // server-side in CoreSimulatorService whether or not our child lives to see it. + // On stdin EOF, let quick in-flight requests finish so their responses flush and their `xcrun` + // children exit, rather than orphaning them — but cap the wait: a mid-boot `bootstatus` can run + // for minutes, and that device keeps booting server-side in CoreSimulatorService whether or not + // our child lives to see it, so past the drain we abandon the rest. + gate.wait_idle(SHUTDOWN_DRAIN); let _ = tx.send(OutMsg::Stop); let _ = writer.join(); } @@ -95,6 +173,19 @@ fn serve(request: Request, tx: &Sender) { Op::OpenUrl { udid, url } => simctl::open_url(&udid, &url), Op::Screenshot { udid, format } => { match simctl::screenshot(&udid, format).and_then(|image| { + // Guard the frame budget here so an over-limit capture (a high-entropy iPad screen + // can exceed it) fails just this request instead of the sole writer thread — which + // would tear down and silently drop every later response. Frame overhead is the type + // byte + the u16 id length + the request id (see `encode_screenshot`/`write_frame`). + if image.len() + request_id.len() + 3 > MAX_FRAME_LEN { + return Err(OpError::new( + ErrorCode::SimctlFailed, + format!( + "screenshot is {} bytes, over the {MAX_FRAME_LEN}-byte frame limit", + image.len() + ), + )); + } encode_screenshot(&request_id, &image) .map_err(|e| OpError::new(ErrorCode::Io, e.to_string())) }) { diff --git a/crates/linkcode-sim/src/simctl.rs b/crates/linkcode-sim/src/simctl.rs index 485dfce43..befc5167d 100644 --- a/crates/linkcode-sim/src/simctl.rs +++ b/crates/linkcode-sim/src/simctl.rs @@ -47,14 +47,14 @@ pub struct Device { /// Check that simulator tooling is usable and report where it lives. pub fn probe() -> Result { - let mut find_simctl = Command::new(XCRUN); + let mut find_simctl = apple_tool(XCRUN); find_simctl.args(["--find", "simctl"]); let simctl_path = run_ok(find_simctl, DEFAULT_TIMEOUT).map_err(|e| match e.code { // xcrun exists but cannot find simctl: Xcode's iOS platform is missing. ErrorCode::SimctlFailed => OpError::new(ErrorCode::XcodeMissing, e.message), _ => e, })?; - let mut developer_dir_cmd = Command::new(XCODE_SELECT); + let mut developer_dir_cmd = apple_tool(XCODE_SELECT); developer_dir_cmd.arg("-p"); let developer_dir = run_ok(developer_dir_cmd, DEFAULT_TIMEOUT)?; Ok(json!({ @@ -145,11 +145,22 @@ pub fn screenshot(udid: &str, format: ImageFormat) -> Result, OpError> { } fn simctl<'a>(args: impl IntoIterator) -> Command { - let mut cmd = Command::new(XCRUN); + let mut cmd = apple_tool(XCRUN); cmd.arg("simctl").args(args); cmd } +/// Build a `Command` for an Apple tool at an absolute path, scrubbing the SDK-selection overrides a +/// launcher may have injected. `/usr/bin/xcrun` honors inherited `DEVELOPER_DIR`/`SDKROOT`, so a +/// foreign selection (nix xcbuild, a stale toolchain in the daemon's environment) makes `--find`, +/// `list`, `boot`, and the rest fail — reporting `xcodeMissing` — even with a full Xcode installed. +/// Every Apple-tool spawn must go through here (the device-loop fixture removes the same two vars). +fn apple_tool(program: &str) -> Command { + let mut cmd = Command::new(program); + cmd.env_remove("DEVELOPER_DIR").env_remove("SDKROOT"); + cmd +} + /// Run a command to completion under `timeout`; return its stdout on exit code 0, or a /// classified [`OpError`] otherwise. stderr rides along in the error message. fn run_ok(mut cmd: Command, timeout: Duration) -> Result {