day-cli 0.0.6

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
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
//! HarmonyOS / OpenHarmony (`ohos-arkui`) pipeline — the OHOS analogue of mobile.rs's android/iOS
//! pipelines. `build_ohos` cross-compiles the app to `libentry.so`, then packages + signs a `.hap`
//! via the ArkTS host project under `<project>/platform/ohos/`; `launch_ohos` installs + starts it on a
//! connected emulator/device over `hdc`.
//!
//! The reference emulator is the openharmony-rs `emulator-action` Oniro QEMU image: an **x86_64**,
//! software-emulated (TCG), NETWORKED hdc target — so every hdc call carries `-t <connect-key>`
//! (default `127.0.0.1:55555`; override with `DAY_OHOS_TARGET`). Building a `.hap` needs `hvigor` +
//! `ohpm` on PATH (from the OpenHarmony command-line-tools), the SDK via `OHOS_BASE_SDK_HOME` /
//! `OHOS_NDK_HOME` (e.g. from `openharmony-rs/setup-ohos-sdk`), and a JDK for signing. Two OHOS-only
//! gotchas the code accounts for (see the CI research): `aa start` exits 0 even when the launch is
//! refused (so we parse its output for `Error Code:`), and `snapshot_display` writes JPEG (so the
//! screenshot path prefers `uitest screenCap`, which writes PNG). See docs/harmonyos.md.

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;

use crate::meta::Project;
use crate::mobile::{run_logged, rustup_cargo};
use crate::ops::{BuildOutcome, LaunchSpec, LogStream, emit_log, status};
use crate::targets::Target;

/// Bring up the Oniro/OpenHarmony QEMU emulator as a native window (the OHOS analogue of
/// `skip android emulator launch`). No VNC, no Screen Sharing: on macOS the QEMU `cocoa` backend
/// opens a real window; `--headless` uses no display (hdc-only, for CI). Self-contained — it builds
/// the QEMU command itself, so it doesn't depend on the emulator distribution's shell launcher.
///
/// The image directory is `DAY_OHOS_EMULATOR` (a dir holding `bzImage`, `ramdisk.img`, `system.img`,
/// `vendor.img`, `updater.img`, `userdata.img`) or the default `~/ohos/emulator/images`. The host
/// hdc port comes from `DAY_OHOS_TARGET` (default `127.0.0.1:55555`), forwarded to the guest's 55555.
pub fn emulator_launch(headless: bool) -> Result<(), String> {
    let home = std::env::var("HOME").unwrap_or_default();
    let images = std::env::var("DAY_OHOS_EMULATOR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from(&home).join("ohos/emulator/images"));
    for f in [
        "bzImage",
        "ramdisk.img",
        "system.img",
        "vendor.img",
        "userdata.img",
    ] {
        if !images.join(f).exists() {
            return Err(format!(
                "OpenHarmony emulator images not found at {} (missing {f}). Download the Oniro \
                 emulator and set DAY_OHOS_EMULATOR to its image dir (see docs/harmonyos.md).",
                images.display()
            ));
        }
    }
    let qemu = "qemu-system-x86_64";
    if Command::new(qemu).arg("--version").output().is_err() {
        return Err(format!(
            "{qemu} not found — install QEMU (`brew install qemu`) to run the OpenHarmony emulator."
        ));
    }
    // Host hdc port from the connect key (guest hdc always listens on 55555). Kill any stale hdc
    // server first so it can't hold the host port before QEMU binds the forward.
    let target = ohos_target();
    let host_port = target.rsplit(':').next().unwrap_or("55555").to_string();
    let _ = Command::new(hdc_bin()).arg("kill").output();

    // The display backend: a native window locally (cocoa on macOS), none when headless.
    let display: &[&str] = if headless {
        &["-display", "none"]
    } else if cfg!(target_os = "macos") {
        &["-display", "cocoa"]
    } else {
        &["-display", "gtk"]
    };

    // Kernel cmdline + block devices are fixed for the Oniro x86_general image.
    let append = "ip=dhcp loglevel=4 console=ttyS0,115200 init=init root=/dev/ram0 rw \
                  ohos.boot.hardware=x86_general \
                  ohos.required_mount.system=/dev/block/vdb@/usr@ext4@ro,barrier=1@wait,required \
                  ohos.required_mount.vendor=/dev/block/vdc@/vendor@ext4@ro,barrier=1@wait,required \
                  ohos.required_mount.misc=/dev/block/vda@/misc@none@none=@wait,required";
    let hostfwd = format!("user,id=net0,hostfwd=tcp:127.0.0.1:{host_port}-:55555");
    let gpu = "virtio-gpu-pci,xres=360,yres=720,max_outputs=1,addr=08.0";

    let mut cmd = Command::new(qemu);
    cmd.current_dir(&images)
        .args([
            "-machine", "q35", "-smp", "6", "-m", "4096M", "-boot", "c", "-vga", "none",
        ])
        .args(["-device", gpu])
        .args(display)
        .args(["-rtc", "base=utc,clock=host", "-device", "es1370"])
        .args(["-initrd", "ramdisk.img", "-kernel", "bzImage"])
        .args([
            "-drive",
            "if=none,file=updater.img,format=raw,id=updater,index=0",
        ])
        .args(["-device", "virtio-blk-pci,drive=updater"])
        .args([
            "-drive",
            "if=none,file=system.img,format=raw,id=system,index=1",
        ])
        .args(["-device", "virtio-blk-pci,drive=system"])
        .args([
            "-drive",
            "if=none,file=vendor.img,format=raw,id=vendor,index=2",
        ])
        .args(["-device", "virtio-blk-pci,drive=vendor"])
        .args([
            "-drive",
            "if=none,file=userdata.img,format=raw,id=userdata,index=3",
        ])
        .args(["-device", "virtio-blk-pci,drive=userdata"])
        .args(["-serial", "none", "-append", append])
        .args(["-accel", "tcg,thread=multi", "-cpu", "max"])
        .args(["-netdev", &hostfwd, "-device", "virtio-net-pci,netdev=net0"]);
    status(
        "Emulator",
        &format!(
            "OpenHarmony ({}) — {}",
            images.display(),
            if headless { "headless" } else { "windowed" }
        ),
    );
    let mut child = cmd.spawn().map_err(|e| format!("qemu: {e}"))?;
    crate::signals::register_child(child.id());

    // Wait for hdc to see the target booted (TCG boot is slow), like `skip android emulator launch`.
    status("Emulator", "waiting for boot (TCG is slow — up to ~4 min)…");
    for _ in 0..48 {
        if let Some(code) = child.try_wait().ok().flatten() {
            return Err(format!("qemu exited early ({code})"));
        }
        let _ = Command::new(hdc_bin()).args(["tconn", &target]).output();
        let booted = Command::new(hdc_bin())
            .args([
                "-t",
                &target,
                "shell",
                "param",
                "get",
                "bootevent.boot.completed",
            ])
            .output()
            .ok()
            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
            .unwrap_or(false);
        if booted {
            status("Emulator", &format!("booted — hdc target {target}"));
            return Ok(());
        }
        std::thread::sleep(Duration::from_secs(5));
    }
    Err("emulator did not report boot within the timeout (still starting?)".into())
}

/// The hdc target key (`-t`) for the emulator/device. Oniro's QEMU emulator is a networked target
/// reachable at the emulator-action connect-key `127.0.0.1:55555`; override via `DAY_OHOS_TARGET`
/// (a real device's connect key, or a different port).
pub fn ohos_target() -> String {
    std::env::var("DAY_OHOS_TARGET").unwrap_or_else(|_| "127.0.0.1:55555".into())
}

/// The `hdc` executable: on PATH if present, else resolved from the SDK install's sibling
/// `toolchains/` dir (the public SDK ships it there, next to the `native` NDK) — so
/// `day launch -p ohos-arkui` works from GUI-launched editors whose environment has neither the
/// variable nor the PATH entry.
fn hdc_bin() -> &'static str {
    static HDC: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    HDC.get_or_init(|| {
        let on_path = std::env::var("PATH")
            .is_ok_and(|path| std::env::split_paths(&path).any(|d| d.join("hdc").is_file()));
        if on_path {
            return "hdc".into();
        }
        if let Ok(ndk) = find_ohos_ndk() {
            let cand = Path::new(&ndk).parent().map(|p| p.join("toolchains/hdc"));
            if let Some(c) = cand
                && c.is_file()
            {
                return c.to_string_lossy().into_owned();
            }
        }
        "hdc".into()
    })
}

/// A fresh `hdc` command targeting the default connect key (`DAY_OHOS_TARGET`).
pub fn hdc() -> Command {
    hdc_for(&ohos_target())
}

/// A fresh `hdc` command pinned to connect key `key` (`-t <key>`), for multi-device install/launch.
fn hdc_for(key: &str) -> Command {
    let mut c = Command::new(hdc_bin());
    c.args(["-t", key]);
    c
}

/// A connected OpenHarmony target: its `hdc` connect key + the arch it runs (queried via `uname -m`,
/// mapped to the Rust triple + hap ABI dir). An emulator is x86_64; a device is arm64 — we ask.
pub(crate) struct OhosDevice {
    pub key: String,
    pub triple: &'static str,
    pub abi: &'static str,
}

/// arch string from `uname -m` → (Rust triple, hap ABI dir).
fn arch_triple(uname: &str) -> Option<(&'static str, &'static str)> {
    match uname.trim() {
        "aarch64" | "arm64" => Some(("aarch64-unknown-linux-ohos", "arm64-v8a")),
        "x86_64" | "amd64" => Some(("x86_64-unknown-linux-ohos", "x86_64")),
        _ => None,
    }
}

/// Connected OHOS targets. `hdc list targets` lists USB/attached keys; the networked emulator is
/// reached via `DAY_OHOS_TARGET`, so that key is always included (after a best-effort `tconn`). Each
/// target's arch is queried with `uname -m`. Unreachable targets are dropped.
pub(crate) fn ohos_devices() -> Vec<OhosDevice> {
    let mut keys: Vec<String> = Vec::new();
    // The default/networked target: connect + include it.
    let default_key = ohos_target();
    let _ = Command::new(hdc_bin())
        .args(["tconn", &default_key])
        .output();
    keys.push(default_key);
    // Any additional attached targets.
    if let Ok(out) = Command::new(hdc_bin()).args(["list", "targets"]).output() {
        for line in String::from_utf8_lossy(&out.stdout).lines() {
            let k = line.trim();
            if !k.is_empty() && !k.starts_with('[') && !keys.iter().any(|e| e == k) {
                keys.push(k.to_string());
            }
        }
    }
    keys.into_iter()
        .filter_map(|key| {
            let uname = Command::new(hdc_bin())
                .args(["-t", &key, "shell", "uname", "-m"])
                .output()
                .ok()
                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
                .unwrap_or_default();
            let (triple, abi) = arch_triple(&uname)?;
            Some(OhosDevice { key, triple, abi })
        })
        .collect()
}

/// The (triple, abi) set to build for: the distinct arches of the connected targets, or — with none
/// reachable — the `DAY_OHOS_ARCH` override / emulator default, so `day build` still produces a hap.
fn ohos_build_arches() -> Vec<(&'static str, &'static str)> {
    let mut arches: Vec<(&'static str, &'static str)> = ohos_devices()
        .into_iter()
        .map(|d| (d.triple, d.abi))
        .collect();
    arches.sort();
    arches.dedup();
    if arches.is_empty() {
        arches.push(match std::env::var("DAY_OHOS_ARCH").ok().as_deref() {
            Some("device") | Some("arm64") | Some("arm64-v8a") => {
                ("aarch64-unknown-linux-ohos", "arm64-v8a")
            }
            _ => ("x86_64-unknown-linux-ohos", "x86_64"),
        });
    }
    arches
}

/// The OpenHarmony NDK (`native` dir) for the cross-linker: `OHOS_NDK_HOME` (set by CI's
/// setup-ohos-sdk) if present, else a couple of common local install paths (see docs/harmonyos.md:
/// extract the public SDK's `native` component). Validated by the presence of `llvm/bin`.
pub(crate) fn find_ohos_ndk() -> Result<String, String> {
    if let Ok(v) = std::env::var("OHOS_NDK_HOME") {
        return Ok(v);
    }
    let home = std::env::var("HOME").unwrap_or_default();
    for cand in [
        format!("{home}/ohos/ndk-extract/native"),
        format!("{home}/ohos-sdk/native"),
    ] {
        if Path::new(&cand).join("llvm/bin").is_dir() {
            return Ok(cand);
        }
    }
    Err(
        "OHOS_NDK_HOME is not set and no OpenHarmony NDK was found — set it to the SDK's `native` \
         directory (see docs/harmonyos.md)"
            .into(),
    )
}

pub fn build_ohos(
    project: &Project,
    target: &'static Target,
    profile: &str,
    start: std::time::Instant,
) -> Result<BuildOutcome, String> {
    let harmony = project.root.join("platform/ohos");
    if !harmony.join("build-profile.json5").exists() {
        return Err(format!(
            "ohos-arkui: no ArkTS host project at {} — a HarmonyOS app needs a `platform/ohos/` \
             project (the hvigor project + sign-hap.mjs), like apps/showcase/platform/ohos. See \
             docs/harmonyos.md.",
            harmony.display()
        ));
    }

    // 1) Cross-compile the app to a cdylib for EACH connected target's arch (an emulator is x86_64,
    //    a device arm64 — the hap carries both so it installs on either), staging each as
    //    entry/libs/<abi>/libentry.so — the .so the ArkTS host imports (its NAPI module is "entry").
    //    Uses the OHOS NDK cross-linker (OHOS_NDK_HOME) + a rustup toolchain (Homebrew rustc ships no
    //    OHOS std) and `feature_selection("arkui")` (the arkui toolkit feature + every standalone
    //    piece's `<pkg>/arkui` renderer feature, Tier A.2), exactly like the android/iOS legs.
    let ndk = find_ohos_ndk()?;
    let (cargo, bin) = rustup_cargo()?;
    let name = project.manifest.app.name.clone();
    for (triple, abi) in ohos_build_arches() {
        let target_dir = project
            .root
            .join("build/day/cargo/ohos-arkui")
            .join(abi)
            .join(profile);
        let linker_var = format!(
            "CARGO_TARGET_{}_LINKER",
            triple.to_uppercase().replace('-', "_")
        );
        status("Building", &format!("{} (cargo cdylib {abi})", target.name));
        let mut cmd = Command::new(&cargo);
        cmd.current_dir(&project.root)
            .env(
                "PATH",
                format!(
                    "{}:{}",
                    bin.display(),
                    std::env::var("PATH").unwrap_or_default()
                ),
            )
            .env("CARGO_TARGET_DIR", &target_dir)
            .env(&linker_var, format!("{ndk}/llvm/bin/{triple}-clang"))
            // day-arkui-sys's build.rs compiles the C++ shim with the NDK clang and reads this
            // variable itself — export the RESOLVED path so auto-detected local installs work even
            // when the parent environment (a GUI-launched editor) never set it.
            .env("OHOS_NDK_HOME", &ndk)
            .args([
                "rustc",
                "-p",
                &name,
                "--lib",
                "--crate-type",
                "cdylib",
                "--no-default-features",
                "--features",
                &crate::ops::feature_selection(project, "arkui"),
                "--target",
                triple,
            ]);
        if profile == "release" {
            cmd.arg("--release");
        }
        run_logged(&mut cmd, &format!("cargo (ohos {abi})"))?;
        // The cdylib is `lib<[lib].name>.so` (libentry.so for a crate whose `[lib] name = "entry"`,
        // else lib<crate>.so) — find the single produced .so and stage it AS libentry.so.
        let out_dir = target_dir.join(triple).join(profile);
        let so = std::fs::read_dir(&out_dir)
            .map_err(|e| format!("reading {}: {e}", out_dir.display()))?
            .flatten()
            .map(|e| e.path())
            .find(|p| p.extension().and_then(|x| x.to_str()) == Some("so"))
            .ok_or_else(|| format!("no cdylib .so produced in {}", out_dir.display()))?;
        let libs = harmony.join("entry/libs").join(abi);
        std::fs::create_dir_all(&libs).map_err(|e| format!("mkdir {}: {e}", libs.display()))?;
        std::fs::copy(&so, libs.join("libentry.so"))
            .map_err(|e| format!("stage libentry.so: {e}"))?;
        // libentry.so links the NDK's SHARED libc++ (the day-arkui-sys C++ shim), which OpenHarmony
        // does NOT provide on-device for apps — an unbundled hap dies at load with MUSL-LDSO's
        // "Error loading shared library libc++_shared.so". Stage it next to libentry.so so hvigor
        // packs it into the hap (the exact analogue of the Android jniLibs bundling). The NDK's
        // per-arch lib dir uses the CLANG triple (`x86_64-linux-ohos`), not the Rust triple — drop
        // the `unknown-` vendor field.
        let clang_triple = triple.replace("unknown-", "");
        let libcxx = PathBuf::from(&ndk)
            .join("llvm/lib")
            .join(&clang_triple)
            .join("libc++_shared.so");
        if libcxx.exists() {
            std::fs::copy(&libcxx, libs.join("libc++_shared.so"))
                .map_err(|e| format!("stage libc++_shared.so: {e}"))?;
        } else {
            status(
                "Warning",
                &format!(
                    "libc++_shared.so not found at {} — the hap may fail to load",
                    libcxx.display()
                ),
            );
        }
    }

    // 2) Assemble the .hap with hvigor (compiles the ArkTS host + packs the native libs + resources).
    //    hvigor + ohpm come from the OpenHarmony command-line-tools (on PATH); the SDK from
    //    OHOS_BASE_SDK_HOME. `ohpm install` is best-effort (the app has only a local dependency).
    status(
        "Building",
        &format!("{} (hvigorw assembleHap)", target.name),
    );
    let _ = Command::new("ohpm")
        .arg("install")
        .current_dir(&harmony)
        .status();

    let mode = if profile == "release" {
        "release"
    } else {
        "debug"
    };
    // A missing hvigor otherwise surfaces as a bare spawn ENOENT — check up front and say what to
    // install (it is NOT part of the public SDK; the `native` NDK alone only covers the Rust step).
    let hvigor_on_path = std::env::var("PATH")
        .is_ok_and(|p| std::env::split_paths(&p).any(|d| d.join("hvigorw").is_file()));
    if !hvigor_on_path {
        return Err(
            "hvigorw not found on PATH — the Rust cross-compile succeeded, but packaging the .hap \
             needs the OpenHarmony command-line-tools (hvigor + ohpm; bundled with DevEco Studio). \
             Install them and put their bin/ on PATH — see docs/harmonyos.md."
                .into(),
        );
    }
    let mut hv = Command::new("hvigorw");
    hv.current_dir(&harmony).args([
        "assembleHap",
        "--mode",
        "module",
        "-p",
        "product=default",
        "-p",
        &format!("buildMode={mode}"),
        "--no-daemon",
    ]);
    run_logged(&mut hv, "hvigorw assembleHap")?;

    // 3) Patch + sign the assembled (unsigned) .hap via platform/ohos/sign-hap.mjs: it rewrites module.json's
    //    compileSdkType to "OpenHarmony" (so the emulator skips code-sign verification — see the script)
    //    then signs with the OpenHarmony public release material.
    let hap = sign_hap(&harmony, &ndk)?;
    status("Built", &format!("{}{}", target.name, hap.display()));
    Ok(BuildOutcome {
        target: target.name,
        artifact: hap,
        seconds: start.elapsed().as_secs_f64(),
    })
}

/// The hvigor-built UNSIGNED hap of `project` (release re-signing input — pack/ohos.rs).
pub(crate) fn find_unsigned_hap(project: &crate::meta::Project) -> Option<PathBuf> {
    find_hap(&project.root.join("platform/ohos/entry/build"), |n| {
        n.contains("unsigned")
    })
}

/// Recursively find the first `*.hap` under `dir` whose file name satisfies `pred`.
fn find_hap(dir: &Path, pred: impl Fn(&str) -> bool) -> Option<PathBuf> {
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&d) else {
            continue;
        };
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                stack.push(p);
            } else if p.extension().and_then(|x| x.to_str()) == Some("hap") {
                let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
                if pred(name) {
                    return Some(p);
                }
            }
        }
    }
    None
}

/// Patch + sign the hvigor-built unsigned hap via the project's `sign-hap.mjs <unsigned> <signed>`
/// (Node — hvigor already requires it). The script rewrites module.json's compileSdkType to
/// "OpenHarmony" so the emulator skips code-sign verification (the public release cert's code
/// signature is otherwise rejected with 9568393), then signs with the SDK's release material.
fn sign_hap(harmony: &Path, ndk: &str) -> Result<PathBuf, String> {
    let build = harmony.join("entry/build");
    // hvigor emits `entry-<product>-unsigned.hap`; fall back to any hap.
    let unsigned = find_hap(&build, |n| n.contains("unsigned"))
        .or_else(|| find_hap(&build, |_| true))
        .ok_or_else(|| format!("no .hap produced under {}", build.display()))?;
    let sign = harmony.join("sign-hap.mjs");
    if !sign.exists() {
        // No patcher/signer — hand back the unsigned hap and let the install surface the rejection.
        return Ok(unsigned);
    }
    let signed = unsigned.with_file_name("day-signed.hap");
    status("Signing", &signed.display().to_string());
    let mut cmd = Command::new("node");
    cmd.arg(&sign)
        .arg(&unsigned)
        .arg(&signed)
        // The script locates the SDK signing material relative to the NDK (its findLib probes
        // OHOS_NDK_HOME first) — hand it the resolved path, like the cargo step.
        .env("OHOS_NDK_HOME", ndk)
        .current_dir(harmony);
    run_logged(&mut cmd, "sign-hap.mjs")?;
    Ok(signed)
}

/// Combined stdout+stderr of a finished command, as one string.
fn combined(out: &std::process::Output) -> String {
    format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    )
}

/// Is `bundle` installed on the target? `hdc install`/`bm install` can print `error: failed to
/// execute your command` yet still install (and yet exit 0), so verify the end state with
/// `bm dump -a` — the flat list of every installed bundle name — a clean membership test (unlike
/// `bm dump -n <bundle>`, whose per-bundle JSON can itself contain the words "error"/"failed").
fn bundle_installed_on(bundle: &str, key: &str) -> bool {
    hdc_for(key)
        .args(["shell", "bm", "dump", "-a"])
        .output()
        .map(|o| combined(&o).contains(bundle))
        .unwrap_or(false)
}

pub fn launch_ohos(
    project: &Project,
    outcome: &BuildOutcome,
    spec: &LaunchSpec,
) -> Result<std::thread::JoinHandle<i32>, String> {
    let bundle = project.manifest.app.id.clone();
    let devices = ohos_devices();
    if devices.is_empty() {
        return Err(format!(
            "no OpenHarmony target reachable (hdc). Boot an emulator (`day ohos emulator launch`) \
             or attach a device; the default connect key is {}.",
            ohos_target()
        ));
    }
    // The dayscript runner drives ONE target over the hdc-forwarded port — the default key — so a
    // scripted run stays deterministic even with several targets attached.
    let multi = devices.len() > 1;
    let mut log_threads = Vec::new();
    for dev in &devices {
        install_and_start(&bundle, &dev.key, outcome, spec)?;
        if spec.attached {
            let label = if multi {
                format!("{}:{}", outcome.target, dev.key)
            } else {
                outcome.target.to_string()
            };
            let key = dev.key.clone();
            log_threads.push(std::thread::spawn(move || stream_hilog(&key, &label)));
        }
    }
    Ok(std::thread::spawn(move || {
        let mut code = 0;
        for t in log_threads {
            if let Ok(c) = t.join()
                && c != 0
                && code == 0
            {
                code = c;
            }
        }
        code
    }))
}

/// Install (reinstall) + `aa start` the bundle on the target `key`, with the Oniro retry dances.
fn install_and_start(
    bundle: &str,
    key: &str,
    outcome: &BuildOutcome,
    spec: &LaunchSpec,
) -> Result<(), String> {
    // Keep the screen awake + in never-doze power mode so it doesn't re-lock mid-run (best-effort).
    let _ = hdc_for(key)
        .args(["shell", "power-shell", "wakeup"])
        .status();
    let _ = hdc_for(key)
        .args(["shell", "power-shell", "setmode", "602"])
        .status();

    // Install (reinstall over any existing copy), RETRYING: right after boot the bundle-manager
    // service may not accept installs yet, and `hdc install`'s exit code + its "error: failed to
    // execute your command" message are BOTH unreliable on Oniro (the app often installs anyway).
    // Gate on `bm dump -a` actually listing the bundle rather than on the install command's output.
    status("Installing", &format!("ohos-arkui ({bundle}) on {key}"));
    let mut install_log = String::new();
    let mut installed = false;
    for attempt in 1..=10u32 {
        if let Ok(out) = hdc_for(key)
            .args(["install", "-r"])
            .arg(&outcome.artifact)
            .output()
        {
            install_log = combined(&out);
        }
        if bundle_installed_on(bundle, key) {
            installed = true;
            break;
        }
        if attempt < 10 {
            let _ = hdc_for(key)
                .args(["shell", "power-shell", "wakeup"])
                .status();
            std::thread::sleep(Duration::from_secs(3));
        }
    }
    if !installed {
        return Err(format!(
            "hdc install: {bundle} not installed on {key} after 10 tries:\n{}",
            install_log.trim()
        ));
    }

    // The `aa start` args: the dayscript engine port/token + locale as `--ps` string parameters (all
    // shell-safe single tokens). EntryAbility.ets applies them to the process env (via the native
    // `setEnv`) before `start()` runs the engine — mirrors Android's intent extras.
    let mut args: Vec<String> = ["shell", "aa", "start", "-a", "EntryAbility", "-b", bundle]
        .iter()
        .map(|s| s.to_string())
        .collect();
    for (k, v) in &spec.envs {
        let param = match k.as_str() {
            "DAYSCRIPT_PORT" => "day.dayscript.port".to_string(),
            "DAYSCRIPT_TOKEN" => "day.dayscript.token".to_string(),
            other => format!("day.env.{other}"),
        };
        args.extend(["--ps".to_string(), param, v.clone()]);
    }
    if let Some(locale) = &spec.locale {
        args.extend(["--ps".to_string(), "day.locale".to_string(), locale.clone()]);
    }

    status("Launching", &format!("ohos-arkui ({bundle}) on {key}"));
    // The emulator boots with the keyguard up; it AUTO-DISMISSES a few seconds after boot but
    // `aa start` is refused until then (Error 10106102) — and there is NO hdc command to force-unlock
    // in developer mode (it is disabled by design). So retry, re-waking the screen between tries, until
    // it stops failing — the keyguard-readiness poll the Eclipse Oniro CI uses. `aa start` also EXITS 0
    // EVEN WHEN REFUSED, so we inspect its output for the failure markers.
    let mut last = String::new();
    for attempt in 1..=20u32 {
        let out = hdc_for(key)
            .args(&args)
            .output()
            .map_err(|e| format!("hdc aa start: {e}"))?;
        let text = combined(&out);
        if out.status.success()
            && !text.contains("Error Code:")
            && !text.to_lowercase().contains("failed to start")
        {
            return Ok(());
        }
        last = text;
        if attempt < 20 {
            let _ = hdc_for(key)
                .args(["shell", "power-shell", "wakeup"])
                .status();
            std::thread::sleep(Duration::from_secs(3));
        }
    }
    Err(format!(
        "hdc aa start refused on {key} after 20 tries (keyguard/launch):\n{}",
        last.trim()
    ))
}

/// Stream one target's hilog into the day log with `label` (best-effort). Returns its exit code.
fn stream_hilog(key: &str, label: &str) -> i32 {
    match hdc_for(key)
        .args(["shell", "hilog"])
        .stdout(Stdio::piped())
        .spawn()
    {
        Ok(mut child) => {
            crate::signals::register_child(child.id());
            if let Some(out) = child.stdout.take() {
                for line in
                    std::io::BufRead::lines(std::io::BufReader::new(out)).map_while(Result::ok)
                {
                    emit_log(label, LogStream::Out, &line);
                }
            }
            child.wait().map(|s| s.code().unwrap_or(0)).unwrap_or(0)
        }
        Err(e) => {
            emit_log(label, LogStream::Err, &format!("hdc hilog: {e}"));
            1
        }
    }
}