// 行为判据的设备侧 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 结果。 */ function sh(hdc, args, timeout = 20000) { return spawnSync(hdc, args, { 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` 回来 → 解析)。 * * dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_.json`, * 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径 * 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。 * * 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事 * —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。 */ export function dumpLayout(hdc) { if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)'); const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000); if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`); const m = (r.stdout || '').match(/saved to:(\S+)/); if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`); const path = m[1].trim(); const cat = sh(hdc, ['shell', 'cat', path], 20000); if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`); 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 `)。 * * ★ 判据经常需要设备侧的第二来源:`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; }