这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。
## 一、外观保存从来就没成功过(PUT 400)
`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。
症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。
★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。
## 二、改了档位,页面背景一点不变(两层原因)
**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。
**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:
Appearance: sync: bgKind=preset … hasImg=true ← 数据是对的
Wallpaper: kind=none layers=0 active=false ← 渲染读到的还是旧值
修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。
★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。
## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)
真相有**两层,两层都值得记**:
1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
—— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。
根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。
★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。
## 四、设备判据基建(这一轮加的能力)
- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
`swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
(拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
`harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
· `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
· 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」
## 验证
`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。
设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。
**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
376 lines
18 KiB
JavaScript
376 lines
18 KiB
JavaScript
// 行为判据的设备侧 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 { mkdirSync, writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
// 读盘走 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_<ts>.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);
|
||
const node = findByText(root, text);
|
||
if (node === null) return false;
|
||
const c = boundsCenter(node.attributes?.bounds);
|
||
if (c === null) return false;
|
||
return tap(hdc, c.cx, c.cy);
|
||
}
|