day-cli 0.0.10

Declarative app development API using native UI toolkits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
//! The dayscript runner (DESIGN.md §14, §16.5): launches the app with the engine invited
//! (token + runner-chosen port — the port-0 handshake-file refinement is post-MVP), connects
//! over TCP (adb-forwarded on Android), executes the YAML flow, saves screenshots, prints
//! per-step results, and returns exit code 5 on assertion failure.

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};
use crate::term::{BOLD, ERROR, SUCCESS, WARN};
use anstream::eprintln;

pub struct ScriptRun {
    pub steps_total: usize,
    pub steps_failed: usize,
    pub screenshots: Vec<PathBuf>,
}

/// Parse a walkthrough file into engine steps: each flow entry is a single-key mapping
/// (`- tap: { id: x, repeat: 3 }`, `- screenshot: home`, `- wait_idle:`).
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)
}

/// How long to keep (re)trying the engine connection, in seconds. Override with
/// `DAYSCRIPT_CONNECT_SECS`; the default is per-target — 20 s for local targets, 120 s for
/// HarmonyOS, whose software-emulated (TCG) guest can spend minutes between `aa start` and the
/// app-side engine binding its socket (and whose forwarded hdc channel drops with transient
/// connection resets that the roundtrip retry below rides out).
pub(crate) fn connect_window_secs(kind: TargetKind) -> u64 {
    std::env::var("DAYSCRIPT_CONNECT_SECS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(match kind {
            TargetKind::HarmonyOs => 120,
            _ => 20,
        })
}

pub(crate) fn connect(port: u16, window_secs: u64) -> Result<TcpStream, String> {
    let attempts = window_secs * 4; // 250 ms apart
    for _ in 0..attempts {
        if let Ok(s) = TcpStream::connect(("127.0.0.1", port)) {
            s.set_read_timeout(Some(Duration::from_secs(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}"
    ))
}

/// Where a run's screenshots land: `build/day/screenshots/<target>/<subdir>/`. The subdir is
/// the `--variant` name when given (themed/localized capture sets: light / dark / fr), else
/// the locale, else "default".
fn shot_dir(
    project: &Project,
    target: &Target,
    locale: Option<&str>,
    variant: Option<&str>,
) -> PathBuf {
    project
        .root
        .join("build/day/screenshots")
        .join(target.name)
        .join(variant.or(locale).unwrap_or("default"))
}

/// Device-level capture fallback for targets whose in-process snapshot is unsupported.
fn device_screenshot(target: &Target, path: &Path) -> Result<(), String> {
    match target.kind {
        TargetKind::IosSim => {
            // The scripted run drives one simulator (the first booted); pin it so multiple booted
            // sims don't make `simctl … booted` ambiguous.
            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 => {
            // Pin the first device (the one the runner forwarded to), else `adb` errors with
            // several attached.
            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 => {
            // Engine (in-process) snapshot unavailable — on an X11 session (the CI linux legs run
            // under xvfb) capture the root window with ImageMagick's `import`: with the xvfb
            // screen sized to the app window (ci.yml passes `-screen 0 1000x720x24`) the root IS
            // the window. Elsewhere there is nothing portable to call.
            if cfg!(target_os = "linux") && std::env::var_os("DISPLAY").is_some() {
                let ok = Command::new("import")
                    .args(["-window", "root", "-silent"])
                    .arg(path)
                    .status()
                    .map(|s| s.success())
                    .unwrap_or(false);
                if ok {
                    return Ok(());
                }
            }
            Err("desktop snapshot returned unsupported".into())
        }
        TargetKind::HarmonyOs => {
            // `uitest screenCap` writes a real PNG; `snapshot_display` writes JPEG (so its bytes in a
            // .png file are wrong) — prefer uitest, fall back to snapshot_display. Then `hdc file recv`.
            // Re-wake the display first (best-effort): a sleeping screen captures as a black frame.
            let _ = crate::ohos::hdc()
                .args(["shell", "power-shell", "wakeup"])
                .status();
            // The TCG guest's compositor lags the UI thread: `ui_idle` returns when DAY's tree
            // settled, but screenCap still serves the PREVIOUS frame for a while (every shot
            // trails one page without this). Give composition a moment to catch up.
            std::thread::sleep(Duration::from_millis(
                std::env::var("DAY_OHOS_SHOT_SETTLE_MS")
                    .ok()
                    .and_then(|v| v.parse().ok())
                    .unwrap_or(1500),
            ));
            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())
                    }
                })
        }
    }
}

/// Reach the in-app dayscript engine from the host: device targets need a TCP forward
/// (adb / hdc); desktop and the iOS simulator answer on loopback directly.
/// Public seams for `day drive` (drive.rs): the same primitives run_scripts uses.
pub(crate) fn b64decode_public(s: &str) -> Vec<u8> {
    day_script_b64::b64decode(s)
}
pub(crate) fn b64encode_public(bytes: &[u8]) -> String {
    day_script_b64::b64encode(bytes)
}
pub(crate) fn device_screenshot_public(target: &Target, path: &Path) -> Result<(), String> {
    device_screenshot(target, path)
}

pub(crate) fn forward_engine(kind: TargetKind, port: u16) {
    if kind == TargetKind::Android {
        // The dayscript runner drives ONE device; with several attached, `adb forward` (no `-s`)
        // errors ("more than one device"), so pin the first enumerated device.
        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 kind == TargetKind::HarmonyOs {
        // hdc's `adb forward` equivalent: host tcp:port → the app's tcp:port on the launched
        // target, so `connect(port)` reaches the in-app dayscript engine (docs/harmonyos.md;
        // pinned to the discovered device + retried through hdc server recycles in ohos.rs).
        crate::ohos::fport_engine(port);
    }
}

#[allow(clippy::too_many_arguments)] // a straight CLI-flag pass-through, not an API surface
pub fn run_scripts(
    project: &Project,
    target: &'static Target,
    port: u16,
    token: &str,
    scripts: &[PathBuf],
    locale: Option<&str>,
    variant: Option<&str>,
    keep_alive: bool,
    attached: bool,
) -> Result<ScriptRun, String> {
    forward_engine(target.kind, port);
    let window_secs = connect_window_secs(target.kind);
    let mut stream = connect(port, window_secs)?;
    let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);

    // adb-forwarded ports accept host connections BEFORE the device listener exists; a
    // request/reply that hits EOF reconnects and retries within a bounded window.
    let roundtrip = |stream: &mut TcpStream,
                     reader: &mut BufReader<TcpStream>,
                     line: &str|
     -> Result<String, String> {
        let deadline = std::time::Instant::now() + Duration::from_secs(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(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, variant);
    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!(
            "{BOLD}     Script{BOLD:#} {} on {} ({} steps)",
            script.display(),
            target.name,
            steps.len()
        );
        for (op, step) in steps {
            run.steps_total += 1;
            // `pause` sleeps runner-side (the engine must not block the UI thread).
            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!("  {SUCCESS}{SUCCESS:#} 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!("  {SUCCESS}{SUCCESS:#} {op} {detail}");
            } else {
                run.steps_failed += 1;
                let err = reply
                    .get("error")
                    .and_then(|v| v.as_str())
                    .unwrap_or("failed");
                eprintln!("  {ERROR}{ERROR:#} {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})"),
                    }
                }
            }
        }
    }
    if keep_alive {
        // Interactive script development (docs/agent.md): leave the app running — its session
        // stays drivable (`day drive`), so scripts can be built and debugged incrementally.
        // Attached: `day` stays in the foreground streaming the app's console output until the
        // app exits or the run is stopped. Detached: `day` exits now and the app lives on.
        if attached {
            eprintln!(
                "  {WARN}{WARN:#} {} left running (--keep-alive): streaming logs — stop the task \
                 (or Ctrl-C) to quit; drive it from another shell with `day drive -p {}`",
                target.name, target.name
            );
        } else {
            eprintln!(
                "  {WARN}{WARN:#} {} left running (--keep-alive): drive it with `day drive -p {}`",
                target.name, target.name
            );
        }
    } else {
        // Terminate the app now that the run is over (and drop its session entry).
        terminate(project, target);
        crate::sessions::remove(&project.root, target.name);
    }
    // Refresh the machine-local screenshot gallery (an at-a-glance index of every capture
    // set under build/day/screenshots/) after each run that saved captures.
    if !run.screenshots.is_empty() {
        write_gallery(&project.root.join("build/day/screenshots"));
    }
    Ok(run)
}

/// Regenerate `build/day/screenshots/index.html`: one labelled thumbnail per capture, grouped
/// by `<target>/<variant>`, each linking to the full-size image — a quick browsable index of
/// everything captured on this machine (open it with `open build/day/screenshots/index.html`).
fn write_gallery(root: &Path) {
    fn dirs(p: &Path) -> Vec<PathBuf> {
        let mut v: Vec<PathBuf> = std::fs::read_dir(p)
            .map(|rd| {
                rd.flatten()
                    .map(|e| e.path())
                    .filter(|p| p.is_dir())
                    .collect()
            })
            .unwrap_or_default();
        v.sort();
        v
    }
    fn esc(s: &str) -> String {
        s.replace('&', "&amp;").replace('<', "&lt;")
    }
    let mut body = String::new();
    let mut shots = 0usize;
    for target in dirs(root) {
        let tname = target
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .into_owned();
        for variant in dirs(&target) {
            let vname = variant
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned();
            let mut pngs: Vec<PathBuf> = std::fs::read_dir(&variant)
                .map(|rd| {
                    rd.flatten()
                        .map(|e| e.path())
                        .filter(|p| p.extension().is_some_and(|e| e == "png"))
                        .collect()
                })
                .unwrap_or_default();
            pngs.sort();
            if pngs.is_empty() {
                continue;
            }
            body.push_str(&format!(
                "<section><h2>{} <span class=\"v\">{}</span></h2><div class=\"grid\">",
                esc(&tname),
                esc(&vname)
            ));
            for png in &pngs {
                let name = png
                    .file_stem()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .into_owned();
                let rel = format!("{}/{}/{}.png", tname, vname, name);
                body.push_str(&format!(
                    "<a href=\"{rel}\"><figure><img loading=\"lazy\" src=\"{rel}\" alt=\"{n}\"><figcaption>{n}</figcaption></figure></a>",
                    rel = esc(&rel),
                    n = esc(&name)
                ));
                shots += 1;
            }
            body.push_str("</div></section>");
        }
    }
    let html = format!(
        "<!doctype html><meta charset=\"utf-8\"><title>day screenshots</title><style>\
         body{{font:14px system-ui;margin:24px;background:#16181d;color:#e8eaf0}}\
         h1{{font-size:1.2rem}} h2{{font-size:0.9rem;margin:28px 0 10px;text-transform:uppercase;letter-spacing:0.08em}}\
         h2 .v{{color:#8bd5d3;margin-left:6px}} a{{color:inherit;text-decoration:none}}\
         .grid{{display:flex;flex-wrap:wrap;gap:14px}} figure{{margin:0;width:120px}}\
         img{{width:120px;border:1px solid #333a44;border-radius:6px;display:block;background:#0f1115}}\
         figcaption{{font-size:11px;color:#9aa0ad;text-align:center;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}\
         </style><h1>day screenshots — {shots} captures</h1>{body}"
    );
    let _ = std::fs::write(root.join("index.html"), html);
}

pub(crate) fn terminate(project: &Project, target: &Target) {
    match target.kind {
        TargetKind::Desktop if cfg!(windows) => {
            // No pkill on Windows; kill the app by image name (taskkill is on every runner).
            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)
    )
}

/// A minimal standalone base64 decoder — dayscript replies (screenshots, a11y dumps) come back
/// base64-encoded. Inlined here so the CLI needn't pull in `day-script` (and its whole runtime graph:
/// day-core/reactive/pieces/fluent/l10n) for one small function; `day-script` keeps its own copy for
/// the app side.
mod day_script_b64 {
    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

    pub fn b64encode(bytes: &[u8]) -> String {
        let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
        for chunk in bytes.chunks(3) {
            let b = [
                chunk[0],
                *chunk.get(1).unwrap_or(&0),
                *chunk.get(2).unwrap_or(&0),
            ];
            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
            out.push(B64[(n >> 18) as usize & 63] as char);
            out.push(B64[(n >> 12) as usize & 63] as char);
            out.push(if chunk.len() > 1 {
                B64[(n >> 6) as usize & 63] as char
            } else {
                '='
            });
            out.push(if chunk.len() > 2 {
                B64[n as usize & 63] as char
            } else {
                '='
            });
        }
        out
    }

    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
    }
}