use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CapsuleMode {
Hard,
Soft,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CapsuleState {
pub mode: CapsuleMode,
pub udid: String,
pub started_at: String,
pub runner_port: u16,
pub capture_endpoint: String,
pub simulator_app_was_running: bool,
#[serde(default)]
pub no_capture: bool,
}
#[derive(Debug)]
pub struct CapsuleGuardRejected {
pub hint: String,
}
pub const GUARD_HINT: &str = "Simulator.app 已运行 → simctl boot 会自动开窗,违反硬胶囊条件。\n\
关掉它后重试(`pkill -INT Simulator`),\n\
或加 `--soft` flag 显式接受降级到软胶囊(带窗,仅事件对账兜底,\n\
不保证物理无窗口入口)。";
pub fn state_path(workspace_root: &Path, udid: &str) -> PathBuf {
workspace_root
.join(".smix")
.join("capsule")
.join(format!("{udid}.state.json"))
}
pub fn simulator_app_running() -> bool {
std::process::Command::new("pgrep")
.args(["-x", "Simulator"])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub fn decide_mode(sim_running: bool, soft: bool) -> Result<CapsuleMode, CapsuleGuardRejected> {
match (sim_running, soft) {
(false, _) => Ok(CapsuleMode::Hard),
(true, true) => Ok(CapsuleMode::Soft),
(true, false) => Err(CapsuleGuardRejected {
hint: GUARD_HINT.to_string(),
}),
}
}
pub struct UpOptions<'a> {
pub root: &'a Path,
pub udid: &'a str,
pub runner_port: u16,
pub capture_endpoint: &'a str,
pub bundle: Option<&'a str>,
pub soft: bool,
pub no_capture: bool,
}
pub async fn up(opts: UpOptions<'_>) -> Result<(), String> {
let sim_running = simulator_app_running();
let mode = decide_mode(sim_running, opts.soft).map_err(|e| e.hint)?;
let simctl = smix_simctl::SimctlClient::new();
match simctl
.boot_and_wait(opts.udid, std::time::Duration::from_secs(120))
.await
{
Ok(_) => {}
Err(smix_simctl::SimctlError::NonZeroExit { stderr, .. })
if stderr.contains("current state: Booted") => {}
Err(e) => return Err(format!("simctl boot {}: {e}", opts.udid)),
}
if !opts.no_capture {
let capture_start_url = format!("{}/api/capture/start", opts.capture_endpoint);
let body = format!("{{\"udid\":\"{}\"}}", opts.udid);
post_json(&capture_start_url, &body).map_err(|e| {
format!(
"capsule up: capture start failed: {e} (是否 smix-server 在 {} 跑?)",
opts.capture_endpoint
)
})?;
}
if opts.no_capture {
println!(
"capsule up: mode={mode:?} device={} no-capture (simctl io recordVideo lock free for in-scenario use)",
opts.udid
);
} else {
println!(
"capsule up: mode={mode:?} device={} /live={}/live/{}",
opts.udid, opts.capture_endpoint, opts.udid
);
}
crate::runner::up(
opts.root,
opts.udid,
opts.runner_port,
opts.bundle.or(Some("dev.smix.SelftestFixture")),
true,
)?;
let path = state_path(opts.root, opts.udid);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let state = CapsuleState {
mode,
udid: opts.udid.to_string(),
started_at: chrono::Utc::now().to_rfc3339(),
runner_port: opts.runner_port,
capture_endpoint: opts.capture_endpoint.to_string(),
simulator_app_was_running: sim_running,
no_capture: opts.no_capture,
};
let json = serde_json::to_string_pretty(&state).map_err(|e| format!("serialize state: {e}"))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("rename {}: {e}", path.display()))?;
Ok(())
}
pub async fn down(root: &Path, udid: &str) -> Result<(), String> {
let path = state_path(root, udid);
if !path.exists() {
eprintln!("capsule down: {} not active (no state.json)", udid);
return Ok(());
}
let json =
std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let state: CapsuleState =
serde_json::from_str(&json).map_err(|e| format!("deserialize {}: {e}", path.display()))?;
let mut errors: Vec<String> = Vec::new();
if let Err(e) = crate::runner::down(root, state.runner_port) {
eprintln!("capsule down: runner down failed: {e}");
errors.push(format!("runner: {e}"));
}
if !state.no_capture {
let capture_stop_url = format!("{}/api/capture/stop", state.capture_endpoint);
let body = format!("{{\"udid\":\"{}\"}}", state.udid);
if let Err(e) = post_json(&capture_stop_url, &body) {
if !e.contains("status=404") && !e.contains("status=410") {
eprintln!("capsule down: capture stop failed: {e}");
errors.push(format!("capture: {e}"));
}
}
}
let simctl = smix_simctl::SimctlClient::new();
if let Err(e) = simctl.shutdown(&state.udid).await {
eprintln!("capsule down: simctl shutdown failed: {e}");
errors.push(format!("shutdown: {e}"));
}
if let Err(e) = std::fs::remove_file(&path) {
eprintln!("capsule down: remove {}: {e}", path.display());
errors.push(format!("rm state: {e}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(format!(
"capsule down completed with errors: {}",
errors.join("; ")
))
}
}
fn post_json(url: &str, body: &str) -> Result<(), String> {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let (host, port, path) = parse_url(url)?;
let mut stream = TcpStream::connect_timeout(
&(host.as_str(), port)
.to_socket_addrs()
.map_err(|e| format!("resolve {host}:{port}: {e}"))?
.next()
.ok_or_else(|| format!("no socket addr for {host}:{port}"))?,
Duration::from_secs(5),
)
.map_err(|e| format!("connect {host}:{port}: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|e| format!("set read_timeout: {e}"))?;
let req = format!(
"POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(req.as_bytes())
.map_err(|e| format!("write: {e}"))?;
let mut resp = String::new();
stream
.read_to_string(&mut resp)
.map_err(|e| format!("read: {e}"))?;
let status_line = resp.lines().next().unwrap_or("");
let status_code: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.ok_or_else(|| format!("malformed status line: {status_line:?}"))?;
if (200..300).contains(&status_code) {
Ok(())
} else {
Err(format!(
"status={status_code} body={}",
resp.lines().last().unwrap_or("")
))
}
}
fn parse_url(url: &str) -> Result<(String, u16, String), String> {
let rest = url
.strip_prefix("http://")
.ok_or_else(|| format!("only http:// supported, got {url}"))?;
let (host_port, path) = rest.split_once('/').unwrap_or((rest, ""));
let (host, port) = if let Some((h, p)) = host_port.split_once(':') {
(
h.to_string(),
p.parse().map_err(|e| format!("port parse: {e}"))?,
)
} else {
(host_port.to_string(), 80)
};
Ok((host, port, format!("/{path}")))
}
use std::net::ToSocketAddrs;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_path_under_workspace() {
let p = state_path(Path::new("/tmp/ws"), "ABCD-1234");
assert_eq!(
p,
PathBuf::from("/tmp/ws/.smix/capsule/ABCD-1234.state.json")
);
}
#[test]
fn mode_default_hard_when_simulator_absent() {
assert_eq!(decide_mode(false, false).unwrap(), CapsuleMode::Hard);
assert_eq!(decide_mode(false, true).unwrap(), CapsuleMode::Hard);
}
#[test]
fn mode_requires_soft_flag_when_simulator_present() {
let err = decide_mode(true, false).unwrap_err();
assert!(
err.hint.contains("Simulator.app 已运行"),
"guard hint should name the precondition, got {:?}",
err.hint
);
assert!(
err.hint.contains("--soft"),
"guard hint should suggest --soft, got {:?}",
err.hint
);
}
#[test]
fn mode_soft_when_explicit() {
assert_eq!(decide_mode(true, true).unwrap(), CapsuleMode::Soft);
}
#[test]
fn state_round_trips_json() {
let s = CapsuleState {
mode: CapsuleMode::Hard,
udid: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string(),
started_at: "2026-06-14T12:34:56+09:00".to_string(),
runner_port: 22087,
capture_endpoint: "http://127.0.0.1:8787".to_string(),
simulator_app_was_running: false,
no_capture: false,
};
let j = serde_json::to_string(&s).unwrap();
let r: CapsuleState = serde_json::from_str(&j).unwrap();
assert_eq!(r.mode, CapsuleMode::Hard);
assert_eq!(r.udid, s.udid);
assert_eq!(r.started_at, s.started_at);
assert_eq!(r.runner_port, 22087);
assert_eq!(r.capture_endpoint, s.capture_endpoint);
assert!(!r.simulator_app_was_running);
assert!(!r.no_capture);
}
#[test]
fn state_round_trips_with_no_capture_flag() {
let s = CapsuleState {
mode: CapsuleMode::Hard,
udid: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string(),
started_at: "2026-06-16T12:34:56+09:00".to_string(),
runner_port: 22087,
capture_endpoint: "http://127.0.0.1:8787".to_string(),
simulator_app_was_running: false,
no_capture: true,
};
let j = serde_json::to_string(&s).unwrap();
let r: CapsuleState = serde_json::from_str(&j).unwrap();
assert!(r.no_capture);
}
#[test]
fn state_back_compat_missing_no_capture_field_defaults_false() {
let legacy = r#"{
"mode": "hard",
"udid": "X",
"started_at": "2026-06-14T12:34:56+09:00",
"runner_port": 22087,
"capture_endpoint": "http://127.0.0.1:8787",
"simulator_app_was_running": false
}"#;
let r: CapsuleState = serde_json::from_str(legacy).unwrap();
assert!(!r.no_capture);
}
}