use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use crate::meta::Project;
use crate::targets::{Target, TargetKind};
pub struct ScriptRun {
pub steps_total: usize,
pub steps_failed: usize,
pub screenshots: Vec<PathBuf>,
}
fn parse_flow(path: &Path) -> Result<Vec<(String, serde_json::Value)>, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let doc: serde_json::Value = serde_norway::from_str(&text).map_err(|e| e.to_string())?;
let flow = doc
.get("flow")
.and_then(|f| f.as_array())
.ok_or("script has no `flow:` sequence")?;
let mut steps = Vec::new();
for entry in flow {
let obj = entry
.as_object()
.ok_or("flow entries must be single-key mappings")?;
let (op, params) = obj.iter().next().ok_or("empty flow entry")?;
let mut step = serde_json::Map::new();
step.insert("op".into(), serde_json::Value::String(op.clone()));
match params {
serde_json::Value::Object(m) => {
for (k, v) in m {
step.insert(k.clone(), v.clone());
}
}
serde_json::Value::String(s) if op == "screenshot" => {
step.insert("name".into(), serde_json::Value::String(s.clone()));
}
serde_json::Value::Number(n) if op == "pause" => {
step.insert("secs".into(), serde_json::Value::Number(n.clone()));
}
serde_json::Value::Null => {}
other => {
return Err(format!("step {op}: unsupported params {other}"));
}
}
steps.push((op.clone(), serde_json::Value::Object(step)));
}
Ok(steps)
}
fn connect_window_secs() -> u64 {
std::env::var("DAYSCRIPT_CONNECT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(20)
}
fn connect(port: u16) -> Result<TcpStream, String> {
let attempts = connect_window_secs() * 4; for _ in 0..attempts {
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)) {
s.set_read_timeout(Some(Duration::from_secs(connect_window_secs().max(20))))
.ok();
return Ok(s);
}
std::thread::sleep(Duration::from_millis(250));
}
Err(format!(
"could not connect to the dayscript engine on 127.0.0.1:{port}"
))
}
fn shot_dir(project: &Project, target: &Target, locale: Option<&str>) -> PathBuf {
project
.root
.join("build/day/screenshots")
.join(target.name)
.join(locale.unwrap_or("default"))
}
fn device_screenshot(target: &Target, path: &Path) -> Result<(), String> {
match target.kind {
TargetKind::IosSim => {
let udid = crate::mobile::booted_sims()
.into_iter()
.next()
.unwrap_or_else(|| "booted".into());
let ok = Command::new("xcrun")
.args(["simctl", "io", &udid, "screenshot"])
.arg(path)
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err("simctl screenshot failed".into())
}
}
TargetKind::Android => {
let mut cmd = Command::new("adb");
if let Some(dev) = crate::mobile::android_devices().first() {
cmd.args(["-s", &dev.serial]);
}
let out = cmd
.args(["exec-out", "screencap", "-p"])
.output()
.map_err(|e| e.to_string())?;
std::fs::write(path, &out.stdout).map_err(|e| e.to_string())
}
TargetKind::Desktop => Err("desktop snapshot returned unsupported".into()),
TargetKind::HarmonyOs => {
let dev = "/data/local/tmp/day-shot.png";
let cap = crate::ohos::hdc()
.args(["shell", "uitest", "screenCap", "-p", dev])
.status()
.map(|s| s.success())
.unwrap_or(false)
|| crate::ohos::hdc()
.args(["shell", "snapshot_display", "-f", dev])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !cap {
return Err("hdc screenshot failed (uitest screenCap / snapshot_display)".into());
}
crate::ohos::hdc()
.args(["file", "recv", dev])
.arg(path)
.status()
.map_err(|e| e.to_string())
.and_then(|s| {
if s.success() {
Ok(())
} else {
Err("hdc file recv failed".into())
}
})
}
}
}
pub fn run_scripts(
project: &Project,
target: &'static Target,
port: u16,
token: &str,
scripts: &[PathBuf],
locale: Option<&str>,
) -> Result<ScriptRun, String> {
if target.kind == TargetKind::Android {
let mut cmd = Command::new("adb");
if let Some(dev) = crate::mobile::android_devices().first() {
cmd.args(["-s", &dev.serial]);
}
let _ = cmd
.args(["forward", &format!("tcp:{port}"), &format!("tcp:{port}")])
.status();
}
if target.kind == TargetKind::HarmonyOs {
let _ = crate::ohos::hdc()
.args(["fport", &format!("tcp:{port}"), &format!("tcp:{port}")])
.status();
}
let mut stream = connect(port)?;
let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
let roundtrip = |stream: &mut TcpStream,
reader: &mut BufReader<TcpStream>,
line: &str|
-> Result<String, String> {
let deadline = std::time::Instant::now() + Duration::from_secs(connect_window_secs());
loop {
let attempt = (|| -> Result<String, String> {
stream
.write_all(line.as_bytes())
.map_err(|e| e.to_string())?;
let mut reply = String::new();
let n = reader.read_line(&mut reply).map_err(|e| e.to_string())?;
if n == 0 {
return Err("EOF".into());
}
Ok(reply)
})();
match attempt {
Ok(r) => return Ok(r),
Err(e) if std::time::Instant::now() < deadline => {
let _ = e;
std::thread::sleep(Duration::from_millis(500));
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)) {
s.set_read_timeout(Some(Duration::from_secs(
connect_window_secs().max(20),
)))
.ok();
*reader = BufReader::new(s.try_clone().map_err(|e| e.to_string())?);
*stream = s;
}
}
Err(e) => return Err(format!("engine connection lost: {e}")),
}
}
};
let dir = shot_dir(project, target, locale);
let _ = std::fs::create_dir_all(&dir);
let mut run = ScriptRun {
steps_total: 0,
steps_failed: 0,
screenshots: Vec::new(),
};
for script in scripts {
let steps = parse_flow(script)?;
eprintln!(
"\x1b[1m Script\x1b[0m {} on {} ({} steps)",
script.display(),
target.name,
steps.len()
);
for (op, step) in steps {
run.steps_total += 1;
if op == "pause" {
let secs = step.get("secs").and_then(|v| v.as_f64()).unwrap_or(0.5);
std::thread::sleep(Duration::from_secs_f64(secs));
eprintln!(" \x1b[32m✓\x1b[0m pause {secs}s");
continue;
}
let req = serde_json::json!({"token": token, "step": step});
let mut line = serde_json::to_string(&req).unwrap();
line.push('\n');
let reply_line = roundtrip(&mut stream, &mut reader, &line)?;
let reply: serde_json::Value =
serde_json::from_str(reply_line.trim()).map_err(|e| e.to_string())?;
let ok = reply.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
let detail = step
.get("id")
.and_then(|v| v.as_str())
.or_else(|| step.get("name").and_then(|v| v.as_str()))
.unwrap_or("");
if ok {
eprintln!(" \x1b[32m✓\x1b[0m {op} {detail}");
} else {
run.steps_failed += 1;
let err = reply
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("failed");
eprintln!(" \x1b[31m✗\x1b[0m {op} {detail} — {err}");
}
if op == "screenshot" && ok {
let name = step.get("name").and_then(|v| v.as_str()).unwrap_or("shot");
let path = dir.join(format!("{name}.png"));
if let Some(b64) = reply.get("png_base64").and_then(|v| v.as_str()) {
let bytes = day_script_b64::b64decode(b64);
let _ = std::fs::write(&path, bytes);
run.screenshots.push(path);
} else if reply
.get("screenshot_unsupported")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
match device_screenshot(target, &path) {
Ok(()) => run.screenshots.push(path),
Err(e) => eprintln!(" (device screenshot failed: {e})"),
}
}
}
}
}
terminate(project, target);
Ok(run)
}
fn terminate(project: &Project, target: &Target) {
match target.kind {
TargetKind::Desktop if cfg!(windows) => {
let _ = Command::new("taskkill")
.args(["/F", "/IM", &format!("{}.exe", project.manifest.app.name)])
.status();
}
TargetKind::Desktop => {
let _ = Command::new("pkill")
.args([
"-f",
&format!("cargo/{}.*{}", target.name, project.manifest.app.name),
])
.status();
}
TargetKind::IosSim => {
let _ = Command::new("xcrun")
.args(["simctl", "terminate", "booted", &project.manifest.app.id])
.status();
}
TargetKind::Android => {
let _ = Command::new("adb")
.args(["shell", "am", "force-stop", &project.manifest.app.id])
.status();
}
TargetKind::HarmonyOs => {
let _ = crate::ohos::hdc()
.args(["shell", "aa", "force-stop", &project.manifest.app.id])
.status();
}
}
}
pub fn pick_port(index: usize) -> u16 {
34100 + (std::process::id() % 900) as u16 + index as u16
}
pub fn make_token() -> String {
format!(
"{:x}-{:x}",
std::process::id(),
std::time::UNIX_EPOCH
.elapsed()
.map(|d| d.as_millis())
.unwrap_or(0)
)
}
mod day_script_b64 {
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn b64decode(s: &str) -> Vec<u8> {
let val = |c: u8| B64.iter().position(|&x| x == c).unwrap_or(0) as u32;
let bytes: Vec<u8> = s.bytes().filter(|&c| c != b'\n' && c != b'\r').collect();
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
if chunk.len() < 4 {
break;
}
let pad = chunk.iter().filter(|&&c| c == b'=').count();
let n = (val(chunk[0]) << 18)
| (val(chunk[1]) << 12)
| (val(if chunk[2] == b'=' { b'A' } else { chunk[2] }) << 6)
| val(if chunk[3] == b'=' { b'A' } else { chunk[3] });
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
out
}
}