鸿蒙 PC 桌面端应用上架实战:数采工作台从图标不一致误判到产物反查三重判据的翻案记
鸿蒙 PC 桌面端应用上架实战:数采工作台从图标不一致误判到产物反查三重判据的翻案记
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_PyVISA_PyDAQmx



数采工作台是一款面向 OpenHarmony PC 的仪器控制与数据采集工作台:SCPI 命令语法校验、VISA 资源字符串解析、NI-DAQmx 采集配置校验,并把配置回放成可直接运行的 Python 脚本。业务实现只有六个文件、两千零二十八行——index.html 177 行、main.js 102 行、renderer.js 513 行、pyvisadaq-engine.js 676 行、pyvisadaq.css 551 行、package.json 9 行。壳工程是标准的 Electron 双层结构,包名 com.wei.shucai,vendor wei,versionCode 1000000。
它在 AGC 上收到过一条真实驳回:提交图标与终端显示图标不一致。我此前给这条驳回下过结论,认为根因是构建缓存陈旧、把模板旧图标回填进了包里,并且这条结论已经写进了工程笔记。这一次为了写这篇文章,我没有再靠推理,而是把签名包拆开,把三张图标的哈希、字节数、像素尺寸和缩放后逐像素平均绝对差全部量了一遍。
结论被推翻了:包里的图标就是新图标,一点不差;错的是我用来判断的那把尺子。
这篇文章就顺着这次翻案往回走,把"以构建产物为唯一真相源"这件事做成一套能照着跑的核验动作:签名包里到底有什么、图标到底在哪一层被改写、代码到底是不是逐字节进包的、进包的原生库到底是不是你以为的那一份、权限到底声明了几项而代码到底用了几项。全文的实测数字都由十一段脚本在当前工程上跑出来,脚本就写在各自那一节的正文里,任何一条都可以自行复现——包括那条被推翻的旧结论。文末会把哪些输出有脚本、哪些只有结果逐类交代清楚。
一、为什么要把核验面从配置面挪到产物面
常见的自查动作基本都落在配置面:读 app.json5、读 module.json5、读四套 string.json、数权限、比对 hvigor 产物时间戳。这套动作有用,但它有一个隐含的前提——配置面写了什么,产物里就是什么。
Electron 壳工程恰好是最容易让这个前提失效的形态,原因有三条,每一条都能在本工程实测到:
- 资源会被改写而不是复制。 hvigor 的资源管线会把 AppScope 里的 app_icon.png 从 1024×1024 缩放成 512×512 并重新编码,于是产物里那份图标的哈希永远不等于源。任何人用"哈希是否相等"来判定"包里的图标是不是新图标",都会得出 100% 错误的结论。
- 配置会被合并、也会被丢弃。 web_engine 是一个 HAR 模块,它的 module.json5 里写了 14 项 requestPermissions 和 1 条 definePermissions,进包后合并进行模块的 requestPermissions,而 definePermissions 字段直接变成 None。
- 同名文件可能被替换。 electron/libs/arm64-v8a/libc++_shared.so 在磁盘上是 1,825,624 字节,包内同路径那份是 1,334,688 字节,哈希也不同。
这三条有一个共同点:在配置面看不到,只有把签名包拆开才能看到。 从这一节开始,本文的每一次判定都遵循同一套顺序:先取回产物,再解剖产物,最后才回头解释配置。
二、先把证据取回来:五个产物、两个批次
工程里和上架直接相关的产物有五个,实测的时间与体积如下:
| 文件 | 字节数 | 修改时间 |
|---|---|---|
| electron/build/default/outputs/default/electron-default-signed.hap | 196,288,789 | 2026-10-27 12:29:12 |
| electron/build/default/outputs/default/electron-default-unsigned.hap | 194,827,193 | 2026-10-27 12:29:09 |
| electron/build/default/outputs/default/app/electron-default.hap | 193,668,339 | 2026-10-17 10:09:05 |
| build/outputs/default/ohos_hap-default-signed.app | 86,760,150 | 2026-10-17 10:09:16 |
| build/outputs/default/ohos_hap-default-unsigned.app | 86,745,502 | 2026-10-17 10:09:12 |
第一眼看这张表要读出三件事。
第一,工程里存在两个批次的产物。 10 月 17 日那一批产出的是 App Pack(.app,内含未签名的内层 hap),10 月 27 日那一批产出的是 electron 模块单独签名的 .hap。两批相差整整十天,中间正好夹着一次图标替换:AppScope 下三张源图的修改时间是 2026-10-15 22:52:39,而构建缓存里 app_icon.png 的时间是 2026-10-27 12:28:55。
第二,.app 比 .hap 小得多不是丢了东西。 .app 是包了 hap 的容器,内层 hap 以压缩包形式存储;体积不能直接横向比较,要比的永远是同名条目的解压后字节数。
第三,"产物是不是旧的"这个问题不能靠时间戳回答。 十天前的 .app 和一天前的 .hap 都做过嵌套解包,两处图标位上的字节完全一致:
signed.hap (2026-10-27) resources/base/media/app_icon.png
262f937a 201519 B 512x512 与源逐像素平均绝对差 0.57
signed.app (2026-10-17) 内层 hap 同一条目
262f937a 201519 B 512x512 与源逐像素平均绝对差 0.57
两个批次里都是这一份。也就是说,"驳回是因为提交了旧包"这条路在静态证据上是走不通的。这条否定比那条肯定更重要,因为它把排查方向从"重新打一次包"逼回到了"到底怎么判断包里的图对不对"。
这条否定还能再往前推一步。 表中 193,668,339 那一行是没有被装进 .app 的同批 hap,.app 内层那一份的解压后字节数是 193,668,681,差额正好 342 字节。把这 342 字节定位到具体条目,只需要一次全条目对照:
import os, sys, io, json, zipfile, hashlib
B = r'D:\hwAppLaunch\PyVISADAQ\ohos_hap'
FLAT = os.path.join(B, 'electron', 'build', 'default', 'outputs', 'default', 'app', 'electron-default.hap')
APP = os.path.join(B, 'build', 'outputs', 'default', 'ohos_hap-default-signed.app')
a = zipfile.ZipFile(FLAT)
inner = zipfile.ZipFile(io.BytesIO(zipfile.ZipFile(APP).read('electron-default.hap')))
na, nb = a.namelist(), inner.namelist()
print('flat entries=%d inner entries=%d' % (len(na), len(nb)))
print('only in flat=%s only in inner=%s' % ([x for x in na if x not in nb],
[x for x in nb if x not in na]))
for n in na:
if n in nb and a.getinfo(n).file_size != inner.getinfo(n).file_size:
print('SIZE DIFF %-24s flat=%d inner=%d' % (
n, a.getinfo(n).file_size, inner.getinfo(n).file_size))
for n in na:
if n in nb and not n.endswith('/'):
if hashlib.sha256(a.read(n)).hexdigest() != hashlib.sha256(inner.read(n)).hexdigest():
print('HASH DIFF %s' % n)
ra = a.read('pack.info').decode('utf-8')
rb = inner.read('pack.info').decode('utf-8')
for tag_, r in (('flat', ra), ('inner', rb)):
print('%-5s chars=%d lines=%d newline=%d sp=%d tab=%d' % (
tag_, len(r), r.count('\n') + 1, r.count('\n'), r.count(' '), r.count('\t')))
print('semantically equal=%s whitespace-insensitive equal=%s' % (
json.loads(ra) == json.loads(rb), ''.join(ra.split()) == ''.join(rb.split())))
flat entries=28 inner entries=28
only in flat=[] only in inner=[]
SIZE DIFF pack.info flat=742 inner=1084
HASH DIFF pack.info
flat chars=742 lines=2 newline=1 sp=0 tab=0
inner chars=1084 lines=65 newline=64 sp=33 tab=246
semantically equal=True whitespace-insensitive equal=True
28 个条目同名同序,一个不多一个不少,唯一的差异落在 pack.info 上。更关键的是这处差异连内容都算不上:按 JSON 解析后比较,两份完全相等;把空白字符全部剥掉再比,也完全相等。区别只在序列化格式——一份是压成一行的紧凑写法,另一份是 65 行 Tab 缩进的可读写法。64 个换行加 246 个 Tab 加 33 个空格,扣掉紧凑版末尾那一个换行,正好凑出那 342 字节。
于是 .app 打包动作对包体的真实影响被一句话概括完了:它重排了一份元信息清单的排版,其余条目逐字节不动。 这比"两个批次图标一致"强得多——两个批次之间没有任何业务代码、原生库或资源发生过变化。"我提交上去的是旧包"这个假设,到字节层面已经彻底排除,剩下的唯一可能就是判据本身有问题。
三、图标三重判据:哈希、尺寸、像素内容
这一节给出本文的核心工具。判定一个图标是否被正确带进包,需要三把尺子同时量:
- 哈希尺:回答"这三份文件之间是不是同一份字节"。
- 尺寸尺:回答"管线有没有改写它"。
- 像素尺:回答"改写之后内容还是不是同一张图"。
只用第一把会得到错误结论,只用第三把又看不出谁被改写过。三者叠起来才是完整判据。
import os, sys, io, hashlib, zipfile
import numpy as np
from PIL import Image
ROOT = r'D:\hwAppLaunch'
PROJECTS = ['PyVISADAQ']
NAMES = ['app_icon.png', 'product_logo_32.png', 'startIcon.png']
def tag(b):
im = Image.open(io.BytesIO(b)).convert('RGB')
return '%s/%d/%dx%d' % (hashlib.sha256(b).hexdigest()[:8], len(b), im.size[0], im.size[1])
def mad(src, dst):
a = Image.open(io.BytesIO(src)).convert('RGB')
b = Image.open(io.BytesIO(dst)).convert('RGB')
if a.size != b.size:
a = a.resize(b.size, Image.LANCZOS)
return float(np.abs(np.asarray(a, np.int16) - np.asarray(b, np.int16)).mean())
def find_hap(b):
for sub in (os.path.join(b, 'electron', 'build'), os.path.join(b, 'build')):
for r, d, f in os.walk(sub) if os.path.isdir(sub) else []:
for n in f:
if n.endswith('-signed.hap'):
return os.path.join(r, n)
return None
def in_zip(path, name):
z = zipfile.ZipFile(path)
for n in z.namelist():
if n.endswith('/media/' + name):
return z.read(n)
return None
for p in PROJECTS:
b = os.path.join(ROOT, p, 'ohos_hap')
media = os.path.join(b, 'AppScope', 'resources', 'base', 'media')
cache = os.path.join(b, 'electron', 'build', 'default', 'intermediates', 'res', 'default', '.caches', 'base', 'media')
hap = find_hap(b)
print('### %s hap=%s' % (p, os.path.basename(hap) if hap else 'NO BUILD OUTPUT'))
for name in NAMES:
sp = os.path.join(media, name)
if not os.path.exists(sp):
continue
src = open(sp, 'rb').read()
cc = open(os.path.join(cache, name), 'rb').read() if os.path.exists(os.path.join(cache, name)) else None
pk = in_zip(hap, name) if hap else None
line = ' %-20s src=%s' % (name, tag(src))
if cc:
line += ' cache=%s mad=%.2f' % (tag(cc), mad(src, cc))
else:
line += ' cache=NONE'
if pk:
line += ' pkg=%s mad=%.2f' % (tag(pk), mad(src, pk))
else:
line += ' pkg=NONE'
print(line)
print()
在本工程上跑出来的输出:
### PyVISADAQ hap=electron-default-signed.hap
app_icon.png src=3018b81c/481973/1024x1024 cache=262f937a/201519/512x512 mad=0.57 pkg=262f937a/201519/512x512 mad=0.57
product_logo_32.png src=3018b81c/481973/1024x1024 cache=NONE pkg=3018b81c/481973/1024x1024 mad=0.00
startIcon.png src=3018b81c/481973/1024x1024 cache=NONE pkg=3018b81c/481973/1024x1024 mad=0.00
这段输出里能提出四个结论,每一个都是过去没有明确过的:
结论一:hvigor 只对 app_icon.png 做缩放重编码。 app_icon 从 1024×1024 变成 512×512、字节数从 481,973 降到 201,519、哈希改变;product_logo_32.png 与 startIcon.png 在包内与源逐字节相同(mad=0.00),缓存目录里也没有它们的副本。也就是说,资源管线只挑被 app.json5 的 icon 字段引用的那一张改写,其余按原样复制。
结论二:缩放结果与新源图的内容一致。 mad 是缩放后逐像素平均绝对差,值域 0~255。本工程这一对是 0.57,肉眼不可分辨。也就是说,包里的 512 图就是那张 1024 新图的等比缩放,不是另一张图。
结论三:mad 只能证明"内容一致",不能单独证明"来源是这张源图"。 它回答的是"两张图看起来像不像",回答不了"这张图是从哪来的"。真正把来源钉死的是哈希:源与包内哈希必然不同(因为被重编码),而同一个包内条目在 10-17 与 10-27 两个批次上哈希相同,都是 262f937a、201,519 字节。如果缓存里被回填过模板旧图标,这两批不可能同哈希。所以判据是三步而不是一步:尺寸定改写关系,像素定内容一致,跨批次哈希定来源唯一。
结论四:缓存目录里只躺着被改写的那一张。 顺手列一下 intermediates 的两处目录:
== electron/build/default/intermediates/res/default/resources/base/media ==
app_icon.png 201,519 2026-10-27 12:28:55
product_logo_32.png 481,973 2026-10-15 22:52:40
startIcon.png 481,973 2026-10-15 22:52:41
== electron/build/default/intermediates/res/default/.caches/base/media ==
app_icon.png 201,519 2026-10-27 12:28:55
.caches 目录下只有 app_icon.png 一个文件,它就是缩放缓存;另外两张源图在 intermediates 里保留源修改时间(10-15),说明它们是直接复制过去的。改一次图标,需要盯住的只有 app_icon 这一条链路。
四、被推翻的旧结论,以及它为什么会错得这么自然
把旧结论原样写出来:数采工作台的"图标不一致"驳回,根因是构建缓存陈旧——hvigor 把上一次构建留下的模板图标回填进了包,删掉 build 目录重新构建才能解决。
这条结论之所以错得自然,是因为它踩中了一个非常常见的思维捷径:先把"哈希不等于源"读成"内容不是源",再给这个不一致找一个听起来合理的机制。 三个观察事实本身都是对的:包里的 app_icon 哈希确实不等于源、字节数确实从 481,973 掉到了 201,519、尺寸确实从 1024 变成了 512。错的是把这三件事解释成"旧图标",而它们其实是"新图标被缩放"的标准表现。
更值得说的是这条旧结论的实践后果:如果照它去处置,动作就是删 build 目录、重打包、再提交。这个动作不会改变任何东西,因为源图本来就是对的,缩放本来就是正确的,重打包出来的图标字节和之前完全一样(本文第二节已经证明两个批次同象)。真正会被这个动作掩盖掉的是另一件事——它把一个"判据错误"伪装成了一个"已执行的修复动作",于是每次驳回都会用同一套动作回应,而问题从来不在被处置的那一层。
所以这条旧结论的正确修正方式不是换成另一句结论,而是换成一条判据:
图标核验的正确问句不是"包里的图标哈希等不等于源",
而是"包里的图标是不是源图在当前平台规则下应有的样子"。
判定手段:哈希定同一性 → 尺寸定改写关系 → 像素 mad 定内容一致性,
并且必须用同一包内条目在两个构建批次上的哈希来锁定来源。
那么这条真实驳回还能怎么解释?把静态能证的都证完之后,剩下的面只有一个:AGC 做这项比对时参照的不只是包内图标,还包括后台上传的应用图标素材。包内三处(源、缓存、产物)已经证明完全自洽且是新图,因此不一致的那一侧只可能在后台提交件。这条只能作为待验证项诚实记下,动作是在 AGC 后台把应用图标重新上传一次并与包内 512 那份比对——这一步不能在本机静态完成,任何"我已经修好了"的表述在此都没有证据支撑。
这也是本文标题写"翻案记"而不是"修复记"的原因:翻案的部分有数据和脚本可以复现,未证的部分明确标注为未证。
五、解剖签名包:30 个条目读出的身份、权限与页面
图标之外,签名包还回答了三类问题:这个包的身份字段到底是什么、能力注册成了什么样子、业务代码是以什么形态进去的。下面这段脚本把这三类一次读出来。
import os, sys, json, hashlib, zipfile
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\PyVISADAQ\ohos_hap'
HAP = os.path.join(B, 'electron', 'build', 'default', 'outputs', 'default', 'electron-default-signed.hap')
SRCAPP = os.path.join(B, 'web_engine', 'src', 'main', 'resources', 'resfile', 'resources', 'app')
z = zipfile.ZipFile(HAP)
names = z.namelist()
print('entries=%d' % len(names))
for n in names:
print(' %10d %s' % (z.getinfo(n).file_size, n))
mj = json.loads(z.read('module.json'))
app, mod = mj['app'], mj['module']
print('\n== identity ==')
for k in ('bundleName', 'versionCode', 'versionName', 'vendor', 'icon', 'label',
'minAPIVersion', 'targetAPIVersion', 'apiReleaseType', 'compileSdkVersion'):
print(' app.%-18s %s' % (k, app.get(k)))
print(' module.%-9s %s type=%s' % ('name', mod.get('name'), mod.get('type')))
print(' abilities=%s' % [a['name'] for a in mod.get('abilities', [])])
print(' extensionAbilities=%s' % [a['name'] for a in mod.get('extensionAbilities', [])])
perms = [p['name'] for p in mod.get('requestPermissions', [])]
print(' requestPermissions=%d definePermissions=%s' % (len(perms), mod.get('definePermissions')))
print('\n== business payload: in-hap vs source ==')
tot = 0
for n in sorted(names):
if '/resources/app/' in n and not n.endswith('/'):
rel = n.split('/resources/app/')[-1]
sp = os.path.join(SRCAPP, *rel.split('/'))
a = hashlib.sha256(z.read(n)).hexdigest()[:8]
b = hashlib.sha256(open(sp, 'rb').read()).hexdigest()[:8] if os.path.exists(sp) else 'MISSING'
tot += (a != b)
print(' %-26s %s vs %s %s' % (rel, a, b, 'SAME' if a == b else 'DIFF'))
print(' DIFF_TOTAL=%d' % tot)
实测输出(截取到本文要用的部分):
entries=30
1492912 ets/modules.abc
2621200 libs/arm64-v8a/libadapter.so
1334688 libs/arm64-v8a/libc++_shared.so
167471912 libs/arm64-v8a/libelectron.so
2181528 libs/arm64-v8a/libffmpeg.so
21376 .pages.info
572033 ets/sourceMaps.map
3628 module.json
742 pack.info
9028 resources.index
201519 resources/base/media/app_icon.png
481973 resources/base/media/product_logo_32.png
481973 resources/base/media/startIcon.png
76 resources/base/profile/configuration.json
188 resources/base/profile/main_pages.json
147793 resources/resfile/chrome_100_percent.pak
221300 resources/resfile/chrome_200_percent.pak
6584 resources/resfile/electron
10464144 resources/resfile/icudtl.dat
492210 resources/resfile/locales/en-US.pak
498227 resources/resfile/locales/zh-CN.pak
5777343 resources/resfile/resources.pak
8904 resources/resfile/resources/app/index.html
3438 resources/resfile/resources/app/main.js
220 resources/resfile/resources/app/package.json
26007 resources/resfile/resources/app/pyvisadaq-engine.js
18562 resources/resfile/resources/app/renderer.js
11688 resources/resfile/resources/app/styles/pyvisadaq.css
79090 resources/resfile/snapshot_blob.bin
214155 resources/resfile/v8_context_snapshot.bin
== identity ==
app.bundleName com.wei.shucai
app.versionCode 1000000
app.versionName 1.0.0
app.vendor wei
app.icon $media:app_icon
app.label $string:app_name
app.minAPIVersion 50005017
app.targetAPIVersion 60000020
app.apiReleaseType Release
app.compileSdkVersion 6.0.0.47
module.name electron type=entry
abilities=['EntryAbility', 'BrowserAbility', 'StatelessAbility']
extensionAbilities=['StatusBarEntryAbility', 'BrowserEmbeddedAbility']
requestPermissions=14 definePermissions=None
== business payload: in-hap vs source ==
index.html f7f6eea9 vs f7f6eea9 SAME
main.js 9423bb50 vs 9423bb50 SAME
package.json 78837612 vs 78837612 SAME
pyvisadaq-engine.js 90ff2352 vs 90ff2352 SAME
renderer.js 8d498b29 vs 8d498b29 SAME
styles/pyvisadaq.css 8b3e1b03 vs 8b3e1b03 SAME
DIFF_TOTAL=0
这份输出里有四个需要专门解释的点。
第一,minAPIVersion 50005017 与 targetAPIVersion 60000020 不是同一个来源。 前者对应 build-profile.json5 里显式写的 compatibleSdkVersion “5.0.5(17)”,pack.info 里的 apiVersion.compatible 也是 17;后者对应 compileSdkVersion 6.0.0.47,pack.info 里的 apiVersion.target 是 20。配置面只承诺了 compatible,target 是打包时按所用 SDK 自动填进去的。这两档在提审材料里都会被读出来,不能只按配置面那一条来对外表述。
第二,definePermissions 在产物里是 None。 web_engine 是 HAR,它的 module.json5 里那条 definePermissions 只服务于编译期校验,不会进包;包内合并后的 requestPermissions 干净地是 14 项。这条第九节还会用到。
第三,包内业务文件只有一份,路径是 resources/resfile/resources/app/。 没有 electron-apps 那类第二份拷贝,也没有任何 LICENSE 或 NOTICE 文件——30 个条目里筛不出来。
第四,.pages.info 不能当文本核验。 它 21,376 字节,可打印字节占比只有 0.01,在里面搜 pages/Login 这类字符串一个都找不到。页面清单只能读 resources/base/profile/main_pages.json,包内那份是:
{"src":["pages/Index","pages/SubWindow","pages/StatusBarPage","pages/EmbeddedWindow","pages/WindowNode","pages/Login","pages/WebPage","pages/NodeHandleWindow","pages/NodeHandleSubWindow"]}
九页,与源文件逐字节一致。而 pages 目录下实测有十个 .ets 文件,多出来的那一个 QuickLoginButtonComponent.ets 不是页面而是组件,它不在 main_pages 里是正常状态。这类"数量对不上"的直觉必须先过一遍字面量检索再下判断:判页面能不能删,看的是有没有代码按路径字面量引用它,而不是 pages 目录里有几个文件。
顺带把资源表也验一次,因为图标路径就登记在那里:
resources.index size=9028 PNG magic count=0
app_icon
app_name
electron/resources/base/media/app_icon.png
electron/resources/base/media/product_logo_32.png
electron/resources/base/media/startIcon.png
start_window_background
BrowserAbility
OpenNewWindow_label
You have disabled the screenshot access permission. Go to Settings > Privacy & security > Screenshot and enable the screenshot access permission for label
Camera requires user_grant permission
两件事一次说清:resources.index 里没有嵌任何图片字节(PNG 魔数计数为 0),图标只以路径形式登记,所以"编译后的资源表里藏着旧图标"这个假设可以直接排除;另外资源表里带着一批适配层模板送来的 user_grant 提示文案(相机、剪贴板、截图、下载目录),而本应用一项 user_grant 权限都没有申请——这些字符串是死资源,不影响功能,但会影响"这个应用是否有隐私授权流程"的第一印象,值得记在观察项里。
六、三张同源图标的三种命运
第三节证明了只有 app_icon 会被缩放。这一节把"三张图各自被谁引用"补齐,因为这才是"改一张忘两张"风险的来源。
先看源。AppScope/resources/base/media 下三张图实测:
app_icon.png 481973 sha=3018b81c
product_logo_32.png 481973 sha=3018b81c
startIcon.png 481973 sha=3018b81c
三张图逐字节相同,都是同一张 1024×1024。 而进包之后它们的命运分岔了:
| 源文件 | 被谁引用 | 包内形态 | 包内尺寸 | 与源关系 |
|---|---|---|---|---|
| app_icon.png | app.json5 的 icon、EntryAbility 的 icon、BrowserAbility 与 StatelessAbility 的 startWindowIcon、StatusBarEntryAbility 的 icon | resources/base/media/app_icon.png | 512×512 | 缩放重编码,mad 0.57 |
| startIcon.png | EntryAbility 的 startWindowIcon | resources/base/media/startIcon.png | 1024×1024 | 逐字节相同 |
| product_logo_32.png | 无任何引用 | resources/base/media/product_logo_32.png | 1024×1024 | 逐字节相同 |
第三行的信息量最大:product_logo_32.png 在配置面没有任何字段引用它,它照样跟着进包,而且文件名承诺的是 32,实际像素是 1024。这个名字是 Electron 壳模板带过来的,属于必须分级处理的残留项——它不会让应用出错,但它会让"这个包里的图标到底几张有效"变成一个需要人工回答的问题。
引用矩阵本身也有两处值得记:EntryAbility 的 startWindowIcon 用的是 startIcon,而 BrowserAbility 与 StatelessAbility 的 startWindowIcon 用的是 app_icon。同一个应用的两条启动窗口路径指向了两个不同的 media 名,今天因为两张图源字节相同而看不出差别,一旦换图时只替换 app_icon.png,启动窗口和桌面图标就会裂开。这一条比"图标不一致"驳回本身更有操作价值,因为它是可以在改动之前就挡住的。
七、代码字节级同传,以及一个字节统计陷阱
第五节末尾那段 DIFF_TOTAL=0 是本文最让人安心的一行:六个业务文件的包内哈希与源文件哈希全同,零差异,零改写,零合并。 Electron 壳方案里"我改的是 resfile 下那份,进包的是不是它"这个日常疑问,用三段哈希比对就能一次性关闭。
但这里有个陷阱值得单独写一节的一半篇幅。第五节的表里,包内 index.html 是 8,904 字节;而如果在 Python 里用文本模式读同一个源文件再统计字节,会得到 8,728。两者相差 176,恰好等于 index.html 的行数 177 减一。实测六个文件全部命中这个规律:
index.html disk= 8904 crlf= 176 lf_only= 8728 lines= 177
main.js disk= 3438 crlf= 101 lf_only= 3337 lines= 102
package.json disk= 220 crlf= 8 lf_only= 212 lines= 9
pyvisadaq-engine.js disk= 26007 crlf= 675 lf_only= 25332 lines= 676
renderer.js disk= 18562 crlf= 512 lf_only= 18050 lines= 513
styles/pyvisadaq.css disk= 11688 crlf= 550 lf_only= 11138 lines= 551
disk 是二进制读的实际字节,crlf 是 CRLF 序列个数,lf_only 是文本模式读出来再编码的字节。三个量的关系恒定为 disk = lf_only + crlf,且 crlf = lines - 1。
结论有两层:第一,打包过程没有改写换行符,源文件是 Windows CRLF,进包还是 CRLF,这也是哈希能全同的前提;第二,任何用文本模式统计字节再和包内比对的动作都会得到"大小不一致"的假警报。这个假警报的危险之处在于它看起来非常像"产物被改写过",如果顺着它查下去,就会重演第四节那种"给正确现象编一个错误机制"的过程。核验脚本里凡是涉及字节比较的,一律二进制读,没有例外。
八、原生库:包内那份不一定是你以为的那一份
Electron 壳的体积大头在四个 .so 上。常规理解是"electron/libs/arm64-v8a 里放什么,包里就是什么"。实测第三条就不成立:
import os, sys, zipfile, hashlib
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\PyVISADAQ'
HAP = os.path.join(B, 'ohos_hap', 'electron', 'build', 'default', 'outputs', 'default', 'electron-default-signed.hap')
LIBS = os.path.join(B, 'ohos_hap', 'electron', 'libs', 'arm64-v8a')
UNST = os.path.join(B, 'lib.unstripped')
z = zipfile.ZipFile(HAP)
NAMES = ['libc++_shared.so', 'libadapter.so', 'libelectron.so', 'libffmpeg.so']
def fmt(x):
return '{:,}'.format(x)
print('== same-name file on disk vs inside hap ==')
for n in NAMES:
d = open(os.path.join(LIBS, n), 'rb').read()
h = z.read('libs/arm64-v8a/' + n)
print(' %-19s disk=%s/%s hap=%s/%s %s' % (
n, fmt(len(d)), hashlib.sha256(d).hexdigest()[:8],
fmt(len(h)), hashlib.sha256(h).hexdigest()[:8],
'SAME' if d == h else 'DIFFERENT'))
print()
for n in NAMES:
u = os.path.join(UNST, n)
l = os.path.join(LIBS, n)
print(' %-19s unstripped=%s module/libs=%s in-hap=%s' % (
n, fmt(os.path.getsize(u)) if os.path.exists(u) else 'NONE',
fmt(os.path.getsize(l)), fmt(z.getinfo('libs/arm64-v8a/' + n).file_size)))
== same-name file on disk vs inside hap ==
libc++_shared.so disk=1,825,624/b6fc1231 hap=1,334,688/12c12adb DIFFERENT
libadapter.so disk=2,621,200/ab034bb0 hap=2,621,200/ab034bb0 SAME
libelectron.so disk=167,471,912/537b12ed hap=167,471,912/537b12ed SAME
libffmpeg.so disk=2,181,528/6c2c5320 hap=2,181,528/6c2c5320 SAME
libadapter、libelectron、libffmpeg 三个确实是逐字节同传;libc++_shared.so 被换成了另一份更小的,从 1,825,624 变成 1,334,688,哈希完全不同。这个替换在配置面上完全不可见——build-profile.json5 里只有一句 nativeLib.collectAllLibs 为 true。工程根目录下还有一个 lib.unstripped,里面放着带符号的母本,上面那段脚本的后半段量的就是这份对应关系:
libc++_shared.so unstripped=NONE module/libs=1,825,624 in-hap=1,334,688
libadapter.so unstripped=15,494,016 module/libs=2,621,200 in-hap=2,621,200
libelectron.so unstripped=1,143,668,440 module/libs=167,471,912 in-hap=167,471,912
libffmpeg.so unstripped=NONE module/libs=2,181,528 in-hap=2,181,528
lib.unstripped 不在 ohos_hap 目录里,因此不进包;libelectron.so 的未剥离母本 1.14 GB,包内那份 1.67 亿字节,差 6.8 倍。这份对应关系正是崩溃定位需要的:真机栈顶地址必须拿未剥离母本去解,拿包内那份只能得到偏移量。
判定"包内是否已剥离"这一步,本文第一次也做错了。第一版脚本用子串搜索:
import zipfile
z = zipfile.ZipFile(r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\electron\build\default\outputs\default\electron-default-signed.hap')
for n in z.namelist():
if not n.endswith('.so'):
continue
buf = z.read(n)
print('%-34s symtab=%s' % (n, b'strtab' in buf or b'.symtab' in buf))
四个库全部报 True:
libs/arm64-v8a/libadapter.so symtab=True
libs/arm64-v8a/libc++_shared.so symtab=True
libs/arm64-v8a/libelectron.so symtab=True
libs/arm64-v8a/libffmpeg.so symtab=True
看起来像是"包内还留着符号表",与 lib.unstripped 的存在直接矛盾。错在判据本身:b’strtab’ 这个子串在任何带 section 名字表的 ELF 里都恒存,因为段表自己就需要一个名为 .shstrtab 的字符串表;而即便只搜 b’.symtab’,命中的也可能只是字符串表里的一个名字,而不是一个真的 section。子串搜索回答的是"这段字节里有没有这几个字符",不是"有没有这个结构"。 正确的做法是解析 ELF64 段表,按 shstrtab 逐段取名:
import os, struct, zipfile, hashlib
B = r'D:\hwAppLaunch\PyVISADAQ'
HAP = os.path.join(B, 'ohos_hap', 'electron', 'build', 'default', 'outputs', 'default', 'electron-default-signed.hap')
z = zipfile.ZipFile(HAP)
def sections(buf):
"""parse ELF64 section headers, return list of (name, type, size)"""
assert buf[:4] == b'\x7fELF' and buf[4] == 2, 'not ELF64'
e_shoff, = struct.unpack_from('<Q', buf, 0x28)
e_shentsize, e_shnum = struct.unpack_from('<HH', buf, 0x3A)
shstrndx, = struct.unpack_from('<H', buf, 0x3E)
hdr = buf[e_shoff + shstrndx * e_shentsize: e_shoff + (shstrndx + 1) * e_shentsize]
off, size = struct.unpack_from('<QQ', hdr, 0x18)
strtab = buf[off:off + size]
out = []
for i in range(e_shnum):
h = buf[e_shoff + i * e_shentsize: e_shoff + (i + 1) * e_shentsize]
nm, typ = struct.unpack_from('<II', h, 0)
sz, = struct.unpack_from('<Q', h, 0x20)
end = strtab.find(b'\0', nm)
out.append((strtab[nm:end].decode('ascii', 'replace'), typ, sz))
return out
for n in ('libs/arm64-v8a/libelectron.so', 'libs/arm64-v8a/libadapter.so', 'libs/arm64-v8a/libc++_shared.so'):
b = z.read(n)
names = [s[0] for s in sections(b)]
print('%s %s' % (n, '{:,}'.format(len(b))))
print(' sections=%d .symtab=%s .debug_* count=%d sha=%s' %
(len(names), '.symtab' in names, sum(1 for x in names if x.startswith('.debug')),
hashlib.sha256(b).hexdigest()[:8]))
跑出来的结果是干净的:
libs/arm64-v8a/libelectron.so 167,471,912
sections=28 .symtab=False .debug_* count=0 sha=537b12ed
libs/arm64-v8a/libadapter.so 2,621,200
sections=27 .symtab=False .debug_* count=0 sha=ab034bb0
libs/arm64-v8a/libc++_shared.so 1,334,688
sections=28 .symtab=False .debug_* count=0 sha=12c12adb
三个库都没有 .symtab,也没有任何 .debug_* 段——包内确已剥离,与 lib.unstripped 的存在互相印证。这一段留在这里,是为了把本文方法论里最硬的一条说明白:判据本身也要被核验。 一个能通过"看起来合理"的检查、却把结论说反的判据,比没有判据更危险,因为它会让人停止怀疑。
九、权限的三个口径:配置 15、去重 14、产物 14
权限是提审材料里最容易被数字口径坑到的一项。本工程的 14 项权限声明在 web_engine 这个 HAR 里,源文件只有 60 行,实测三个口径:
import re, collections
w = open(r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\web_engine\src\main\module.json5', encoding='utf-8').read()
lines = w.split('\n')
raw = re.findall(r'ohos\.permission\.[A-Z_0-9]+', w)
nc = '\n'.join(l for l in lines if not l.strip().startswith('//'))
after = re.findall(r'ohos\.permission\.[A-Z_0-9]+', nc)
print('lines=%d naive=%d noncomment=%d unique=%d' % (len(lines), len(raw), len(after), len(set(after))))
print('dups=%s' % [k for k, v in collections.Counter(after).items() if v > 1])
print('comment lines containing permission=%s' % [l.strip() for l in lines if l.strip().startswith('//') and 'permission' in l.lower()])
lines=60 naive=15 noncomment=15 unique=14
dups=['ohos.permission.LOCK_WINDOW_CURSOR']
comment lines containing permission=[]
三个数要分开讲清楚,不能混着说成"15 项权限"或者"14 项权限":
- naive=15:正则扫到 15 次 ohos.permission.* 字面量。
- noncomment=15:把以 // 开头的行全部删掉之后仍然是 15。这一步在本工程不会改变数字,因为没有注释掉的权限行——这和有的工程"数出来 16、去掉注释 15、再去重 14"的成因完全不同,不能套同一套说法。
- unique=14:多出来的那一次是 LOCK_WINDOW_CURSOR,它同时出现在 requestPermissions 与 definePermissions 两处。
配置面原文是这样收尾的:
"requestPermissions": [
{ "name": "ohos.permission.WINDOW_TOPMOST" },
{ "name": "ohos.permission.GYROSCOPE" },
{ "name": "ohos.permission.ACCELEROMETER" },
{ "name": "ohos.permission.SET_ABILITY_INSTANCE_INFO" },
{ "name": "ohos.permission.GET_FILE_ICON" },
{ "name": "ohos.permission.LOCK_WINDOW_CURSOR" }
],
"definePermissions": [
{
"name": "ohos.permission.LOCK_WINDOW_CURSOR"
},
]
definePermissions 里那一项后面跟了一个尾逗号,元素之间也缺分隔逗号——JSON5 容忍这种写法,构建不报错,但它是重复声明的直接来源。第五节已经给出结论:进包后 definePermissions 是 None,requestPermissions 是干净的 14 项。所以对外的权限数量必须按产物口径报 14,而不是按配置字面量报 15。
数量之外更要紧的是档位。14 项全部拿到 OpenHarmony 应用权限文档的"开放权限(对所有应用开放)"清单里逐条比对,13 项能直接命中,剩下的 RUNNING_LOCK 单独检索也是同一档:
ohos.permission.WINDOW_TOPMOST 权限级别:normal 授权方式:系统授权(system_grant)
ohos.permission.RUNNING_LOCK 权限级别:normal 授权方式:系统授权(system_grant)
ohos.permission.PRIVACY_WINDOW 权限级别:normal
ohos.permission.ACCESS_CERT_MANAGER 权限级别:normal
ohos.permission.GET_NETWORK_INFO 权限级别:normal
ohos.permission.INTERNET 权限级别:normal
ohos.permission.PRINT 权限级别:normal
这一条比数量重要:14 项里没有一项是受限开放权限,没有一项需要 ACL。 这个数字要和第十节的 Profile 实测放在一起读,才能得出正确结论——它不是"漏配了 ACL",而是"本来就不需要 ACL"。
顺带记一个容易忽略的事实:WINDOW_TOPMOST 的起始版本是 13,且按官方说明目前仅 PC/2in1 支持。本工程 deviceTypes 恰好是 tablet 与 2in1,minAPIVersion 是 50005017,因此这项权限在目标形态上合法。换成手机形态复用这套壳,这一条就得重新评估。
十、签名材料反查:Profile 才是最终授权面
build-profile.json5 的 signingConfigs 只有一份 default,指向用户目录下的自动调试材料:
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"certpath": "C:\\Users\\ws199\\.ohos\\config\\default_ohos_hap_...=.cer",
"keyAlias": "debugKey",
"profile": "C:\\Users\\ws199\\.ohos\\config\\default_ohos_hap_...=.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "C:\\Users\\ws199\\.ohos\\config\\default_ohos_hap_...=.p12"
}
}
]
(密钥口令字段此处一律省略,不抄进文章,也不落到任何脚本输出里。)
同一段配置里还有两个信息:buildModeSet 声明了 debug 与 release 两档,但 signingConfigs 只有 debug 材料,release 档没有配任何签名材料;electron/build-profile.json5 里混淆开关只挂在 release 的 buildOptionSet 上(enable 为 true,规则文件 obfuscation-rules.txt),也就是当前这份产物走 debug 档, ArkTS 侧混淆根本没生效。
p7b 是 PKCS#7 容器,Profile 的 JSON 夹在二进制里。取它的时候第一版脚本这样切:
import re, os, json
bp = open(r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\build-profile.json5', encoding='utf-8').read()
profile_path = re.search(r'"profile":\s*"((?:[^"\\]|\\.)*)"', bp).group(1).replace('\\\\', '\\')
raw = open(profile_path, 'rb').read()
i, j = raw.find(b'{'), raw.rfind(b'}')
d = json.loads(raw[i:j + 1].decode('utf-8', 'replace')) # Extra data: line 1 column 1597
23 个 p7b 全部解析失败,本工程的这一份报在 line 1 column 1597。原因是 rfind 找到的那个右括号不是内层 JSON 的结尾,切片尾部多带了一段数据,json.loads 对尾随数据是零容忍的。正确写法是用 raw_decode,它只解析第一个完整 JSON 值并返回消耗长度:
import re, os, json
bp = open(r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\build-profile.json5', encoding='utf-8').read()
p = re.search(r'"profile":\s*"((?:[^"\\]|\\.)*)"', bp).group(1).replace('\\\\', '\\')
print('profile file = %s' % os.path.basename(p))
print('exists=%s size=%d' % (os.path.exists(p), os.path.getsize(p)))
raw = open(p, 'rb').read()
i = raw.find(b'{')
d, used = json.JSONDecoder().raw_decode(raw[i:].decode('utf-8', 'replace'))
print('top keys = %s' % list(d.keys()))
for k, v in d.items():
if isinstance(v, dict):
print(' %s -> keys %s' % (k, list(v.keys())))
elif isinstance(v, list):
print(' %s -> list len %d' % (k, len(v)))
else:
print(' %s -> %s' % (k, json.dumps(v, ensure_ascii=False)[:120]))
b = d['bundle-info']
print('bundle-name=%s apl=%s app-feature=%s' % (b['bundle-name'], b['apl'], b['app-feature']))
print('device-ids=%d type=%s' % (len(d['debug-info']['device-ids']), d['type']))
换掉之后一次跑通,本工程的 Profile 实测:
profile file = default_ohos_hap_BBGeHTTwACu4n16JPOkeFECPFLW7N8Tl8FKHbkVZHoE=.p7b
exists=True size=4067
top keys = ['version-name', 'version-code', 'uuid', 'validity', 'type', 'bundle-info', 'baseapp-info', 'permissions', 'debug-info', 'acls', 'issuer']
version-name -> "2.0.0"
version-code -> 2
uuid -> "8fafaf55-a2f1-4e24-a42d-91fc64f5946c"
validity -> keys ['not-before', 'not-after']
type -> "debug"
bundle-info -> keys ['developer-id', 'development-certificate', 'bundle-name', 'apl', 'app-feature', 'app-identifier']
baseapp-info -> keys []
permissions -> keys []
debug-info -> keys ['device-ids', 'device-id-type']
acls -> keys ['allowed-acls']
issuer -> "app_gallery"
bundle-name=com.wei.shucai apl=normal app-feature=hos_normal_app
device-ids=1 type=debug
逐项读出上架含义:
- type 是 debug,issuer 是 app_gallery。 这一份 Profile 只能装到 debug-info 里列出的设备,实测 device-ids 恰好一个 udid。提审要的是 release Profile,这一条是硬阻断项,不是建议项。
- bundle-name 是 com.wei.shucai,与 app.json5 与包内 module.json 三处一致。 三处一致是好事,但它是"用同一份配置自动签名"顺带保证的,换成正式材料时必须重跑这条比对。
- apl 是 normal,app-feature 是 hos_normal_app。 应用特权等级 normal 意味着拿不到 system_basic 及以上权限。
- permissions 是空对象,acls.allowed-acls 是空数组。 结合第九节"14 项全是 normal 级开放权限"这个结论,这两个空值是对的:normal 应用申请 normal 权限,本来就不需要 ACL 授权。如果这里出现"空 acls + 受限权限"的组合,那才是漏配。
- validity 有 not-before 与 not-after,developer-name 与 distribution-cert 字段不存在。 正式发布 Profile 才带 distribution-cert,这是判断材料类型的第二个信号(第一个是 type)。
【配图位 2:DevEco Studio 的 Project Structure > Signing Configs 页面截图两张。第一张是当前自动调试签名状态,要能看清 Sign Debug HAP 勾选、keyAlias 为 debugKey、Profile 路径指向用户目录;第二张是切换到 Material 目录后的空 release 配置(或已成功生成的 release 材料),用来支撑本节"release 档没配任何签名材料"这条结论。】
十一、最小必要:14 项权限对 6 个文件的零调用
权限声明与代码能力的交叉核对是提审材料里最容易做、也最容易被跳过的一步。先扫业务代码:
import os
A = r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\web_engine\src\main\resources\resfile\resources\app'
F = ['index.html', 'main.js', 'package.json', 'pyvisadaq-engine.js', 'renderer.js', 'styles/pyvisadaq.css']
allt = '\n'.join(open(os.path.join(A, f), encoding='utf-8').read() for f in F)
print('== high-risk / network api scan ==')
pats = ['require(', 'child_process', 'http.request', 'fetch(', 'XMLHttpRequest', 'WebSocket',
'clipboard', 'shell.openExternal', 'eval(', 'Function(', 'ipcRenderer.invoke', 'ipcMain.handle',
'net.connect', 'socket', 'dgram']
for p in pats:
print(' %-22s %d' % (p, allt.count(p)))
== high-risk / network api scan ==
require( 3
child_process 0
http.request 0
fetch( 0
XMLHttpRequest 0
WebSocket 0
clipboard 0
shell.openExternal 0
eval( 0
Function( 0
ipcRenderer.invoke 6
ipcMain.handle 5
net.connect 0
socket 0
dgram 0
十五个高危字面量在这六个业务文件上合并计数,只有 require、invoke、handle 三样非零。再看 HTML 侧:
import re, os, sys
sys.stdout.reconfigure(encoding='utf-8')
html = open(r'D:\hwAppLaunch\PyVISADAQ\ohos_hap\web_engine\src\main\resources\resfile\resources\app\index.html', encoding='utf-8').read()
print('index.html title=%s lang=%s' % (re.findall(r'<title>.*?</title>', html), re.findall(r'<html[^>]*>', html)))
print('img/link refs=%s' % re.findall(r'(?:src|href)="([^"]+)"', html))
print('data-url-ish count=%d' % len(re.findall(r'https?://', html)))
index.html title=['<title>数采工作台</title>'] lang=['<html lang="zh-CN">']
img/link refs=['styles/pyvisadaq.css', 'pyvisadaq-engine.js', 'renderer.js']
data-url-ish count=0
index.html 里 http 开头的 URL 计数为 0,全部引用只有三条本地相对路径。把这两份扫描和第九节的 14 项放在一起:
| 权限 | 应用是否具备对应能力 | 判定依据 |
|---|---|---|
| INTERNET / GET_NETWORK_INFO / ACCESS_CERT_MANAGER | 否,全应用零网络 API | 上表四项网络类计数全 0,HTML 零外链 |
| ACCELEROMETER / GYROSCOPE | 否,桌面工具无传感器用途 | 代码无 sensor 相关调用 |
| 否,界面只有文本预览与文件导出 | 无打印链路 | |
| ACCESS_BIOMETRIC | 否,无认证流程 | 无生物识别调用 |
| RUNNING_LOCK | 存疑,长时脚本在用户自己的机器上跑 | 本应用不执行脚本 |
| PREPARE_APP_TERMINATE | 存疑,需确认关闭时是否有未保存会话提示 | 需真机验证 |
| PRIVACY_WINDOW / WINDOW_TOPMOST / LOCK_WINDOW_CURSOR / SET_ABILITY_INSTANCE_INFO / GET_FILE_ICON | 由适配层承载,业务代码不直接触发 | 属壳能力,不能按业务代码判定 |
这里必须给出一个和"最小必要"口号相反、但在壳工程上正确的结论:不建议照着这张表删权限。 声明方是适配层模板而不是业务代码,删掉之后失效的可能不是这六个文件里的功能,而是壳本身某条我没跑到的路径(比如文件对话框落到系统侧取图标,或者窗口置顶与光标锁定)。正确的动作是两条:一是把这张表作为待办交给适配层维护方逐项确认,二是在隐私说明与提审描述里严格按代码能力写,不要按 14 项权限清单写。后者是可以立刻执行的,也是最容易被驳回咬住的——描述里出现"支持网络通信"而应用根本不出网,属于自造矛盾。
扫描过程中还有一处必须记下的假阳性:用正则统计 exec 调用时命中 7 处,第一反应是 child_process。实际这 7 处全部是 RegExp 对象的 exec 方法(VISA 资源串解析本来就要大量用正则),文件里连 child_process 这个字符串都不存在。高危 API 的计数必须回读命中行,不能只信计数。
十二、安全域:nodeIntegration 直通模式在什么条件下可以成立
本工程 renderer.js 第二行就直接引 Electron:
// 职责:UI 交互 + SCPI 会话管理 + Python 脚本生成 + 会话存取
var electron = require('electron');
var ipcRenderer = electron.ipcRenderer;
原因在 main.js 的窗口配置里:
app.disableHardwareAcceleration();
// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
// 窗口级菜单移除(与应用级双保险)
mainWindow.setMenu(null);
// 全局防护:禁开新窗口/禁外部导航——适配层把新窗口实现为 SubWindow,创建失败即崩溃
mainWindow.webContents.setWindowOpenHandler(function() { return { action: 'deny' }; });
mainWindow.webContents.on('will-navigate', function(e) { e.preventDefault(); });
} catch (e) {
console.warn('[数采] 创建窗口失败:', e.message);
}
}
nodeIntegration 为 true、contextIsolation 为 false,渲染进程直接持有 require,这按当下前端安全常识是要被划红线的配置。但在本工程的实际约束下,它成立的条件是齐的,而且这三条都在前几节被量过:
- 加载的是本地文件,loadFile 指向 index.html,包内没有远程入口。
- 界面零外部 URL,第十一节实测 index.html 里 http 计数为 0,三条引用全是相对路径。
- 不出网,第十一节网络 API 计数全 0,因此不存在"远端内容拿到 Node 能力"这条通路。
真正把这套配置的风险关进笼子是那两行防护:setWindowOpenHandler 一律 deny,will-navigate 一律 preventDefault。前者在壳上尤其关键,因为适配层把新开窗口实现为 SubWindow,创建失败就是进程级崩溃,不是"多开一个窗口失败"这么轻。换句话说这两行同时防住了安全性和稳定性两件事,一行代码两种收益。
诚实的待办也要写上:这套配置成立的前提是"永不加载远程内容"。一旦后续加任何在线更新、远程仪表页面或 http 开头的导航,就必须改成 preload 加 contextBridge 的最小接口面,而不是继续给 renderer 整个 require。这条前提是设计约束,应该在 main.js 顶部写死成注释,而不是留在脑子里。
十三、IPC 的错误处理形态:5 个通道、6 处调用、两层 catch
数采工作台一共声明 5 个 handle 通道,renderer 侧 6 处 invoke:
== invoke targets == == handle channels ==
file:saveScriptDialog file:openSession
file:write file:read
file:saveSessionDialog file:saveSessionDialog
file:write file:saveScriptDialog
file:openSession file:write
file:read
交叉核对的结论是:5 个通道全部被用到,没有死通道;6 处 invoke 全部落在已声明的通道上,没有指向未注册通道的调用(file:write 被两处复用,脚本保存与会话保存共用写入)。这类双向核对只花一分钟,但它是"保存按钮点了没反应"这类问题的第一道排查——一侧漏注册,另一侧就是静默失败。
写文件的链路形态是这样的:
function saveScriptTo(script, defaultName, filterName, filterExt) {
if (!script) {
statusInfo.textContent = '请先生成脚本';
return;
}
ipcRenderer.invoke('file:saveScriptDialog', {
defaultName: defaultName,
filterName: filterName,
filterExt: filterExt
}).then(function(result) {
if (result && !result.canceled && result.filePath) {
ipcRenderer.invoke('file:write', result.filePath, script).then(function(wr) {
if (wr.success) {
statusInfo.textContent = '已保存 ' + result.filePath.split(/[\\/]/).pop() + ' (' + (wr.size / 1024).toFixed(1) + ' KB)';
} else {
statusInfo.textContent = '保存失败(详情见控制台)';
appendLine('[错误] 写入失败: ' + wr.error, 'console-error');
}
}).catch(function(e) {
statusInfo.textContent = '保存失败(详情见控制台)';
appendLine('[错误] 写入失败: ' + e.message, 'console-error');
});
}
}).catch(function(e) {
statusInfo.textContent = '保存失败(详情见控制台)';
appendLine('[错误] 保存对话框失败: ' + e.message, 'console-error');
});
}
这段里三件事值得作为规范写下来,而且它们恰好是这类壳工程最容易写错的三处:
- 嵌套的两次 invoke,两层各自有 catch。 内层管写入异常,外层管对话框异常,错误文案分别落到状态栏与控制台行,不会出现"点了没反应"。
- 显式判取消。 判据是 result && !result.canceled && result.filePath 三个条件同时成立,用户在系统保存框里点取消不会走到 write,也不会误报成功。
- 成功反馈带可核验信息。 文件名取路径末段,大小由主进程回传的 size 除以 1024 保留一位小数,而不是只写一句"已保存"。
主进程侧的每个 handler 也都是 try/catch 后返回结构化结果,不抛异常跨进程:
// 写入文件
ipcMain.handle('file:write', function(event, filePath, content) {
try {
fs.writeFileSync(filePath, content, 'utf-8');
return { success: true, size: content.length };
} catch (e) {
return { success: false, error: e.message };
}
});
还有一处已经修好的状态同步问题留了注释护栏,这类注释是我会特意保留的少数几种:
// 同步状态:setSegActive 只切按钮视觉,state.termination 需显式赋值(否则生成脚本仍用旧终止符)
state.termination = ex.termination || '\\n';
setSegActive('termGroup', 'data-term', ex.termination || '\\n');
【配图位 3:保存链路真机截图两张。第一张是点击"保存脚本"后系统保存对话框弹出、默认文件名为 instrument_session.py 且过滤项为 Python 脚本的画面;第二张是保存成功后底部状态栏显示"已保存 文件名(x.x KB)"的画面。第二张尤其关键,取消路径与写入失败路径都只能靠真机连拍确认。】
十四、引擎侧的合规红利,以及开源声明的载体分级
数采工作台的业务价值全在 pyvisadaq-engine.js 这 676 行、13 个函数里:16 条 IEEE 488.2 星号通用命令表、5 类 SCPI 参数形态判定、VISA 资源串按 ASRL / GPIB / TCPIP(VXI-11、HiSLIP、SOCKET)/ USB 分发解析、DAQmx 配置 5 维校验、3 个脚本生成器、5 个预设场景(dmm、scope、funcgen、serial、daq)。
校验器是按真实硬件约束写的,不是按"看起来像校验"写的:
function validateDaqConfig(cfg) {
var errors = [];
var warnings = [];
var ch = validateDaqChannel(cfg.channel || '');
if (!ch.valid) {
errors.push(ch.error);
} else if (ch.kind !== 'ai') {
errors.push('当前 AI 采集脚本要求模拟输入通道(ai),收到 ' + ch.kind + ' 类型通道');
} else {
var range = parseChannelRange(cfg.channel);
if (range && range.count > 16) {
warnings.push('通道范围覆盖 ' + range.count + ' 路,请确认硬件规模');
}
}
var rate = parseFloat(cfg.sampleRate);
if (!(rate > 0) || rate > 2000000) {
errors.push('采样率必须为 0 ~ 2 MS/s 之间的正数');
}
而生成的脚本里, import 行是这样五处(真实字符串拼接,不是伪代码):
lines.push('import pyvisa');
lines.push('from PyDAQmx import Task, int32, byref');
lines.push('from PyDAQmx.DAQmxConstants import (');
lines.push('import numpy as np');
lines.push('import pyvisa');
这一节真正要给其他壳工程作者的东西是这一句:这个应用既不出网,也不直连仪器,它只产出一段可移植的 Python 文本。 仪器控制类应用天然是审核高风险区(要网络、要 USB、要设备权限、要后台常驻),而把"执行"这一步从应用里剥离出去、交给用户在装有驱动的主机上跑,是把风险面一次性归零的设计选择。第十一节那张"网络类 API 全 0"的表,本质上是这个设计选择的结果,而不是运气。
代价也写在界面上,而且是明文写的。关于弹窗五段文字里有一段专门交代平台限制:
<p class="about-p"><strong>脚本运行</strong>:生成的 PyVISA 脚本可在装有 Python 3 与 pyvisa / pyvisa-py 的机器上运行;DAQmx 采集脚本依赖 NI-DAQmx 驱动库(libnicaiu),鸿蒙平台暂无该驱动,请在装有驱动的主机运行采集脚本。</p>
<p class="about-p"><strong>引擎参考源码</strong>:pyvisa(MIT,资源名解析/高层 API)、pyvisa-py(MIT,TCPIP 的 VXI-11 / HiSLIP / SOCKET 会话分发)、PyDAQmx(BSD-3,双命名机制与 Task RAII 生命周期)。</p>
<p class="about-p"><strong>免责声明</strong>:本工具仅供学习与研究使用,按"原样"提供,不对其适用性或准确性作任何明示或暗示的保证。使用本工具所产生的结果由用户自行承担。</p>
<p class="about-p"><strong>开源说明</strong>:本应用完全开源,基于 Electron(MIT 协议)构建,引擎参考 pyvisa / pyvisa-py / PyDAQmx 等开源项目的 API 设计。</p>
引擎文件头部同样把参考出处写成注释(pyvisa MIT、pyvisa-py MIT、PyDAQmx BSD-3),"基于上游源码重写"这句话在代码里是有落点的。
但把声明面从产物侧看一遍,就能看出三个层次的分野,这一层是本文要专门指出来的:
== LICENSE / NOTICE / README in ohos_hap (excluding oh_modules) ==
total=0
- 载体一:DOM 文本。 关于弹窗里有完整四段,随包走、用户能看到,但机器不可解析,审核与自动化都读不到。
- 载体二:package.json 字段。 只有一个 license 值:
{
"name": "shucai",
"version": "1.0.0",
"description": "数采工作台 - 鸿蒙 PC Electron 壳应用(仪器控制与数据采集)",
"main": "main.js",
"author": "wei",
"license": "Apache-2.0"
}
- 载体三:LICENSE 文件本身。 实测包内 30 个条目、工程内 ohos_hap 目录(排除 oh_modules)都为零。
也就是说,本工程的开源声明停在"人可读"这一层,没有落到"机器可读、法务可查"这一层。另外还有一处口径不统一需要修正:package.json 声明自身是 Apache-2.0,而弹窗里的开源说明只提到 Electron 的 MIT 和三个上游库的许可证,没有一句话说明本应用自身采用什么许可证。这不会导致崩溃,但它是审核与合规问答里最容易被追的一句"你说开源,依据在哪"。
待办很轻:仓库根补 LICENSE(与 package.json 一致的那份全文)、包内 resfile 下补一份 LICENSE 与上游 NOTICE、弹窗开源说明里补一句本应用自身许可证名称。三件事都是文本改动,但只有第一件是必须让机器读得到的。
【配图位 4:关于弹窗在鸿蒙 PC 上的真机截图。要拍到遮罩覆盖主界面、卡片居中、五段文字(脚本运行 / 引擎参考源码 / 免责声明 / 开源说明)完整可读、底部关闭按钮可见的整窗状态。这张图用来支撑本节的核心判断:声明内容已经齐了,缺的只是机器可读的载体。】
十五、名称与字符串:四套资源文件、模板残留分级
Electron 壳的名称分散在四处,实测清点如下:
AppScope\resources\base\element\string.json -> 1 项(app_name = 数采工作台)
electron\src\main\resources\base\element\string.json -> 12 项
electron\src\main\resources\en_US\element\string.json -> 8 项
electron\src\main\resources\zh_CN\element\string.json -> 8 项
AppScope 侧只有一套 base,没有 en_US 与 zh_CN 变体目录,所以 app_name 天生只有一份中文值。electron 侧三套语言目录都在,但三套里的 EntryAbility_label 全是"数采工作台":
{
"name": "EntryAbility_label",
"value": "数采工作台"
}
base、en_US、zh_CN 三处一字不差。这就带来一个需要作者主动决定的问题:en_US 里没翻译的到底是什么策略。 如果目标是单语应用,那 en_US 与 zh_CN 两份冗余文件本身就没有存在意义;如果目标是双语,那 en_US 里的应用名、模块描述、按钮文案都缺英文。当前状态是两头都不彻底——文件结构承诺双语,内容承诺单语。相比之下 module_desc 更老实:base 是 module description,zh_CN 是模块描述,en_US 仍是 module description。
另一处更值得记的是引用与定义的错位。electron module.json5 里实际被引用的字符串资源是这些:
icon refs=['app_icon', 'startIcon']
label refs=['BrowserAbility_desc', 'EntryAbility_desc', 'EntryAbility_label',
'StatelessAbility_desc', 'StatelessAbility_label',
'StatusBarEntryAbility_desc', 'module_desc']
对照 string.json 的定义面,能筛出三级残留:
| 级别 | 条目 | 判据 | 处置 |
|---|---|---|---|
| 会影响系统显示 | EntryAbility_label(数采工作台) | 被 module.json5 引用,且是桌面标签与任务中心标题来源 | 保持,且必须与 AGC 上架名一致 |
| 会被审核读到但没有英文 | app_name、module_desc、EntryAbility_desc(值为 description) | 在包内 resources.index 有登记,属对外可读文本 | 二选一:补齐英文或删冗余语言目录 |
| 纯死资源 | BrowserAbility_label(值 BrowserAbility)、OpenNewWindow_label、product_logo_32.png | 引用列表里没有它们,icon refs 里没有 product_logo_32 | 记录,不必强行删(删模板字段有回归风险) |
第三级里 EntryAbility_desc 的值是英文单词 description,这个占位值随包进到了 resources.index,审核在应用信息页读到的描述就是这三个字母级别的通用词。所以这里把尺子落在引用点上:判一条资源是不是残留,看的是有没有引用点,不是看它长得像不像模板。 BrowserAbility_label 之所以能确定是死资源,就是因为 BrowserAbility 在 module.json5 里没有 label 字段。
十六、呈现层自查:145 个 px、0 个 @media,和 1.45 倍字号的矛盾
界面是 Catppuccin 深色方案的纯 CSS 实现,551 行、11,688 字节,没有任何框架。单位分布实测:
== css units ==
px 145
rem 0
em 0
% 0
vw 1
vh 3
font-size decls=25 sizes=['10px', '11px', '12px', '13px', '14px', '15px']
media queries=0
dark theme vars=['--base: #1e1e2e', '--mantle: #181825', '--crust: #11111b',
'--surface0: #313244', '--surface1: #45475a', '--surface2: #585b70',
'--text: #cdd6f4', '--overlay0: #6c7086']
而包内的 configuration.json 只有 76 字节,内容是一行:
{"configuration":{"fontSizeMaxScale":"1.45","fontSizeScale":"followSystem"}}
【配图位 5:系统字号拉到最大档后的界面截图组,至少四张——顶部工具栏特写、左侧 VISA 与 DAQmx 配置面板特写、底部两条 10px/11px 区域特写、关于弹层整窗。四张都要能看清文字有没有被裁掉、折行或溢出按钮,用来支撑本节那 145 处 px 与 0 个 @media 的实际后果。】
这两份文件合在一起就是一条自查项。 fontSizeScale 跟随系统、上限 1.45 倍,意味着用户在系统设置里放大字号时,ArkWeb 会按比例放大文字;而 CSS 里 145 处长度全是 px,rem、em、百分比一处都没有,@media 查询数量为 0。放大的结果就是文字变、容器不变。面板宽度、行高、按钮内边距都钉死在像素上,1.45 倍档下最可能出问题的是那两个只有 10px 与 11px 的区域(控制台次要信息与状态栏角标),它们本来就贴着可读性下限。
这条不是"必须改代码"的结论,而是"必须真机验一遍"的结论:把系统字号拉到最大档,逐屏看有没有裁字、折行、按钮文字溢出。 验不过再决定是补 @media 还是把关键尺寸换成 rem,改法成本由实测结果定,不该由审美偏好定。
第二处是字体栈:
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
前两个名字属于 macOS,Consolas 与 Segoe UI 属于 Windows。在鸿蒙 PC 上这些名字全都命中不了,最终落到 monospace 与 sans-serif 的通用族回退。功能上没问题,但当前界面上看到的字形不是设计时看到的字形,字号与行高的观感差要重新按真机截图确认,不能拿浏览器里的截图当交付图。
第三处是首帧配色。color.json 四项里 start_window_background 是 #FFFFFF:
{ "name": "start_window_background", "value": "#FFFFFF" }
而界面背景 --base 是 #1e1e2e。启动窗口用白、Web 内容是深,从系统启动窗口切到首帧之间必然有一次白闪。这条属于体验观察项,改法是把 start_window_background 调到与 --base 同色,成本是一行,但需要真机确认切换时机是否真能被消除。
十七、待办清单:阻断、建议、观察三级,以及本文能被复现的部分
把全部实测结论收敛成一张可执行清单。分级依据只有一条:是否阻断提审。
阻断项(不改就提交不了或必被驳):
- release 签名材料缺失。 Profile 实测 type 为 debug、issuer 为 app_gallery、debug-info 里绑一个 udid;buildModeSet 声明了 release 但 signingConfigs 只有一份 debug 材料。正式材料(含 distribution-cert、release 类型 Profile)没配之前,一切核验都是在调试产物上做的。
- 开源声明没有机器可读载体。 工程与包内 LICENSE/NOTICE 数量实测为 0,package.json 的 license 值(Apache-2.0)与弹窗里的表述(只提 Electron MIT 与三个上游库)之间缺一句"本应用自身采用什么许可证"。
- 真机截图未采。 本文所有呈现层结论(字号 1.45 倍、字体回退、白闪)都停在"必须验"的状态,配图位 1 已经补入,配图位 2 到 4 还要换成真机画面才能提交。
建议项(会被追问,或成本极低收益明确):
- 图标引用矩阵收敢:EntryAbility 与另外两条 ability 路径的 startWindowIcon 分属 app_icon 与 startIcon 两个 media 名,建议统一到 app_icon,或者把三张源图做成构建时同步的一条脚本。
- product_logo_32.png 处理:文件名承诺 32 而实际 1024×1024,且无任何引用点。要么按名字生成 32 版本,要么连同模板名一起清掉。
- 权限描述口径:对外描述按代码能力写(不出网、不直连仪器),不要按 14 项权限清单写;同时把这张表交给适配层维护方逐项确认,不单方面删声明。
- 目标设备与 API 面表述:产物里 minAPIVersion 50005017、targetAPIVersion 60000020、compileSdkVersion 6.0.0.47 三档并存,提审材料要按产物口径填,不能只抄 build-profile 里那一条 compatibleSdkVersion。
- en_US 语言包补齐或删冗余目录:三套 EntryAbility_label 全是中文,属于"结构承诺双语、内容承诺单语"的中间态。
- ArkTS 侧混淆:release 档的 obfuscation 开关已配好但从未生效过(因为从来没走过 release)。
观察项(记录即可,动它有风险):
- resources.index 里那批 user_grant 提示文案(相机、剪贴板、截图、下载目录)与本应用零 user_grant 权限并存,属模板死资源。
- 壳模板注册的九页(含 Login、SubWindow、NodeHandleSubWindow 等)照旧保留;判"能不能删"要看有没有按路径字面量引用,而不是看 pages 目录里有几个 .ets(实测十个文件对九页,多出的 QuickLoginButtonComponent.ets 是组件)。
- .gitignore 忽略的是 rawfile,而业务代码在 resfile,因此版本库与产物两头都在;这条如果哪天改成往 rawfile 放代码,就会变成"包里有、仓库里没有"。
- 白闪与 10px/11px 小字号,等真机截图说话。
- 关于图标不一致驳回,静态证据链已经全部指向包内自洽,剩下唯一未证面是 AGC 后台上传件,重传一次并比对即可闭合。
最后交代一下本文的可复现性。全文一共十一段可执行脚本,分布在第二节(两个批次的全条目对照)、第三节(图标三重判据:源、缓存、包内三处对照)、第五节(签名包解剖,含条目清单、身份字段与业务文件哈希比对)、第八节(libs 目录与包内同名库对照及母本体积关系、子串判据的假阳性复现、ELF 段表解析)、第九节(权限三口径)、第十节(先复现切片写法报的 Extra data,再给 raw_decode 正确写法)、第十一节(高危 API 计数与 HTML 外链计数)。这十一段都在本机真实跑过,逐行抓回输出现场写进文中,读的全是工程里的真实路径,没有手写数字,也没有"应该是这样"的推断。
三类有意留下的差异都已在文里声明。第一类是排版:第八节母本那四行是同一段脚本的后半段输出,中间隔了一段说明;第十三节的 invoke 目标与 handle 通道是同一份扫描的两列,中间用空列分开。第二类是故意报错:第十节那段切片写法本就是为了失败而贴,它的输出以正文转述的方式出现,列号 1597 与实跑一致。
第三类是只给结果不给脚本的输出块:第二节的批次对照摘要、第三节的缓存目录清单、第五节的 resources.index 串表、第六节的图标字节表、第七节的字节口径对照、第十四节的 LICENSE 计数、第十五节的字符串资源与引用矩阵、第十六节的 CSS 单位计数。它们的取数方式与文中脚本完全同类,只是脚本用完即从工作区删除,不留在仓库里也不占版面;要自己复现这一类,照抄对应输出块里出现的路径字面量重跑一遍即可。另有几处块体本来就不是实测输出,而是文中要引用的官方文档措辞(第九节)与应用源码片段(第十三、十四节),不应被当成可跑脚本。
除这三类之外,文中每一个输出块都与实跑结果逐行相同,包括对齐宽度、字段顺序与空行。本文唯一被明确标注为未证的,仍是第四节末尾那条后台素材比对。
这次翻案真正的收获不是图标结论本身,而是那条更一般的规矩:任何用来定罪的判据,先拿一个已知不同的样本量一遍。 本工程把两个方向都量了:源图与缩放图哈希不同、mad 只有 0.57;同一个包内条目在 10-17 与 10-27 两个批次上完全同哈希。前者说明改写只是重编码,后者说明来源没有换过。两条都量过,0.57 才算清白;只报其中一个,它和本次那条错误结论看起来一样可信。
欢迎加入文首的开源鸿蒙 PC 社区,一起交流更多踩坑经验。
更多推荐



所有评论(0)