鸿蒙 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.hap196,288,7892026-10-27 12:29:12
electron/build/default/outputs/default/electron-default-unsigned.hap194,827,1932026-10-27 12:29:09
electron/build/default/outputs/default/app/electron-default.hap193,668,3392026-10-17 10:09:05
build/outputs/default/ohos_hap-default-signed.app86,760,1502026-10-17 10:09:16
build/outputs/default/ohos_hap-default-unsigned.app86,745,5022026-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 打包动作对包体的真实影响被一句话概括完了:它重排了一份元信息清单的排版,其余条目逐字节不动。 这比"两个批次图标一致"强得多——两个批次之间没有任何业务代码、原生库或资源发生过变化。"我提交上去的是旧包"这个假设,到字节层面已经彻底排除,剩下的唯一可能就是判据本身有问题。


三、图标三重判据:哈希、尺寸、像素内容

这一节给出本文的核心工具。判定一个图标是否被正确带进包,需要三把尺子同时量:

  1. 哈希尺:回答"这三份文件之间是不是同一份字节"。
  2. 尺寸尺:回答"管线有没有改写它"。
  3. 像素尺:回答"改写之后内容还是不是同一张图"。

只用第一把会得到错误结论,只用第三把又看不出谁被改写过。三者叠起来才是完整判据。

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.pngapp.json5 的 icon、EntryAbility 的 icon、BrowserAbility 与 StatelessAbility 的 startWindowIcon、StatusBarEntryAbility 的 iconresources/base/media/app_icon.png512×512缩放重编码,mad 0.57
startIcon.pngEntryAbility 的 startWindowIconresources/base/media/startIcon.png1024×1024逐字节相同
product_logo_32.png无任何引用resources/base/media/product_logo_32.png1024×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 相关调用
PRINT否,界面只有文本预览与文件导出无打印链路
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,这按当下前端安全常识是要被划红线的配置。但在本工程的实际约束下,它成立的条件是齐的,而且这三条都在前几节被量过:

  1. 加载的是本地文件,loadFile 指向 index.html,包内没有远程入口。
  2. 界面零外部 URL,第十一节实测 index.html 里 http 计数为 0,三条引用全是相对路径。
  3. 不出网,第十一节网络 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 同色,成本是一行,但需要真机确认切换时机是否真能被消除。


十七、待办清单:阻断、建议、观察三级,以及本文能被复现的部分

把全部实测结论收敛成一张可执行清单。分级依据只有一条:是否阻断提审。

阻断项(不改就提交不了或必被驳):

  1. release 签名材料缺失。 Profile 实测 type 为 debug、issuer 为 app_gallery、debug-info 里绑一个 udid;buildModeSet 声明了 release 但 signingConfigs 只有一份 debug 材料。正式材料(含 distribution-cert、release 类型 Profile)没配之前,一切核验都是在调试产物上做的。
  2. 开源声明没有机器可读载体。 工程与包内 LICENSE/NOTICE 数量实测为 0,package.json 的 license 值(Apache-2.0)与弹窗里的表述(只提 Electron MIT 与三个上游库)之间缺一句"本应用自身采用什么许可证"。
  3. 真机截图未采。 本文所有呈现层结论(字号 1.45 倍、字体回退、白闪)都停在"必须验"的状态,配图位 1 已经补入,配图位 2 到 4 还要换成真机画面才能提交。

建议项(会被追问,或成本极低收益明确):

  1. 图标引用矩阵收敢:EntryAbility 与另外两条 ability 路径的 startWindowIcon 分属 app_icon 与 startIcon 两个 media 名,建议统一到 app_icon,或者把三张源图做成构建时同步的一条脚本。
  2. product_logo_32.png 处理:文件名承诺 32 而实际 1024×1024,且无任何引用点。要么按名字生成 32 版本,要么连同模板名一起清掉。
  3. 权限描述口径:对外描述按代码能力写(不出网、不直连仪器),不要按 14 项权限清单写;同时把这张表交给适配层维护方逐项确认,不单方面删声明。
  4. 目标设备与 API 面表述:产物里 minAPIVersion 50005017、targetAPIVersion 60000020、compileSdkVersion 6.0.0.47 三档并存,提审材料要按产物口径填,不能只抄 build-profile 里那一条 compatibleSdkVersion。
  5. en_US 语言包补齐或删冗余目录:三套 EntryAbility_label 全是中文,属于"结构承诺双语、内容承诺单语"的中间态。
  6. ArkTS 侧混淆:release 档的 obfuscation 开关已配好但从未生效过(因为从来没走过 release)。

观察项(记录即可,动它有风险):

  1. resources.index 里那批 user_grant 提示文案(相机、剪贴板、截图、下载目录)与本应用零 user_grant 权限并存,属模板死资源。
  2. 壳模板注册的九页(含 Login、SubWindow、NodeHandleSubWindow 等)照旧保留;判"能不能删"要看有没有按路径字面量引用,而不是看 pages 目录里有几个 .ets(实测十个文件对九页,多出的 QuickLoginButtonComponent.ets 是组件)。
  3. .gitignore 忽略的是 rawfile,而业务代码在 resfile,因此版本库与产物两头都在;这条如果哪天改成往 rawfile 放代码,就会变成"包里有、仓库里没有"。
  4. 白闪与 10px/11px 小字号,等真机截图说话。
  5. 关于图标不一致驳回,静态证据链已经全部指向包内自洽,剩下唯一未证面是 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 社区,一起交流更多踩坑经验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐