| 1 | // HarmonyOS backend — drives a device/emulator through `hdc` (HarmonyOS |
| 2 | // Device Connector) plus the on-device `uitest` and `snapshot_display` tools. |
| 3 | // Observation: `uitest dumpLayout` (the accessibility-tree equivalent). |
| 4 | // Input: `uitest uiInput` (click / swipe / inputText / keyEvent). |
| 5 | // Stills: `snapshot_display`. Video: no CLI screen recorder exists on |
| 6 | // current HarmonyOS shells, so recording is an honest |
| 7 | // snapshot-series mode muxed with ffmpeg on the host. |
| 8 | import fs from "node:fs"; |
| 9 | import os from "node:os"; |
| 10 | import path from "node:path"; |
| 11 | import crypto from "node:crypto"; |
| 12 | import { run, runOk, ExecError, tryJson, have, currentSignal, throwIfAborted } from "../exec.mjs"; |
| 13 | |
| 14 | const DEVICE_TMP = "/data/local/tmp/cu"; |
| 15 | |
| 16 | function escDeviceText(s) { |
| 17 | if (/[^\x20-\x7E]/.test(s)) throw new ExecError("harmony uiInput text must be printable ASCII on this backend"); |
| 18 | return `'${String(s).replace(/'/g, `'\\''`)}'`; |
| 19 | } |
| 20 | |
| 21 | function jpegSize(buf) { |
| 22 | // Minimal JPEG SOF parser — enough to learn the panel size of a snapshot. |
| 23 | let i = 2; |
| 24 | while (i + 9 < buf.length) { |
| 25 | if (buf[i] !== 0xff) { i++; continue; } |
| 26 | const marker = buf[i + 1]; |
| 27 | if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { |
| 28 | return { h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) }; |
| 29 | } |
| 30 | i += 2 + buf.readUInt16BE(i + 2); |
| 31 | } |
| 32 | return null; |
| 33 | } |
| 34 | |
| 35 | export function parseBounds(b) { |
| 36 | const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(String(b ?? "")); |
| 37 | if (!m) return null; |
| 38 | const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; |
| 39 | return { x: x1, y: y1, w: x2 - x1, h: y2 - y1, cx: Math.round((x1 + x2) / 2), cy: Math.round((y1 + y2) / 2) }; |
| 40 | } |
| 41 | |
| 42 | export function flatten(node, pathArr = [], out = []) { |
| 43 | if (!node || out.length >= 600) return out; |
| 44 | const a = node.attributes ?? {}; |
| 45 | out.push({ |
| 46 | index: out.length, |
| 47 | path: pathArr, |
| 48 | role: a.type ?? "node", |
| 49 | label: a.text || a.id || a.description || a.name || null, |
| 50 | value: a.text ?? null, |
| 51 | bounds: parseBounds(a.bounds), |
| 52 | attributes: a, |
| 53 | actions: ["click", "longClick", "inputText"], |
| 54 | }); |
| 55 | for (let i = 0; i < (node.children?.length ?? 0); i++) flatten(node.children[i], [...pathArr, i], out); |
| 56 | return out; |
| 57 | } |
| 58 | |
| 59 | /** Coordinate clicks on this backend are always raw pointer events; strategy="a11y" must fail closed rather than silently degrade. */ |
| 60 | function assertEventStrategy(strategy) { |
| 61 | if (strategy != null && strategy !== "auto" && strategy !== "event") { |
| 62 | throw new ExecError(`strategy "${strategy}" is macOS-only; this backend dispatches coordinate clicks as raw pointer events — use an element target for a semantic action`); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | function rejectAppSelectors(args) { |
| 67 | if (["app_ref", "window_id", "windowIndex"].some(key => Object.hasOwn(args, key))) throw Object.assign(new ExecError("HarmonyOS cannot select an app or window for observation or element actions; explicit selectors are unsupported"), { code: "unsupported_selector" }); |
| 68 | } |
| 69 | |
| 70 | export function create({ exec }) { |
| 71 | const shell = (args, opts = {}) => exec.shell(args, { timeoutMs: 20_000, ...opts }); |
| 72 | |
| 73 | async function deviceOut(args, opts = {}) { |
| 74 | const r = await shell(args, opts); |
| 75 | if (r.code !== 0) throw new ExecError(`hdc shell ${args[0]} exited ${r.code}: ${(r.stderr || r.stdout).trim().slice(0, 300)}`, r); |
| 76 | return r.stdout; |
| 77 | } |
| 78 | |
| 79 | async function snapshot(localPath) { |
| 80 | const remote = `${DEVICE_TMP}-${crypto.randomBytes(3).toString("hex")}.jpeg`; |
| 81 | await deviceOut(["snapshot_display", "-f", remote], { timeoutMs: 25_000 }); |
| 82 | try { |
| 83 | await exec.pullFile(remote, localPath, { timeoutMs: 30_000 }); |
| 84 | } finally { |
| 85 | await shell(["rm", "-f", remote]).catch(() => {}); |
| 86 | } |
| 87 | return localPath; |
| 88 | } |
| 89 | |
| 90 | async function dumpLayout() { |
| 91 | const remote = `${DEVICE_TMP}-layout.json`; |
| 92 | await deviceOut(["uitest", "dumpLayout", "-p", remote], { timeoutMs: 40_000 }); |
| 93 | let data; |
| 94 | try { |
| 95 | data = await exec.readFile(remote, { timeoutMs: 30_000 }); |
| 96 | } finally { |
| 97 | await shell(["rm", "-f", remote]).catch(() => {}); |
| 98 | } |
| 99 | return JSON.parse(data.toString("utf8")); |
| 100 | } |
| 101 | |
| 102 | async function uiInput(args, opts = {}) { |
| 103 | await deviceOut(["uitest", "uiInput", ...args], opts); |
| 104 | return { action_sent: true, strategy: "event", backend: "uitest" }; |
| 105 | } |
| 106 | |
| 107 | async function centerOf(target) { |
| 108 | rejectAppSelectors(target); |
| 109 | const tree = await dumpLayout(); |
| 110 | const els = flatten(tree); |
| 111 | const el = els[target.index]; |
| 112 | if (!el || !el.bounds) throw new ExecError("element_stale — re-run get_app_state; uitest indexes change with the UI"); |
| 113 | return el.bounds; |
| 114 | } |
| 115 | |
| 116 | let frameSeq = 0; |
| 117 | let recording = null; // {id, dir, startedAt, intervalMs, timer, display} |
| 118 | let displayPixels = null; |
| 119 | |
| 120 | async function stopFrames() { |
| 121 | const rec = recording; |
| 122 | if (!rec) return null; |
| 123 | rec.stopped = true; |
| 124 | clearInterval(rec.timer); |
| 125 | rec.controller.abort(); |
| 126 | let timer; |
| 127 | try { |
| 128 | await Promise.race([rec.pending, new Promise((_, reject) => { |
| 129 | timer = setTimeout(() => reject(new ExecError("Harmony recording frame did not stop within 2 seconds")), 2_000); |
| 130 | })]); |
| 131 | } finally { clearTimeout(timer); } |
| 132 | recording = null; |
| 133 | return rec; |
| 134 | } |
| 135 | |
| 136 | return { |
| 137 | platform: "harmonyos", |
| 138 | // Session/route cleanup stops capture without a long mux or deleting |
| 139 | // unfinished frames. Failed cleanup retains the recorder for a retry. |
| 140 | closeSession: async () => { |
| 141 | const rec = await stopFrames(); |
| 142 | return rec ? { id: rec.id, frames: rec.seq, framesDir: rec.dir } : null; |
| 143 | }, |
| 144 | probe: async () => { |
| 145 | const r = await exec.run("hdc", [...exec.targetArgs, "list", "targets"], { timeoutMs: 10_000 }); |
| 146 | const targets = r.stdout.trim().split("\n").filter(Boolean); |
| 147 | const connected = r.code === 0 && targets.length > 0 && !targets.includes("[Empty]"); |
| 148 | return { |
| 149 | platform: "harmonyos", |
| 150 | connected, |
| 151 | targets, |
| 152 | capabilities: { screenshot: connected, accessibility_tree: connected, clipboard: false, recording: "snapshot-series" }, |
| 153 | note: "HarmonyOS drives the device over hdc. Clipboard read/write is not exposed by hdc and fails closed. Recording muxes snapshot_display frames with ffmpeg.", |
| 154 | }; |
| 155 | }, |
| 156 | list_displays: async () => { |
| 157 | if (!displayPixels) { |
| 158 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-hm-")); |
| 159 | try { |
| 160 | const shot = path.join(dir, "probe.jpeg"); |
| 161 | await snapshot(shot); |
| 162 | displayPixels = jpegSize(fs.readFileSync(shot)) ?? { w: null, h: null }; |
| 163 | } finally { fs.rmSync(dir, { recursive: true, force: true }).catch(() => {}); } |
| 164 | } |
| 165 | return [{ index: 1, name: "device", pixels: displayPixels, points: displayPixels, scale: 1, main: true }]; |
| 166 | }, |
| 167 | async switch_display({ index }) { |
| 168 | if (index !== 1) throw new ExecError("harmony backend exposes display 1 only"); |
| 169 | return { activeDisplay: 1 }; |
| 170 | }, |
| 171 | list_apps: async () => { |
| 172 | const out = await deviceOut(["bm", "dump", "-a"], { timeoutMs: 25_000 }); |
| 173 | const bundles = out.split("\n").map((s) => s.trim()).filter((s) => /^[a-zA-Z][\w.]*$/.test(s)); |
| 174 | return { apps: bundles.map((b) => ({ name: b, bundle_id: b, kind: "bundle" })) }; |
| 175 | }, |
| 176 | list_windows: async (args = {}) => { |
| 177 | rejectAppSelectors(args); |
| 178 | const out = await deviceOut(["hidumper", "-s", "WindowManagerService", "-a", "-a"], { timeoutMs: 25_000 }).catch(() => ""); |
| 179 | const windows = out.split("\n").filter((l) => /Window Name|bundleName/i.test(l)).slice(0, 40).map((l) => ({ title: l.trim().slice(0, 160) })); |
| 180 | return { windows: windows.length ? windows : [{ title: "(window list unavailable on this HarmonyOS build)" }] }; |
| 181 | }, |
| 182 | open_application: async ({ bundle_id: bid, ability, name } = {}) => { |
| 183 | const bundle = bid ?? name; |
| 184 | const identifier = (value) => typeof value === "string" && value.length <= 256 && /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(value); |
| 185 | if (!identifier(bundle)) throw new ExecError("open_application needs a valid Harmony bundle identifier"); |
| 186 | if (ability != null && !identifier(ability)) throw new ExecError("open_application needs a valid Harmony ability identifier"); |
| 187 | const candidates = ability != null ? [ability] : ["EntryAbility", "MainAbility"]; |
| 188 | let last = null; |
| 189 | for (const a of candidates) { |
| 190 | const r = await shell(["aa", "start", "-b", escDeviceText(bundle), "-a", escDeviceText(a)]); |
| 191 | if (r.code === 0 && !/Error|error/.test(r.stdout + r.stderr)) { |
| 192 | return { launched: true, bundle, ability: a }; |
| 193 | } |
| 194 | last = (r.stderr || r.stdout).trim().slice(0, 200); |
| 195 | } |
| 196 | throw new ExecError(`aa start failed: ${last}`); |
| 197 | }, |
| 198 | get_app_state: async (args = {}) => { |
| 199 | rejectAppSelectors(args); |
| 200 | const tree = await dumpLayout(); |
| 201 | const els = flatten(tree); |
| 202 | return { bundle_id: tree.attributes?.bundleName ?? null, elements: els, truncated: els.length >= 600 }; |
| 203 | }, |
| 204 | screenshot: async (args = {}) => { |
| 205 | rejectAppSelectors(args); |
| 206 | const { path: outPath } = args; |
| 207 | const dir = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"); |
| 208 | fs.mkdirSync(dir, { recursive: true }); |
| 209 | const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpeg`); |
| 210 | await snapshot(file); |
| 211 | const buf = fs.readFileSync(file); |
| 212 | displayPixels = jpegSize(buf) ?? displayPixels; |
| 213 | return { file, bytes: buf.length, pixels: jpegSize(buf), scale: 1, points: jpegSize(buf) }; |
| 214 | }, |
| 215 | zoom: async ({ region, path: outPath }) => { |
| 216 | throw new ExecError("zoom is not supported on the harmony backend yet — screenshot + region on the host is the workaround"); |
| 217 | }, |
| 218 | left_click: ({ target, strategy }) => { assertEventStrategy(strategy); return uiInput(["click", String(Math.round(target.x)), String(Math.round(target.y))]); }, |
| 219 | double_click: ({ target }) => uiInput(["doubleClick", String(Math.round(target.x)), String(Math.round(target.y))]), |
| 220 | triple_click: async ({ target }) => { |
| 221 | await uiInput(["doubleClick", String(Math.round(target.x)), String(Math.round(target.y))]); |
| 222 | return uiInput(["click", String(Math.round(target.x)), String(Math.round(target.y))]); |
| 223 | }, |
| 224 | right_click: async () => { throw new ExecError("uitest uiInput has no right-click; use longClick semantics via hold or left_click"); }, |
| 225 | middle_click: async () => { throw new ExecError("middle click is not exposed by uitest uiInput"); }, |
| 226 | mouse_move: async () => ({ action_sent: false, note: "hover without press is not exposed by uitest uiInput" }), |
| 227 | left_click_drag: ({ from_target: from, to }) => |
| 228 | uiInput(["swipe", String(Math.round(from.x)), String(Math.round(from.y)), String(Math.round(to.x)), String(Math.round(to.y)), "200"], { timeoutMs: 30_000 }), |
| 229 | left_mouse_down: async () => { throw new ExecError("low-level press/release is not exposed by uitest uiInput; use left_click_drag"); }, |
| 230 | left_mouse_up: async () => { throw new ExecError("low-level press/release is not exposed by uitest uiInput; use left_click_drag"); }, |
| 231 | scroll: ({ target, direction = "down", amount = 300 }) => { |
| 232 | const dist = Math.max(60, Math.min(1200, amount * 24)); |
| 233 | const dx = direction === "left" ? dist : direction === "right" ? -dist : 0; |
| 234 | const dy = direction === "up" ? dist : direction === "down" ? -dist : 0; |
| 235 | return uiInput(["swipe", String(Math.round(target.x)), String(Math.round(target.y)), String(Math.round(target.x + dx)), String(Math.round(target.y + dy)), "400"]); |
| 236 | }, |
| 237 | type: async ({ text }) => { |
| 238 | if (!text) return { action_sent: false, note: "empty text" }; |
| 239 | await uiInput(["inputText", "300", "300", escDeviceText(text)]).catch(async (e) => { |
| 240 | // Some builds require coordinates of the focused field; retry with a click-first pattern. |
| 241 | throw e; |
| 242 | }); |
| 243 | return { action_sent: true, chars: text.length, note: "inputText at 300,300 — click the field first for focused input" }; |
| 244 | }, |
| 245 | key: ({ text }) => { |
| 246 | const KEYMAP = { enter: "Enter", return: "Enter", escape: "Esc", esc: "Esc", back: "Back", home: "Home", backspace: "Back", delete: "Del", tab: "Tab", left: "DPAD_LEFT", right: "DPAD_RIGHT", up: "DPAD_UP", down: "DPAD_DOWN", power: "Power", menu: "Menu" }; |
| 247 | const k = KEYMAP[String(text).toLowerCase()] ?? String(text); |
| 248 | if (!/^[A-Za-z0-9_]+$/.test(k)) throw new ExecError(`unsupported key "${text}" on harmony backend`); |
| 249 | return uiInput(["keyEvent", k]); |
| 250 | }, |
| 251 | hold_key: async ({ text, duration }) => { |
| 252 | if (String(text).toLowerCase() !== "click") throw new ExecError('hold_key on harmony supports only hold_key({"text":"click"}) = longClick'); |
| 253 | const d = Math.max(1, Math.min(5, Number(duration) || 1)); |
| 254 | return uiInput(["longClick", "300", "300"]); |
| 255 | }, |
| 256 | set_value: async ({ target, value }) => { |
| 257 | const b = await centerOf(target); |
| 258 | await uiInput(["click", String(b.cx), String(b.cy)]); |
| 259 | await new Promise((r) => setTimeout(r, 300)); |
| 260 | await uiInput(["inputText", String(b.cx), String(b.cy), escDeviceText(String(value))]); |
| 261 | return { action_sent: true, strategy: "uitest-element" }; |
| 262 | }, |
| 263 | select_text: async () => { throw new ExecError("select_text is not exposed by uitest dumpLayout/uiInput on the harmony backend"); }, |
| 264 | perform_action: async ({ target, action }) => { |
| 265 | const b = await centerOf(target); |
| 266 | if (action === "longClick") return uiInput(["longClick", String(b.cx), String(b.cy)]); |
| 267 | return uiInput(["click", String(b.cx), String(b.cy)]); |
| 268 | }, |
| 269 | read_clipboard: async () => { throw new ExecError("clipboard read is not exposed by hdc on current HarmonyOS builds"); }, |
| 270 | write_clipboard: async () => { throw new ExecError("clipboard write is not exposed by hdc on current HarmonyOS builds"); }, |
| 271 | cursor_position: async () => { throw new ExecError("cursor position does not exist on touch devices"); }, |
| 272 | recordingStart: async ({ intervalMs = 400 } = {}) => { |
| 273 | if (recording) throw new ExecError(`recording ${recording.id} already running`); |
| 274 | if (!(await have("ffmpeg"))) throw new ExecError("ffmpeg is required on the host to mux harmony snapshot-series recordings"); |
| 275 | const id = crypto.randomBytes(4).toString("hex"); |
| 276 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cu-rec-${id}-`)); |
| 277 | const startedAt = new Date().toISOString(); |
| 278 | const rec = recording = { id, dir, startedAt, intervalMs, seq: 0, controller: new AbortController() }; |
| 279 | const tick = () => { |
| 280 | if (rec.stopped || rec.pending) return rec.pending; |
| 281 | rec.pending = (async () => { |
| 282 | const opts = { timeoutMs: 15_000, signal: rec.controller.signal }; |
| 283 | const remote = `${DEVICE_TMP}-rec-${id}-${String(rec.seq).padStart(5, "0")}.jpeg`; |
| 284 | try { |
| 285 | await deviceOut(["snapshot_display", "-f", remote], opts); |
| 286 | await exec.pullFile(remote, path.join(dir, `f${String(rec.seq).padStart(5, "0")}.jpeg`), opts); |
| 287 | rec.seq++; |
| 288 | } finally { await shell(["rm", "-f", remote], opts).catch(() => {}); } |
| 289 | })().catch(() => {}).finally(() => { rec.pending = null; }); |
| 290 | return rec.pending; |
| 291 | }; |
| 292 | const signal = currentSignal(); |
| 293 | const abort = () => rec.controller.abort(); |
| 294 | signal?.addEventListener("abort", abort, { once: true }); |
| 295 | try { |
| 296 | throwIfAborted(signal); |
| 297 | await tick(); |
| 298 | throwIfAborted(signal); |
| 299 | if (rec.stopped) throw new ExecError("Harmony recording closed during startup"); |
| 300 | rec.timer = setInterval(tick, Math.max(150, intervalMs)); |
| 301 | } catch (err) { await stopFrames(); throw err; } |
| 302 | finally { signal?.removeEventListener("abort", abort); } |
| 303 | return { id, mode: "snapshot-series", intervalMs, startedAt, note: "no HarmonyOS CLI screen recorder; frames are muxed into mp4 on stop" }; |
| 304 | }, |
| 305 | recordingStop: async ({ id }) => { |
| 306 | if (!recording || recording.id !== id) throw new ExecError(`unknown recording "${id}"`); |
| 307 | const { dir, seq, startedAt, intervalMs } = await stopFrames(); |
| 308 | const dirOut = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"); |
| 309 | fs.mkdirSync(dirOut, { recursive: true }); |
| 310 | const out = path.join(dirOut, `rec-${id}.mp4`); |
| 311 | const fps = Math.max(1, Math.min(15, Math.round(1000 / Math.max(150, intervalMs)))); |
| 312 | const r = await run("ffmpeg", ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.jpeg"), "-c:v", "libx264", "-pix_fmt", "yuv420p", out], { timeoutMs: 180_000 }); |
| 313 | const bytes = fs.existsSync(out) ? fs.statSync(out).size : 0; |
| 314 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} |
| 315 | if (r.code !== 0) throw new ExecError(`ffmpeg mux failed: ${(r.stderr || "").slice(0, 300)}`, r); |
| 316 | return { id, mode: "snapshot-series", frames: seq, fps, file: out, bytes, startedAt, stoppedAt: new Date().toISOString() }; |
| 317 | }, |
| 318 | recordingStatus: ({ id }) => recording && recording.id === id |
| 319 | ? { id, running: true, mode: "snapshot-series", frames: recording.seq, startedAt: recording.startedAt } |
| 320 | : { id, running: false }, |
| 321 | recordingList: async () => { |
| 322 | const dir = process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"); |
| 323 | const out = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(mp4|mov|jpeg|png)$/i.test(f)).map((f) => { |
| 324 | const st = fs.statSync(path.join(dir, f)); |
| 325 | return { file: path.join(dir, f), bytes: st.size, modifiedAt: st.mtime.toISOString() }; |
| 326 | }).sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)).slice(0, 50) : []; |
| 327 | return { dir, recordings: out, running: recording ? [recording.id] : [] }; |
| 328 | }, |
| 329 | }; |
| 330 | } |
| 331 | |
| 332 | export default { create }; |
| 333 |