返回 CodeWhale
harmonyos.mjs
根目录 / crates / tui / plugins / computer-use / src / backends / harmonyos.mjs
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
333 lines Plain Text