Files
MailUI4Agents/client/electron/test/lib/harmony-device.mjs
JianFeeeee 18b148e476 跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态
两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。

## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件

`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
  · mailStore.sent / currentMail
  · sessionStore.sessions / currentSession / currentSessionMails
  · contactStore.contacts / archivedContacts

⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。

新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:

    ① 主动切账号(AccountSwitcher.pick)
    ② 登出(authStore.logout)
    ③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)

只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**

★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
  (它还要复位窄屏分栏、写信态那些纯界面状态)。

判据:`test/stores/resetAccountData.test.ts`(4 格)。

## ② 鸿蒙管理台门禁**失败开放**(fail-open)

`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。

讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。

⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
  否则管理台。

服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
2026-09-28 08:46:01 +08:00

677 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 行为判据的设备侧 harness(pi 2026-09-18 到期闸 → (a) 升级用)。
//
// 为什么单独成模块:到期判据要"真跑一遍 / 真点一次",但 7 个文件各自去找 hdc、
// 各自解析 dumpLayout 会复制 7 份同样脆弱的代码 —— 正是"同一份代码两种调用法
// 两个结论"那个形状长在判据自己身上。这里把"设备在不在 / 应用在不在前台 /
// 真 UI 树 / 点一下"收敛成一组可测函数,判据只负责下断言。
//
// 边界(写在这里,因为它决定行为判据怎么跑):
// ① "本工作区能装能点" ≠ "前台是我的":模拟器是共享的,别的会话可能正拿它
// 做 GUI 联调(dumpLayout 里那张表真的能看到 "GUI 联调专用" 标签)。
// 所以本模块**只读不抢** —— 应用不在前台就不点、不启动,由调用方决定
// 是跳过(计数、不静默绿)还是只对"当前在前的那个应用"做只读断言。
// ② dumpLayout 是只读的,但行为判据断的是"我们的应用",所以调用方必须先
// 问 foregroundBundle() 再决定 dump 出来的树要不要拿来断言。
// ③ 这套和 run-all.mjs 里的 PROBES.device 不是同一件事:那条是**到期闸的
// 探针**(三值:可用 / 不可用 / 拿不准→红),决定"判据该不该到期";
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
import { spawnSync } from 'node:child_process';
import { existsSync, statSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bytes } from './read.mjs';
// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。
// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。
// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了
// (报 `test/lib/harmony-device.mjs:57`)—— 那条判据是对的,错的是我这段代码。
// 顺带说明那条判据的形状为什么对:它扫的是"**能不能换用更准的入口**",
// 而我当时的理由是"这是运行时状态、不是仓库源码"——那理由不成立,
// `prose()` 读的就是任意文本,跟文件性质无关。
import { prose } from './read.mjs';
const TOOLCHAIN_HDC = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc';
/**
* 我们这个应用的 bundleName —— **从唯一权威处读**(`AppScope/app.json5`),不在这里写第二份。
*
* ★ 2026-09-18 实测事故:包名从 `com.agentmail.harmony` 改成 `com.jianf.agentmail`
* (AGC 拒绝 `harmony` 作保留字,见 `align-refs.test` 与 `docs/ALIGN-REFS.json` 的
* `agc.packageName`),而 `harmony-nav.test.mjs` 里的比较**仍是旧字面量** ⇒
* 前台判定永远不成立 ⇒ 那条行为判据**永远走"设备忙"跳过"**,账本一路数到 **42 轮**。
*
* 这正是 `noteBusySkip` 的设界要抓的形状:判据既不算红也不算绿 ⇒ 永远不必被升级。
* 而它抓对了 —— 那 42 轮根本不是"设备被占",是**两处字面量漂移**。
*
* 所以把包名收敛成一个来源:改包名只需改 `app.json5`,消费方自动跟上。
* 另一处硬编码在 `harmony-deviceprobe.test.mjs`(那条判的是 AGC 匹配,用同一来源即可)。
*
* 惰性求值(函数而非模块级常量):读盘失败时返回 null 由调用方决定怎么办,
* 且不把"import 期读文件"变成每个判据的隐式失败点。
*/
export function ourBundle() {
try {
const app = prose(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..',
'harmony', 'AppScope', 'app.json5'));
const m = /"bundleName"\s*:\s*"([^"]+)"/.exec(app);
return m ? m[1] : null;
} catch {
return null;
}
}
/*
* ─── 有界的"不抢前台"(pi 2026-09-18 §3)───
*
* 问题:本模块按"应用不在前台就跳过"设计(别人的会话在用这台模拟器)。
* 但**跳过必须有界** —— 如果设备一直被占着,那 7 条到期判据会永远停在
* "既不绿也不红",这恰恰是到期机制要防的东西("不等谁想起来")。
* "不抢前台"一旦变成永久状态,就等于给到期判据开了一个**永久灰区**:
* 它们不算红、不算绿,也就永远不需要被升级 —— "判据存在但永远不会响"的又一个变体,
* 只是这次入口是"设备忙"。
*
* ⇒ 连续跳过 K 轮之后,**跳过自己变红**(报"设备连续被占,本次仍未验证")。
* K 轮之内是礼貌(不抢别人),K 轮之外是闹钟(到期机制不许静默)。
*
* 账本落在 `.tmp/`(已在 .gitignore 里):它是**本机状态**,不是仓库内容 ——
* 换一台机器/清掉 .tmp 就等于"重新开始数",这是对的:判的是"**这台机器上**
* 连续多少轮没验成",换机器不继承。
*
* ★ 只对"设备在、但前台不是我们的"计数(busy)。**设备不在**不计数、也不变红 ——
* 那是 run-all.mjs 里 PROBES.device 的既有裁定("没装 SDK 的机器不该天天假红"),
* 超出本模块的职责:探针已经决定"不到期",行为部分自然不该自己变红。
*
* ⚠️ 账本路径与上限都**惰性读 env**(不是 import 时定死):否则判据自检没法验
* "超限会不会红" —— 测不了的边界等于没写(本仓纪律)。与 run-all.mjs 里
* `AGENTMAIL_PROBE_DEVICE` 覆盖同一个理由。
*/
const ledgerPath = () => process.env.AGENTMAIL_BUSY_LEDGER
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '.tmp', 'harmony-busy-skips.json');
const busyLimitOf = () => Number(process.env.AGENTMAIL_BUSY_SKIP_LIMIT || 3);
function readLedger() {
try {
const d = JSON.parse(prose(ledgerPath()));
return (d && typeof d === 'object') ? d : {};
} catch {
return {}; // 没有/坏了都当"从 0 开始数",不是错误
}
}
function writeLedger(d) {
try {
mkdirSync(dirname(ledgerPath()), { recursive: true });
writeFileSync(ledgerPath(), JSON.stringify(d, null, 2));
} catch { /* 写不了账本不该让判据崩:退化成"永远不超限",由下面的返回值明说 */ }
}
/**
* 记一次"设备忙,跳过",返回 `{ streak, over }`。
*
* `over === true` ⇒ 连续跳过已超 K 轮,调用方**必须变红**(不再是礼貌跳过)。
* 调 `noteRan()` 清零(真跑成了就不该再记前账)。
*/
export function noteBusySkip(criterion) {
const d = readLedger();
const n = (d[criterion] || 0) + 1;
d[criterion] = n;
writeLedger(d);
return { streak: n, over: n > busyLimitOf() };
}
/** 该判据真的跑成了 ⇒ 连续计数清零。 */
export function noteRan(criterion) {
const d = readLedger();
if (d[criterion]) { delete d[criterion]; writeLedger(d); }
}
/** 只读查询当前连续跳过轮数(判据自检用)。 */
export function busyStreak(criterion) {
return readLedger()[criterion] || 0;
}
/** 供报文用:本机繁忙上限 K。 */
export const busyLimit = () => busyLimitOf();
/*
* ─── "本机真的跑过行为层"的记录(pi 2026-09-18 闸 (ii) 的证据)───
*
* 问题:把一条判据从 `STATIC_ONLY` **移除**(7→6)现在是个纯**记账动作** ——
* 它只是从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求行为条真的执行过**。
* ⇒ 一条判据可以在"已升级"的名义下**永远不跑**。
*
* pi 用变异证明了后果:设备不在时注入一条真实回归,红清单与基线**逐条一致**(零痕迹)。
* 于是 `static=6` 这个余额读起来像"又清了一条",实际可能是"又一条进了永久 skip"。
* 与上面那条同源:**余额里读不出来的东西等于不存在**。
*
* ⇒ 行为条**真绿一次**就在这里留一条记录;`run-all` 拿它当"结算(移出 STATIC_ONLY)"
* 的前提。记录是**本机的**(`.tmp/`,已 gitignore)—— 判的是"**这台机器上**验过",
* 换机器不继承,这正是对的:换一台没设备的机器,"验过"不该跟着走。
*/
const ranPath = () => process.env.AGENTMAIL_BEHAVIORAL_RAN
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '.tmp', 'harmony-behavioral-ran.json');
function readRan() {
try {
const d = JSON.parse(prose(ranPath()));
return (d && typeof d === 'object') ? d : {};
} catch {
return {};
}
}
/**
* 行为条**真的跑绿了**(断言全过)⇒ 记一笔。
* ⚠️ 只在**断言之后**调用:红了也记,就等于"把没验过的当成验过了",比不记更糟。
*/
export function noteBehavioralRan(criterion) {
const d = readRan();
d[criterion] = new Date().toISOString();
try {
mkdirSync(dirname(ranPath()), { recursive: true });
writeFileSync(ranPath(), JSON.stringify(d, null, 2));
} catch { /* 记不下不该让判据崩;run-all 那边把"没记录"当未升级(保守方向) */ }
}
/** 本机有没有"这条行为层真跑过"的记录?返回时间戳或 null。 */
export function behavioralRanAt(criterion) {
return readRan()[criterion] || null;
}
/**
* 找到能跑的 hdc 二进制。候选顺序:SDK 文档安装根下的 hdc,再 PATH 上的 hdc。
* 返回**跑通了 `list targets`** 的那条(status 0);都不行返回 null。
*
* 注意:这里只判"能跑",不判"有没有目标"——有没有目标由 hasTarget() 说,
* 能不能装 / 能不能点由调用方在真跑时知道。把"能跑"和"有目标"拆开,是因为
* "hdc 在但没目标"和"hdc 不在"对调用方是两件不同的事(前者可以等,后者得装)。
*/
export function findHdc() {
// 判据自检用:`AGENTMAIL_HARMONY_DEVICE=none` 模拟"本机没有设备",
// 让行为判据的"设备不在 → 显式跳过"那半边也能被验到(与 run-all.mjs 里
// PROBES.device 的 AGENTMAIL_PROBE_DEVICE 覆盖是同一套纪律:不可测的分支不算数)。
if (process.env.AGENTMAIL_HARMONY_DEVICE === 'none') return null;
for (const bin of [TOOLCHAIN_HDC, 'hdc']) {
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
if (r.error && r.error.code === 'ENOENT') continue; // 这条不在,换下一条
if (r.error) continue; // 跑不成(超时/异常),换下一条
if (r.status === 0) return bin;
}
return null;
}
/** 在 hdc 上跑一条命令,带回 spawnSync 结果。 */
/**
* 选定目标设备的连接键(`AGENTMAIL_HARMONY_TARGET`,如 `192.168.2.108:43679`)。
*
* ★★ 为什么需要它(2026-09-19 装真平板时撞出来的):
* 原来所有助手都**不带 `-t`** ⇒ 一旦同时存在两个目标
* (模拟器 `127.0.0.1:5555` + 真平板 `192.168.2.108:43679`),
* `hdc` 会挑一个 —— 于是设备判据可能在**另一台设备**上跑,
* 而它照样报绿("跑错设备"与"跑对"从输出上看不出来)。
*
* 这与本仓那条"判据的边界要显式"是同一条:#一台设备#是环境事实,
* 不能靠"当前只有一台"这种偶然。
*
* 用法:`AGENTMAIL_HARMONY_TARGET=192.168.2.108:43679 node run-all.mjs`
* 不设时行为与从前完全一致(多目标时由 hdc 自己挑)。
*/
export function targetKey() {
const k = (process.env.AGENTMAIL_HARMONY_TARGET || '').trim();
return k.length > 0 ? k : null;
}
function sh(hdc, args, timeout = 20000) {
/*
* 注入 `-t <key>`:放在 `hdc` 之后、子命令之前(hdc 的参数序)。
* 已经被调用方显式带 `-t` 时不重复注入。
*/
const key = targetKey();
const argv = key && !args.includes('-t') ? ['-t', key, ...args] : args;
return spawnSync(hdc, argv, { encoding: 'utf8', timeout });
}
/** `hdc list targets` 的输出(trim 后)。null 表示 hdc 都没找到。 */
export function listTargets(hdc) {
if (!hdc) return null;
const r = sh(hdc, ['list', 'targets']);
return (r.stdout || '').trim();
}
/** 有没有 hdc 目标(输出非空且不是 `[Empty]`)。 */
export function hasTarget(hdc) {
const t = listTargets(hdc);
if (t === null) return false;
return t.length > 0 && !/\[Empty\]/.test(t);
}
/**
* 当前前台应用的 bundle name。
*
* `aa dump -a` 的输出是分块的(按 `AbilityRecord ID` 切),每块是一个 ability。
* 前台的是 `ability type [PAGE]` 且 `state #FOREGROUND` 的那个 —— 取它的
* `bundle name [X]`。这样跳过 SERVICE 型 ability(它们 state 可能是 ACTIVE
* 但不是用户看到的前台页面)。没找到返回 null(可能设备在但没人前台、
* 或 aa dump 跑不成)。
*/
export function foregroundBundle(hdc) {
if (!hdc) return null;
const r = sh(hdc, ['shell', 'aa', 'dump', '-a'], 20000);
if (r.status !== 0) return null;
const out = r.stdout || '';
const blocks = out.split(/AbilityRecord ID/);
for (let i = 1; i < blocks.length; i++) {
const b = blocks[i];
if (/ability type \[PAGE\]/.test(b) && /state #FOREGROUND/.test(b)) {
const m = b.match(/bundle name \[(.+?)\]/);
if (m) return m[1];
}
}
return null;
}
/**
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
*
* ★★ 2026-09-26:**必须显式给输出路径**。
*
* 原来跑的是裸 `uitest dumpLayout`(不带 `-p`),期望它自己在 stdout 打一行
* `DumpLayout saved to:…`。在 HarmonyPhone(HarmonyOS 6.1.1)上实测:
* 裸调用 → `DumpLayout failed:Wait for subscribe uitest.broadcast.command.reply timeout`
* (非 0 退出、stdout 空 ⇒ 这条判据直接**报错**而不是跳过)
* 加 `-p` → `DumpLayout saved to:/data/local/tmp/d.json`(正常)
* ⇒ 裸调用依赖的是**自动选名**那条路径,它要等 broadcast 回复;
* 给了确定路径就不用等。
*
* 这不是"设备不在"那种环境缺失,而是**判据自己跑不成** —— 所以必须修,
* 否则日历手势那类判据在真机上会一直红。
*
* 路径自己定 ⇒ 不再从 stdout 反解,也不存在"和别的会话的 dump 抢文件"。
*
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
*/
export function dumpLayout(hdc) {
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
// 每次换一个名字:同一路径反复写会让并发跑的两个判据互相读到对方的中间态。
const path = `/data/local/tmp/dsh_dump_${process.pid}_${Date.now()}.json`;
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout', '-p', path], 45000);
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
const cat = sh(hdc, ['shell', 'cat', path], 20000);
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
// 设备上的临时文件不留(几十次判据跑下来 /data/local/tmp 会攒一堆)。
sh(hdc, ['shell', 'rm', '-f', path], 10000);
return JSON.parse(cat.stdout);
}
/** 深度优先遍历 UI 树,yield 每个节点(含根)。 */
export function* walk(node) {
yield node;
for (const c of node.children || []) yield* walk(c);
}
/** 找 text/originalText 等于给定文本的节点(返回数组,可能空)。 */
export function findByText(root, text) {
const out = [];
for (const n of walk(root)) {
const a = n.attributes || {};
if (a.text === text || a.originalText === text) out.push(n);
}
return out;
}
/** 找 type 等于给定类型的所有节点。 */
export function findByType(root, type) {
const out = [];
for (const n of walk(root)) {
const a = n.attributes || {};
if (a.type === type) out.push(n);
}
return out;
}
/**
* 把 `"[x1,y1][x2,y2]"` 解析成中心点 `{cx, cy}`。bounds 不是这个形状返回 null。
* 中心点用于 `uitest uiInput click`(点击坐标)。
*/
export function boundsCenter(bounds) {
const m = (bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!m) return null;
return { cx: Math.round((+m[1] + +m[3]) / 2), cy: Math.round((+m[2] + +m[4]) / 2) };
}
/**
* 在 (x, y) 上点一下(`uitest uiInput click`)。返回 true 表示"No Error"。
*
* **这是写操作** —— 它改变前台应用的状态。按本模块的边界①,调用方有责任先
* 确认前台是自己的应用(`foregroundBundle(hdc) === ourBundle()`)再点,
* 否则会点到别人的会话正在用的界面上。
*/
export function tap(hdc, x, y) {
if (!hdc) return false;
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'click', String(x), String(y)], 15000);
return (r.stdout || '').includes('No Error');
}
/**
* 从 (x1,y1) 滑到 (x2,y2),`velocity` 单位 px/s(`uitest` 合法范围 200~40000)。
* 返回 true 表示 "No Error"。
*
* ★ `velocity` **必须落在合法范围内**:我第一版传了 150(想表达"慢一点"),
* `uitest` 只回一句 `The swipe velocity out of range, the default value will be used.`
* —— 它**不报错、不改退出码**,只是默默换成默认 600,于是"慢滑"变成"更慢的滑",
* 看起来像手势没生效。这类"参数被静默替换"的坑,只能靠**传合法值 + 读回执**避免。
*
* 同样**这是写操作**(改变前台应用状态):调用方先确认前台是自己的应用再滑。
*/
export function swipe(hdc, x1, y1, x2, y2, velocity = 5000) {
if (!hdc) return false;
const v = Math.min(40000, Math.max(200, Math.round(velocity)));
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'swipe',
String(x1), String(y1), String(x2), String(y2), String(v)], 20000);
return (r.stdout || '').includes('No Error');
}
/** 我们的应用是否在**前台且已就绪**(有可点的东西)。 */
export function ourAppInFront(hdc) {
return Boolean(hdc) && hasTarget(hdc) && foregroundBundle(hdc) === ourBundle();
}
/**
* 拉起我们的应用(`aa start`),然后等它真的到前台。
*
* **这是写操作** —— 它会切走当前前台应用。只在"本判据需要自己的应用在前台"
* 时调用,并且调用方要能接受"别人的界面被切走"。
*
* ★ 等待要**轮询**(前台 BundleName 变成我们的),不能睡固定时长:
* 冷启动在不同负载下能从 3 秒到 15 秒不等,写死一个数必然在忙时假红
* (手势判据那条就是被固定 2500ms 坑过 —— 单跑通过、接进套件失败)。
*/
export async function launchOurApp(hdc, { settle = 400, tries = 40 } = {}) {
if (!hdc) return false;
sh(hdc, ['shell', 'aa', 'start', '-a', 'EntryAbility', '-b', ourBundle()], 20000);
for (let i = 0; i < tries; i++) {
await new Promise((r) => setTimeout(r, settle));
if (foregroundBundle(hdc) === ourBundle()) return true;
}
return false;
}
/**
* 点一个**文本完全匹配**的节点,返回是否点到。
*
* 比裸 `tap(hdc,x,y)` 好在:坐标从当前 dump 里算,不写死 pixel。
* (写死坐标的判据在密度/折叠态变化时静默点到别处 —— 本仓吃过这类亏。)
*/
export function tapText(hdc, text) {
if (!hdc) return false;
const root = dumpLayout(hdc);
/*
* ★★ 2026-09-19 修(真 bug,而且是我自己写的帮手):
* `findByText` 返回的是**数组**(同名节点可能多个),而我当单个节点用了
* ⇒ `node.attributes` 恒为 `undefined` ⇒ 这个帮手**从来没成功点过任何东西**。
*
* 它造成的症状极其隐蔽:`tapText(...)` 返回 `false`,调用方以为
* "没找到那个文案"——而实际是**帮手自己坏了**。用它的判据(appearance/admin)
* 都会表现为"找不到入口"或"点不到",看起来像功能缺失。
*
* 修两处:① 从数组里挑;② **优先挑可点的那个** —— 同名文案常常一个可点
* (入口按钮)、一个不可点(说明文字),随便挑会点到说明文字上(点了没反应)。
*/
const nodes = findByText(root, text);
if (nodes.length === 0) return false;
const target = nodes.find((n) => n.attributes?.clickable === 'true') || nodes[0];
/*
* ★★ 2026-09-19 补第三层:文字节点本身常常**不可点**,点击挂在它**祖先**上。
*
* 实测(「我的」页的管理入口):`Text('管理')` 的 `clickable=false`,
* 而 `.onClick` 写在包住它的那层 `Row` 上 —— 这是 ArkUI 的常态
* (文字只是内容,可点的是容器)。直接点文字节点的中心**也能触到**
* (命中测试会冒泡到祖先),但那样判据就依赖"冒泡行为"而不是"我点到了那个入口"。
*
* 所以这里显式用**祖先的可点容器**的边界:找不到可点祖先时才退回文字节点自己。
* 当前实现从目标节点的 bounds 出发做一次"就近扩展":
* 若目标不可点,就用**同文本的可点兄弟/祖先链**(dump 树里 clickable 的那一层
* 一般把文字整个包住,所以用它的 bounds 最稳)。
*/
const clickableAncestor = (() => {
if (target.attributes?.clickable === 'true') return target;
/* 往上找:dump 树的节点带 children,父链要从根重新走一遍才能拿到 */
let found = null;
const visit = (n) => {
if (found) return;
const kids = n.children || [];
for (const k of kids) visit(k);
if (found) return;
const self = n.attributes || {};
if (self.clickable === 'true'
&& String(self.bounds || '') === String(target.attributes?.bounds || '')
&& kids.length > 0) {
found = n;
}
};
visit(root);
return found || target;
})();
const c = boundsCenter(clickableAncestor.attributes?.bounds);
if (c === null) return false;
return tap(hdc, c.cx, c.cy);
}
/**
* 确保回到**主界面**(`MainPage`),而不是某个 push 出去的页
* (管理页 / 详情页 / 写信页 —— 它们没有侧栏与底栏)。
*
* ★★ 为什么需要它(2026-09-19 实测撞出来的**判据间干扰**):
* 每个设备判据都是"写操作",会把前台留在它操作完的那一页上。
* 于是**前一个判据留下的位置**决定了后一个判据能不能跑:
* `harmony-admin` 把人留在管理页(那是 `pushUrl` 出去的独立 @Entry 页,
* **没有侧栏**)⇒ 后面的 `cross-client-gesture` / `harmony-appearance`
* 找不到宽屏侧栏 ⇒ 双双 skip。而它们的 skip 原因写的是
* "宽屏侧栏找不到",看起来像功能没了。
*
* `launchOurApp` 解决不了这个:`aa start` 只把应用切到前台,
* 不会把已经 push 的页面弹栈。
*
* 做法:**反复按返回键**直到侧栏/底栏出现(那是主界面的标志)。
* 上限兜底,避免在没有可弹页面时空按。
*/
export async function backToMain(hdc, { tries = 6, settle = 700 } = {}) {
if (!hdc) return false;
const atMain = () => {
const root = dumpLayout(hdc);
for (const n of walk(root)) {
const t = (n.attributes?.text || '').trim();
/* 侧栏导航轨(宽屏)或底栏(窄屏)上的项 —— 只有 MainPage 有 */
if (t === '通信' || t === '联系') return true;
}
return false;
};
for (let i = 0; i < tries; i++) {
if (atMain()) return true;
sh(hdc, ['shell', 'uitest', 'uiInput', 'keyEvent', 'Back'], 10000);
await new Promise((r) => setTimeout(r, settle));
}
return atMain();
}
/**
* 在设备上跑一条 **shell 命令**并把输出带回来(`hdc shell <cmd>`)。
*
* ★ 判据经常需要设备侧的第二来源:`file` 报的图片尺寸、`ls` 报的体积、
* `/proc` 里的内存……这些都是**独立于本应用**的事实,
* 拿它们与应用的决策对账,比只看应用自己的输出可靠得多
* (否则"应用读错了尺寸"和"应用算错了"长得一模一样)。
*
* 返回 `{ stdout, stderr, status }`。`hdc` 是 `findHdc()` 的返回值(路径字符串)。
*/
export function shellOn(hdc, cmd, timeout = 20000) {
if (!hdc) return { stdout: '', stderr: 'no hdc', status: -1 };
return sh(hdc, ['shell', cmd], timeout);
}
/**
* 截屏并把 PNG 拉到本地,返回本地路径(失败返回 null)。
*
* ★ 为什么需要它(2026-09-19 加):
* 本目录此前**只能**通过 `dumpLayout` 看界面 —— 而 dump 是**结构化描述**,
* 它报的是"组件声明了什么属性",不是"屏幕上真的画成什么样"。
* 两者会分叉的地方恰恰是最要紧的:
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没这串字
* (我为它追了很久,最后靠截图才发现真相);
* · 颜色令牌写对了,但渲染时被父层覆盖 / 被透明度抹掉;
* · 元素在屏幕外(dump 里仍报它的 bounds)。
* ⇒ "观感类"判据必须有截屏这一层。
*
* **这是写操作**(会占用设备屏幕一小会儿)。调用方按本模块的边界①
* 先确认前台是自己的应用。
*/
export function screenshot(hdc, localPath = '/tmp/hm-shot.png') {
if (!hdc) return null;
const remote = '/data/local/tmp/__hm_shot.png';
sh(hdc, ['shell', 'uitest', 'screenCap', '-p', remote], 30000);
const r = sh(hdc, ['file', 'recv', remote, localPath], 30000);
if (!existsSync(localPath)) return null;
/* 再确认一次不是空文件(recv 失败时会留下 0 字节或旧文件) */
try {
return statSync(localPath).size > 100 ? localPath : null;
} catch {
return null;
}
}
/**
* 读一张 PNG 上某个点的像素(返回 `{ r, g, b }`,失败返回 null)。
*
* 实现用 `ffmpeg` 把它转成 1×1 的原始 RGB 再读三个字节 ——
* 不引 PNG 解码依赖(本仓的判据目录不装 node_modules)。
*
* ★ 坐标是**屏幕像素**(与 `dumpLayout` 给的一致),不是 vp。
*/
export function pixelAt(pngPath, x, y) {
if (!existsSync(pngPath)) return null;
const out = join(tmpdir(), `hm-px-${process.pid}-${x}-${y}.raw`);
const r = spawnSync('ffmpeg', [
'-loglevel', 'error', '-y', '-i', pngPath,
'-vf', `crop=1:1:${x}:${y}`,
'-f', 'rawvideo', '-pix_fmt', 'rgb24', out,
], { encoding: 'utf8', timeout: 20000 });
if (r.error || !existsSync(out)) return null;
try {
/*
* ★ 走 `lib/read.mjs` 的 `bytes()`,不裸用 `readFileSync` ——
* `criteria-hygiene` 盯着这条(它是对的:本仓有两套读法,
* `code()` 剥注释、`prose()` 保注释,裸读会让"判代码"退化成"判文本")。
* 二进制只有 `bytes()` 这一个合法入口。
*/
const buf = bytes(out);
unlinkSync(out);
if (buf.length < 3) return null;
return { r: buf[0], g: buf[1], b: buf[2] };
} catch {
return null;
}
}
/** 把 `#RRGGBB` 解析成 `{r,g,b}`(判据里常拿它跟 `pixelAt` 的结果比)。 */
export function hexToRgb(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (!m) return null;
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
}
/** 两个颜色的通道差是否都在容差内(渲染有抗锯齿/取整,不能用全等比)。 */
export function closeColor(a, b, tol = 12) {
if (!a || !b) return false;
return Math.abs(a.r - b.r) <= tol && Math.abs(a.g - b.g) <= tol && Math.abs(a.b - b.b) <= tol;
}
/**
* 把整张 PNG 解码成内存里的 RGB 网格,返回一个 `{ w, h, at(x, y) }` 句柄。
*
* ★ 为什么要有它(2026-09-19 加):
* 上面那个 `pixelAt()` 每读**一个**点就 spawn 一次 `ffmpeg`。单点够用,
* 但"扫一个文字框找最亮/最暗"这种用法要读几百个点 ⇒ 几百次进程启动,
* 一条判据就跑了几十秒(实测 19 个短文本 ~700 次 spawn)。
*
* 整屏解码是**一次** ffmpeg 调用(3184×2232×3 ≈ 21MB 内存),
* 之后所有采样都是数组下标 —— 快三个数量级。
*
* 坐标系与 `dumpLayout` 的 `bounds` 一致(屏幕像素,原点左上)。
*/
export function readPixels(pngPath, screenW, screenH) {
if (!existsSync(pngPath)) return null;
/*
* ★★ 尺寸必须**问出来**,不能猜(2026-09-19 装真平板时撞出来的)。
*
* 我原来的实现是"从 raw 字节数 + 一个候选宽度表反推":
*
* const px = buf.length / 3;
* let w = screenW; // 默认 3184(模拟器)
* for (const cand of [3184, 2232, 2560, 1920, 1280, 1080]) ...
*
* 在模拟器(3184x2232,正好在表里)一直是对的,
* **装到真平板(2800x1840)就错了**:
* 候选表里没有 2800 => 退到 1280 => 算出 **1280x4025**
* (像素数恰好相同:5152000)=> 不报错,`at(x,y)` 全读到错位的行。
*
* 危险之处是**它长得像成功**:句柄返回了、尺寸是个正数、
* `at()` 也不越界 —— 只有拿 `file` 去看原图才发现对不上。
* 这个错会一路带进结论("某个颜色不对"其实是读错了行)。
*
* => 改用 `ffprobe` 问真实尺寸(一次调用,几毫秒),
* 并且**校验 raw 字节数是否等于 w*h*3** ——
* 不等就返回 null(宁可让判据说"读不到",也不要给它一张错位的图)。
*/
let w = screenW || 0;
let h = screenH || 0;
if (!w || !h) {
const probe = spawnSync('ffprobe', [
'-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0', pngPath,
], { encoding: 'utf8', timeout: 20000 });
const out = (probe.stdout || '').trim(); // 形如 "2800,1840"
const m = /^(\d+),(\d+)$/.exec(out);
if (!m) return null;
w = Number(m[1]);
h = Number(m[2]);
}
if (!w || !h) return null;
const raw = join(tmpdir(), `hm-rgb-${process.pid}-${Date.now()}.raw`);
const r = spawnSync('ffmpeg', [
'-loglevel', 'error', '-y', '-i', pngPath,
'-f', 'rawvideo', '-pix_fmt', 'rgb24', raw,
], { encoding: 'utf8', timeout: 60000 });
if (r.error || !existsSync(raw)) return null;
let buf;
try {
buf = bytes(raw);
} catch {
return null;
} finally {
try { unlinkSync(raw); } catch { /* 清不掉就算了 */ }
}
/* 字节数必须严丝合缝 —— 这是防"错位读到别的行"的那道闸 */
if (buf.length !== w * h * 3) return null;
return {
w,
h,
at(x, y) {
if (x < 0 || y < 0 || x >= w || y >= h) return null;
const i = (y * w + x) * 3;
return { r: buf[i], g: buf[i + 1], b: buf[i + 2] };
},
};
}