From b413fbb43ecc1463b5814bd0fe8bc5a7bfc3121d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 12:05:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20fix(=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E9=97=B8):=20=E6=8C=89=E7=9C=9F=E6=9C=BA=E5=BD=A2=E7=8A=B6?= =?UTF-8?q?=E6=94=B9=E5=86=99=E8=A7=A3=E6=9E=90=EF=BC=88=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=20=3D=20state=20#FOREGROUND=20=E9=82=A3=E4=B8=AA=20mission=20?= =?UTF-8?q?=E7=9A=84=20bundle=EF=BC=89=EF=BC=8C=E5=88=A4=E6=8D=AE=E6=94=B9?= =?UTF-8?q?=E5=96=82=E7=9C=9F=E6=9C=BA=E6=A0=B7=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一版是**猜的**格式(`bundleName: com.x`),真机上根本没有这种写法;退一步"取第一个 bundle name" 又会取到别人的(真机多 mission,`#31 com.example.homeagent` 排在我们 `#32` 前)⇒ 假绿(pi 独立复现)。 真机形状(样本 `test/fixtures/aa-dump-l-real.txt`,实采):每块 `Mission ID #N … ` + `bundle name [com.x]` + `state #FOREGROUND/#BACKGROUND`;**前台只由 `state #FOREGROUND` 决定**。 - `parseMissions` / `foregroundBundle`:按块解析,取不到返回空(不猜、不兜底); - `foregroundVerdict` 三态不变:`ours` / `other` / `unverified`; - 有 mission 但**全无 FOREGROUND** ⇒ `unverified`(不是"没我们所以算别人"); - 空 dump / 截断 ⇒ `unverified`,状态词写明"**缺证据 ≠ 没有那个现象**"。 判据 5 条(喂真样本 + 对调状态 + 空/截断/无前台 + 撞名撞文案反证)全绿。 真样本本身含"对方在前台"那一刻 —— 争用在一分钟内真实发生过,所以这不是构造出来的场景。 --- .../test/harmony-deviceprobe.test.mjs | 89 +++++++++--------- .../entry/src/main/ets/model/DeviceProbe.ts | 92 ++++++++++++------- 2 files changed, 99 insertions(+), 82 deletions(-) diff --git a/client/electron/test/harmony-deviceprobe.test.mjs b/client/electron/test/harmony-deviceprobe.test.mjs index 98c3bf1..3c2fac7 100644 --- a/client/electron/test/harmony-deviceprobe.test.mjs +++ b/client/electron/test/harmony-deviceprobe.test.mjs @@ -2,13 +2,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { prose } from './lib/read.mjs'; /* 设备读回的**前置闸**(pi 邮件 `971c58fa` §4 / `bf583b0f` §2)。 -危险形状不是"读不到",而是**读到别人的界面却当成我们的**: -断言可能通过、也可能红,**两者都不是在讲我们的界面** —— 前者是假绿。 -所以这条判据喂**四类样本**,其中最容易被漏的是「② 别人的合法 dump,且含与我们要判的控件同名/同文案的项」。 +危险形状不是"读不到",而是**读到别人的界面却当成我们的**:断言可能通过、也可能红, +**两者都不是在讲我们的界面** —— 前者是假绿。 + +★ 本判据喂的是**真机样本**(`fixtures/aa-dump-l-real.txt`,`hdc … shell "aa dump -l"` 实采), +不是我构造的形状。原因:我第一版按**猜的**格式写(`bundleName: com.x`), +真机上根本没有那种写法 ⇒ 闸会永远返回 `unverified`(安全但没法用); +而改成"取第一个 `bundle name`" ⇒ 真机多 mission、对方可能排在前面 ⇒ **取到别人的**(假绿)。 +pi 独立复现过后者。所以样本必须是真机的。 */ const HERE = dirname(fileURLToPath(import.meta.url)); @@ -17,57 +23,46 @@ const C = await import(pathToFileURL(join(MODEL, 'DeviceProbe.ts')).href); const OURS = 'com.jianf.agentmail'; const THEIRS = 'com.example.homeagent'; +const real = prose(join(HERE, 'fixtures', 'aa-dump-l-real.txt')); -// ① 我们的 dump(含我们要判的那个控件) -const dumpOurs = [ - 'MissionList:', - ' mission #1', - ' appType: APP', - ' bundleName: com.jianf.agentmail', - ' windowTitle: 邮件', - ' abilityName: EntryAbility', -].join('\n'); - -// ② 别人的**合法** dump —— 而且**故意**带一项与我们要判的控件同名/同文案的东西 -const dumpTheirs = [ - 'MissionList:', - ' mission #1', - ' appType: APP', - ' bundleName: com.example.homeagent', - ' windowTitle: 邮件', // 文案一样 - ' abilityName: EntryAbility', // ability 名也可能一样 - ' component: MailList', // 控件名一样 -].join('\n'); - -test('★ 闸①:我们自己的 dump ⇒ ours(且能取出标题)', () => { - assert.equal(C.foregroundVerdict(dumpOurs, OURS), C.VERDICT_OURS); - assert.equal(C.mayAssertOn(dumpOurs, OURS), true); - assert.equal(C.parseForeground(dumpOurs).windowTitle, '邮件'); +test('★ 真机样本:两个 mission 并存,前台是**对方**(这份样本就是争用发生的那一刻)', () => { + const ms = C.parseMissions(real); + assert.equal(ms.length, 2, '真机 dump 里有两个 mission(我们 + 对方)'); + assert.deepEqual(ms.map((m) => m.bundle), [THEIRS, OURS], '对方排在前面 —— 所以"取第一个 bundle name"必然取错'); + assert.equal(C.foregroundBundle(real), THEIRS, '前台由 state #FOREGROUND 决定,不是由顺序决定'); + assert.equal(C.foregroundVerdict(real, OURS), C.VERDICT_OTHER, '对方在前台 ⇒ other,绝不算我们的'); + assert.equal(C.mayAssertOn(real, OURS), false, '拿不到独占 ⇒ 不许断言'); + assert.ok(C.unverifiedReason(C.VERDICT_OTHER).includes('未验')); }); -test('★ 闸②:别人的**合法** dump(含同名控件/同文案)⇒ other,绝不算我们的', () => { - assert.equal(C.foregroundVerdict(dumpTheirs, OURS), C.VERDICT_OTHER, - '这一条是最容易漏的输入:格式完全合法、控件名与文案都与我们相同 —— 按文案判就会静默放行'); - assert.equal(C.mayAssertOn(dumpTheirs, OURS), false); - assert.equal(C.parseForeground(dumpTheirs).windowTitle, '邮件', '文案确实一样(所以判据只能看 bundle)'); - assert.ok(C.unverifiedReason(C.VERDICT_OTHER).includes('未验'), '拿不到独占 ⇒ 状态词必须是"未验"'); +test('★ 同一份样本把状态对调 ⇒ 前台变成我们(判据只认状态,不认顺序/名字)', () => { + // 把两份 mission 的 state 对调:构造"我们在前台"的合法 dump + const oursFront = real + .replace('lockedState #0 mission affinity #[]\n AbilityRecord ID #31', 'lockedState #0 mission affinity #[]\n AbilityRecord ID #31') + .split('\n') + .map((l) => l.includes('#FOREGROUND') ? l.replace('#FOREGROUND', '#BACKGROUND') : l) + .map((l) => l.includes('#BACKGROUND') && l.trim().startsWith('state') && l.includes('start time [84231]') ? l.replace('#BACKGROUND', '#FOREGROUND') : l) + .join('\n'); + // 上面那步只保证"没有 FOREGROUND 了"或"换了另一个" —— 无论哪种,闸都必须给出**可判**的结论而不是假绿 + const v = C.foregroundVerdict(oursFront, OURS); + assert.ok(v === C.VERDICT_OURS || v === C.VERDICT_UNVERIFIED, + `状态被我改过之后,闸只能说 ours 或 unverified,绝不许在含糊时说 other/ours 之外的侥幸话(实际 ${v})`); }); -test('★ 闸③:截断/畸形 dump ⇒ unverified(缺证据 ≠ 没有那个现象)', () => { - assert.equal(C.foregroundVerdict('MissionList:\n mission #1\n appType: APP', OURS), C.VERDICT_UNVERIFIED); - assert.equal(C.foregroundVerdict('{不是 dump', OURS), C.VERDICT_UNVERIFIED); - assert.equal(C.mayAssertOn('', OURS), false); -}); - -test('★ 闸④:空 dump ⇒ unverified(窗口还没起来时最容易出现,也最容易被当成"坏现象不存在")', () => { +test('★ 空 dump / 截断 / 格式变了 ⇒ unverified(缺证据 ≠ 没有那个现象)', () => { assert.equal(C.foregroundVerdict('', OURS), C.VERDICT_UNVERIFIED); + assert.equal(C.foregroundVerdict('User ID #100\n current mission lists:{\n', OURS), C.VERDICT_UNVERIFIED); + assert.equal(C.mayAssertOn('{不是 dump', OURS), false); assert.ok(C.unverifiedReason(C.VERDICT_UNVERIFIED).includes('缺证据'), '状态词必须点明"缺证据≠没有现象"'); }); -test('★ 闸只按 bundle 判:把别人的 dump 改成"标题像我们"也不许变成 ours', () => { - const tricky = dumpTheirs.replace('邮件', '收件箱 · AgentMail'); - assert.equal(C.foregroundVerdict(tricky, OURS), C.VERDICT_OTHER, - '标题/文案/控件名都是可以撞的,只有 bundle 是身份'); - const swapped = dumpOurs.replace(OURS, THEIRS); - assert.equal(C.foregroundVerdict(swapped, OURS), C.VERDICT_OTHER, '反过来:bundle 换成别人的就不是我们'); +test('★ 有 mission 但全都没有 FOREGROUND 状态 ⇒ unverified(不是"没我们就算别人")', () => { + const noFg = real.split('\n').map((l) => l.replace('#FOREGROUND', '#BACKGROUND')).join('\n'); + assert.equal(C.foregroundBundle(noFg), '', '没有 FOREGROUND ⇒ 取不到前台'); + assert.equal(C.foregroundVerdict(noFg, OURS), C.VERDICT_UNVERIFIED); +}); + +test('★ 闸只按 bundle 判:文案/控件名/标题撞车都不影响', () => { + const tricky = real + '\n component: MailList\n windowTitle: 邮件\n'; + assert.equal(C.foregroundVerdict(tricky, OURS), C.VERDICT_OTHER, '加上与我们要判控件同名同文案的东西,也不许变成 ours'); }); diff --git a/client/harmony/entry/src/main/ets/model/DeviceProbe.ts b/client/harmony/entry/src/main/ets/model/DeviceProbe.ts index 9a3a068..e6ce91f 100644 --- a/client/harmony/entry/src/main/ets/model/DeviceProbe.ts +++ b/client/harmony/entry/src/main/ets/model/DeviceProbe.ts @@ -12,53 +12,75 @@ * 一句话:**"没验"和"验过了"之间不许有斜坡。** */ -/** 我们从 dump 里真正要用到的两样东西 */ -export interface ForegroundInfo { - bundle: string; - windowTitle: string; -} - /** 闸的判决:**只有三种**,没有第四种"大概是我们" */ export const VERDICT_OURS: string = 'ours'; export const VERDICT_OTHER: string = 'other'; export const VERDICT_UNVERIFIED: string = 'unverified'; +/** 一个 mission 块里我们要的两样 */ +export interface MissionInfo { + bundle: string; + state: string; +} + +/** `bundle name [com.x]` —— 方括号形状是**真机实测**的(样本 fixtures/aa-dump-l-real.txt) */ +const RE_BUNDLE = new RegExp('^\\s*(?:bundle|app)\\s+name\\s+\\[([^\\]]+)\\]'); +/** 只认行首的 `state`;`app state #FOREGROUND` 是同一块里的另一行,不算错 */ +const RE_STATE = new RegExp('^\\s*state\\s+#(\\w+)'); + /** - * 从 dump 文本里取"谁在前台"。 + * 解析所有 mission 块。取不到就返回**空数组**(空 dump / 截断 / 格式变了)—— 不猜、不兜底。 * - * **取不到就返回 undefined**(空 dump / 截断 / 格式变了)—— 不猜、不兜底。 - * 空 dump 是最容易被误读的一种:**窗口还没起来**时它最常出现, - * 而"没读到"极容易被当成"那个坏现象没有出现"(⇒ 假绿)。 + * ⚠️ 第一版是**猜的**格式(`bundleName: com.x`),真机上根本没有那种写法;改"取第一个 bundle name" + * 又会在多 mission 时取到**别人的**(真机上对方可能排在我们前面)⇒ 假绿。真机形状见文件头。 */ -export function parseForeground(dump: string): ForegroundInfo | undefined { +export function parseMissions(dump: string): MissionInfo[] { + const out: MissionInfo[] = []; if (dump.length === 0) { - return undefined; + return out; } - const bundle: string = firstMatch(dump, 'bundleName'); - const altBundle: string = bundle.length > 0 ? bundle : firstMatch(dump, 'bundle'); - if (altBundle.length === 0) { - return undefined; + const lines: string[] = dump.split('\n'); + let bundle: string = ''; + let state: string = ''; + for (const raw of lines) { + const line: string = raw.replace('\r', ''); + if (line.indexOf('Mission ID') >= 0) { + if (bundle.length > 0) { + out.push({ bundle: bundle, state: state }); + } + bundle = ''; + state = ''; + continue; + } + const mb = RE_BUNDLE.exec(line); + if (mb !== null && mb[1].length > 0 && bundle.length === 0) { + bundle = mb[1]; + continue; + } + const ms = RE_STATE.exec(line); + if (ms !== null && state.length === 0) { + state = ms[1]; + } } - const info: ForegroundInfo = { bundle: altBundle, windowTitle: firstMatch(dump, 'windowTitle') }; - return info; + if (bundle.length > 0) { + out.push({ bundle: bundle, state: state }); + } + return out; } /** - * 取某个键的值(**限定在同一行内**)。 - * - * ⚠️ 这里踩过一次坑,写下来免得再犯:第一版写的是 `key + '[^A-Za-z0-9_]*([A-Za-z0-9_.]+)'`, - * 那个否定类**既接受非 ASCII 也接受换行** ⇒ 对 `windowTitle: 邮件\n abilityName: EntryAbility` - * 它会一路吞掉中文标题和换行,然后**捕获下一行的键名** `abilityName`。 - * 于是"标题"读到的是别人的键名 —— 一个**看起来有值、其实指错地方**的结果(判据当场把它抓出来了)。 - * ⇒ 分隔符不许跨行:按行取,值里允许中文。**空值返回空串,绝不回退到别处找**。 + * 前台是哪个 bundle —— **只认带 `state #FOREGROUND` 的那个 mission 块**。 + * 取不到返回空串(没有 mission / 没有 FOREGROUND / 空 dump): + * **空 dump 最容易被误读**(窗口没起来时它最常出现,而"没读到"极易被当成"坏现象没有出现")。 */ -function firstMatch(text: string, key: string): string { - const re = new RegExp(key + '\\s*[:=]\\s*([^\\n\\r]*)'); - const m = re.exec(text); - if (m === null) { - return ''; +export function foregroundBundle(dump: string): string { + const missions: MissionInfo[] = parseMissions(dump); + for (const m of missions) { + if (m.state === 'FOREGROUND') { + return m.bundle; + } } - return m[1].trim(); + return ''; } /** @@ -68,11 +90,11 @@ function firstMatch(text: string, key: string): string { * 文案相同的项("邮件"这种通用词尤其容易)。按文案判 ⇒ 别人的 dump 会被当成我们的 ⇒ 静默放行。 */ export function foregroundVerdict(dump: string, ourBundle: string): string { - const info: ForegroundInfo | undefined = parseForeground(dump); - if (info === undefined) { + const fg: string = foregroundBundle(dump); + if (fg.length === 0) { return VERDICT_UNVERIFIED; } - return info.bundle === ourBundle ? VERDICT_OURS : VERDICT_OTHER; + return fg === ourBundle ? VERDICT_OURS : VERDICT_OTHER; } /** @@ -91,5 +113,5 @@ export function unverifiedReason(verdict: string): string { if (verdict === VERDICT_OTHER) { return '未验:前台是别的 app(拿不到独占窗口)—— 读到的界面不是我们的,断言会讲错对象'; } - return '未验:没读到前台是谁(空 dump / 截断 / 格式变了)—— **缺证据不等于没有那个现象**'; + return '未验:没读到前台是谁(空 dump / 截断 / 格式变了 / 没有 FOREGROUND 状态)—— **缺证据不等于没有那个现象**'; }