鸿蒙 PC 桌面端应用上架实战:宅匠 3D 室内设计从 ACL 驳回预判落空到同一工程两个签名产物的模式对账

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_SweetHome3D

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


这一篇的起点很不好意思:我给自己出了一道错题。

按台账里的预设,宅匠 3D 室内设计该写的是"ACL 受限权限声明一致性驳回"——也就是 module.json5 里声明的受限权限与签名 Profile 里放行的受限权限列表不一致,提审时被按这一条打回来。我照着这个方向准备了三轮叙事,甚至想好了"第一次改完仍被驳回"该怎么写。

然后我跑了第一个探针。结果是:本工程源码权限集合与包内权限集合双向差集为空,Profile 的 acls 是空列表。宅匠没有声明任何受限权限,因此"声明与放行不一致"这件事在这个工程里从来没有发生过。 我准备的三轮驳回叙事,一轮都没有历史依据——工程里没有这样的驳回记录,我也不能替它造一个。

落空之后往下挖,挖出来的东西比原来那条严重得多,而且是一整条链:

  1. 这个工程的签名材料是调试态——keyAlias 叫 debugKey,Profile 的 type 是 debug,并且绑定了具体设备号;
  2. 材料的三个文件都是 2026-10-27 12:36 写下的,而外层产物的时间是 10-14 13:21——材料比产物晚 13 天,现在配置指向的那份证书不可能是手上这个包的签名者;
  3. 用来证明第 2 条的第二把尺子不是文件时间,而是证书自己写下的生效时刻;
  4. 最要紧的一条在第 9 到第 12 节:我一开始只盯着外层 .app,于是漏掉了同一工程磁盘上的第二个签名产物。两个产物的 module.json 里,一个写着 release、一个写着 debug。

所以这一篇真正要立的判据是:同一个工程里可以同时存在两个都已签名的构建产物,它们来自同一套源码、同一套签名材料,但一个是发布态、一个是调试态;而"哪个是你准备提交的那个"必须由产物内部的字段回答,不能由文件时间、文件大小或者目录名猜。


一、这篇的预设,和它被实测推翻的方式

写这类复盘文最容易滑进的两个方向,我这次都得当场拦住。

第一个方向是"把预设当成事实来叙述"。台账上那行字是我自己写的,依据是上游项目特征加一条常见的驳回理由,不是提交记录。如果我不跑第一个探针,完全能拼出一篇读起来很顺的 ACL 打回故事——毕竟 module.json5 里确实有 14 项权限,Profile 里确实有个 acls 字段,两边一"对照",故事就来了。

第二个方向是"把查不出来当成没问题"。三方集合对账的结果是一行三个集合完全一致的空差集。如果我只写到这里,这篇文章的结论就变成"这个工程在权限声明上很干净",读起来像个好消息,实际是一条什么都没有的线索。

我的处理是分两件事:先把预设项按"不适用"记录下来,附实跑证据;再把同一批数据换一把尺子量,直到量出与发布有关的差异为止。前者防止注水,后者防止浅尝。

这条纪律可以叫"改动前必须先证明这个坑在这个工程里真的存在"。这次是它最难用的一次,因为要否证的是我自己三周前写下的计划。


二、工程速览:八个文件、一份身份

宅匠 3D 室内设计是一款跑在 OpenHarmony PC 上的家居户型设计工具:在 2D 画布上画墙、开门开窗、拉出房间多边形、摆放 25 种预设家具并实时测量,同时由 3D 视图把同一份数据渲染成可行走的立体户型,支持鸟瞰与漫游两种视角,能导出平面图与 3D 视图的 PNG,也可以把整套户型存成 .sh3d 文件。

它的身份配置如下:

== AppScope/app.json5 ==
  bundleName     "com.wei.zhaijiang3d"
  vendor         "wei"
  versionCode    1000000
  versionName    "1.0.0"
  icon           "$media:app_icon"
  label          "$string:app_name"
  description    -

四个地方各写了一遍显示名。把三套资源目录的名称项全部拉直——带宅匠字样的四行是显示名,其余是壳工程自带的占位文案:

== the four name sources ==
  AppScope/resources/base                              app_name             [宅匠 3D 室内设计]
  electron/src/main/resources/base                     module_desc          [module description]
  electron/src/main/resources/base                     EntryAbility_desc    [description]
  electron/src/main/resources/base                     EntryAbility_label   [宅匠 3D 室内设计]
  electron/src/main/resources/base                     BrowserAbility_desc  [description]
  electron/src/main/resources/base                     BrowserAbility_label [BrowserAbility]
  electron/src/main/resources/base                     button_ok            [ok]
  electron/src/main/resources/base                     button_cancel        [cancel]
  electron/src/main/resources/base                     StatusBarEntryAbility_desc [Application status bar]
  electron/src/main/resources/base                     StatusBarEntryAbility_label [Application status bar]
  electron/src/main/resources/base                     StatelessAbility_desc [description]
  electron/src/main/resources/base                     StatelessAbility_label [StatelessAbility]
  electron/src/main/resources/base                     OpenNewWindow_label  [OpenNewWindow]
  electron/src/main/resources/en_US                    module_desc          [module description]
  electron/src/main/resources/en_US                    EntryAbility_desc    [description]
  electron/src/main/resources/en_US                    EntryAbility_label   [宅匠 3D 室内设计]
  electron/src/main/resources/zh_CN                    EntryAbility_label   [宅匠 3D 室内设计]

关于最后一组,en_US 的 EntryAbility_label 值是"宅匠 3D 室内设计"而不是英文名。这在功能上不出错,只是英文环境下应用名仍显示中文;由于 PC 端当前不会切到纯英文资源目录,它没有可见后果。我把它记在文末待办里,不在正文里替它编造影响。

业务实现只有八个文件,全在 web_engine 模块的 resfile/resources/app 目录下。下面这张表列的是 app 根部的七个文件,第八个在下一层的 styles 目录里、叫 sweethome3d.css,它会以另一种身份出现在第十九节的许可证表里:

== app business files: size / lines / crlf ==
  OrbitControls.js            26375 B  crlf=0      lf=1045  
  home-engine.js              32673 B  crlf=982    lf=982   
  index.html                   6033 B  crlf=119    lf=119   
  main.js                      4172 B  crlf=107    lf=107   
  package.json                  219 B  crlf=8      lf=8     
  renderer.js                 48209 B  crlf=1376   lf=1376  
  three.min.js               603445 B  crlf=0      lf=6     

这七行里有两个条目值得先记住:three.min.js 只有 6 个换行却有 603,445 字节,是压缩过的第三方库;OrbitControls.js 有 1,045 个换行、全部是 LF。这两个文件恰好也是全目录里仅有的两个纯 LF 文件,而它们恰好都是第三方库;自己写的六个文件(含样式表)全是 CRLF。 这个对缝在第十九节会再被用到一次。(行数口径:表里的 crlf / lf 是换行符个数,编辑器显示的行数比它多 1;下文引用具体行号时用的是编辑器显示值。第十五节里 OrbitControls.js 的 1,046 是按行数统计的,对应这里的 1,045 个换行符。)

壳工程是标准的 Electron 双层结构,包名 com.wei.zhaijiang3d,vendor 是 wei,versionCode 1000000,versionName 1.0.0。这两个数字后面还会再出现一次。


三、三方权限集合对账:一致性驳回查无实据

要论证"源码声明的受限权限与 Profile 放行的受限权限不一致",需要三个数据源同时在场:源码侧的 requestPermissions 块、产物包内的 module.json、Profile 里的 acls。我把三者一次性拉出来做双向差集。

== 三方权限集合对账:源码 / 包内 / Profile ==
  project       source  package src-pkg   pkg-src   profile type / acls
  SweetHome3D   14      14      none      none      debug / acls=[]

这一行就是全部输出:源码 14、包内 14、双向差集为空、acls 为空、type 为 debug。这就把预设的驳回项彻底钉死:

  • 要触发 ACL 一致性审校,前提是源码侧至少声明一项受限权限。这里 14 项全是 normal 级别,没有一项需要 ACL 放行。
  • 要出现"不一致",前提是 Profile 的 acls 非空。这里这份 Profile 的 acls 是空列表,与"没声明受限权限"恰好互相印证。
  • 要写成"改完仍被驳回",前提是工程里有驳回记录。这里没有,所以本文不编。

顺便把"这一行干净"这件事本身读一遍:三项都是空差集,说明声明面这条链上没有手工改动留下的分叉。但它只是一种否定证据——它证明"没有不一致",推不出"能提审"。干净的对账只能帮你排除一种驳回,不能替你回答发布就绪。

第六节会把这一层翻出来。


四、一个权限块能数出四个数,而 reason 全空才是重点

预设角度虽然落空,但顺着 requestPermissions 往下数,第一个探针就撞出口径问题。

== 权限口径四档 ==
  全文权限名去重(含注释)    : 15  仅注释里的:['ohos.permission.WEB_NATIVE_MESSAGING']
  生效行命中(含重复)        : 15
  重复的那一项                : ['ohos.permission.LOCK_WINDOW_CURSOR']
  生效行去重                  : 14
  包内 module.json 声明       : 14
  包内带 reason 的项数       : 0 / 14
  包内带 usedScene 的项数    : 0 / 14

四个数字,四种口径,全都自洽:

  • 16:把整个 module.json5 当纯文本做正则命中的原始行数,含注释里那一行、含 definePermissions 里那次重复。
  • 15:跳过注释行之后的生效声明行数,仍含重复。
  • 14:生效行去重后的权限名数量。
  • 14:产物包内 module.json 的 requestPermissions 条目数。

这张表的第一行是 15,而紧跟着的分桶表第一行是 16,原因在取法:前者按权限名去重(含注释里那一个名字),后者按正则命中次数(同一个名字出现两次就算两次)。两个数都对,只是口径不同——这正是要把口径写清楚的必要:同一个块能数出四个数,而能直接进审校清单的只有最后那个。

重复的那一项是 LOCK_WINDOW_CURSOR,它在 requestPermissions 与 definePermissions 两个块里各出现一次——前者告诉系统"我要用这个权限",后者告诉系统"这个权限的申请理由文案在哪个字符串资源里"。两处都写是完全正常的;反过来,只在 definePermissions 里配了文案而 requestPermissions 里漏了声明,才是真问题,而且静态扫描通常只报"引用了未声明的权限字符串"这种间接症状。

源码侧的完整分桶长这样:

== web_engine/src/main/module.json5 : permission buckets ==
  raw regex hits (incl. duplicates/definePermissions) : 16
  active declaration lines : 15
  commented-out hits       : ['ohos.permission.WEB_NATIVE_MESSAGING']
  distinct active names    : 14
      ohos.permission.ACCELEROMETER   x1
      ohos.permission.ACCESS_BIOMETRIC   x1
      ohos.permission.ACCESS_CERT_MANAGER   x1
      ohos.permission.GET_FILE_ICON   x1
      ohos.permission.GET_NETWORK_INFO   x1
      ohos.permission.GYROSCOPE   x1
      ohos.permission.INTERNET   x1
      ohos.permission.LOCK_WINDOW_CURSOR   x2
      ohos.permission.PREPARE_APP_TERMINATE   x1
      ohos.permission.PRINT   x1
      ohos.permission.PRIVACY_WINDOW   x1
      ohos.permission.RUNNING_LOCK   x1
      ohos.permission.SET_ABILITY_INSTANCE_INFO   x1
      ohos.permission.WINDOW_TOPMOST   x1
  requestPermissions blocks : 1   definePermissions blocks: 1

还有一个更实用的口径:包内每一项有没有 reason 字段。

== 包内 module.json requestPermissions 的 reason 与 usedScene ==
entry count 30, uncompressed total 194774684 B
   libs/arm64-v8a/libelectron.so                  167471912 -> 167471912
   resources/resfile/icudtl.dat                    10464144 ->  10464144
   resources/resfile/resources.pak                  5777343 ->   5777343
   libs/arm64-v8a/libadapter.so                     2621200 ->   2621200

package module.json requestPermissions: 14 entries
   ohos.permission.INTERNET                   reason=False usedScene=False
   ohos.permission.GET_NETWORK_INFO           reason=False usedScene=False
   ohos.permission.ACCESS_CERT_MANAGER        reason=False usedScene=False
   ohos.permission.RUNNING_LOCK               reason=False usedScene=False
   ohos.permission.PRINT                      reason=False usedScene=False
   ohos.permission.PREPARE_APP_TERMINATE      reason=False usedScene=False
   ohos.permission.ACCESS_BIOMETRIC           reason=False usedScene=False
   ohos.permission.PRIVACY_WINDOW             reason=False usedScene=False
   ohos.permission.WINDOW_TOPMOST             reason=False usedScene=False
   ohos.permission.GYROSCOPE                  reason=False usedScene=False
   ohos.permission.ACCELEROMETER              reason=False usedScene=False
   ohos.permission.SET_ABILITY_INSTANCE_INFO  reason=False usedScene=False
   ohos.permission.GET_FILE_ICON              reason=False usedScene=False
   ohos.permission.LOCK_WINDOW_CURSOR         reason=False usedScene=False

14 项,reason 全 False,usedScene 全 False。没有 reason 就意味着这套声明走默认授权路径,系统不会在安装或首次使用时弹授权框。于是有一整类故障可以被排除掉:以前出现过"应用装上就崩、日志里是权限校验失败"的现象,根因是声明了需要用户授权的权限却没做运行时申请。宅匠这边不用真机就能把这一类关掉。

判据句:看权限会不会引发授权期失败,看的是包内每一项有没有 reason 字段,而不是权限名字里有没有 CAMERA、MICROPHONE 这类字样。 名字是给人看的,reason 是给系统看的。

至此配置面查完,结论是这一层确实没问题。真正的问题在下一层。


五、顺着 build-profile 往下挖:签名材料不在工程里

签名配置的入口永远是壳工程根下的 build-profile.json5。宅匠这份只有六十行,我把它的 material 段逐字段打出来(口令字段属于机器绑定的密文,脚本层直接屏蔽,不进文章):

== signingConfigs 脱敏对照 ==
  SweetHome3D  build-profile.json5 有 60 行,material 字段逐个:
    certpath       C:\Users\<you>\.ohos\config\defaul...2a0_VjdSM=.cer   [文件存在]
    keyAlias       debugKey
    keyPassword    <口令密文已屏蔽:本文件里这一段有 86 个字符,属于 DevEco 按工程维度加密的形态,不进入本文>
    profile        C:\Users\<you>\.ohos\config\defaul...2a0_VjdSM=.p7b   [文件存在]
    signAlg        SHA256withECDSA
    storeFile      C:\Users\<you>\.ohos\config\defaul...2a0_VjdSM=.p12   [文件存在]
    storePassword  <口令密文已屏蔽:本文件里这一段有 86 个字符,属于 DevEco 按工程维度加密的形态,不进入本文>
    buildModeSet  : ['debug', 'release']
    选模式的字段: 本文件里不存在

三个材料路径全部指向同一个目录:C 盘用户目录下的 .ohos\config。文件名是一串 base64 形态的哈希加扩展名,三种材料各一份:.p12 是密钥库,.cer 是证书,.p7b 是 Profile。

【配图位 2:资源管理器截图。左侧是工程目录树里的 build-profile.json5,右侧是 C 盘用户目录下 .ohos\config 里成组的 p12、cer、p7b,文件名是长哈希形态。要能看出这些材料在工程之外。】

这一眼给出两条信息,第二条是后面所有推理的起点。

第一条:路径里没有工程名。文件名由哈希决定,三种材料共用同一串哈希。这个目录是 DevEco 自动签名的共享缓存——每对一个新工程点一次自动签名,工具就会在这里生成一套新哈希命名的材料。所以材料落没落在这个目录里,本身就是"它是不是被自动签名刷出来的"的判据,不用猜意图,只看路径。这也是第六节主表里 origin 那一列的来源。

第二条,也是关键的一条:这些文件在工程之外。 工程目录可以进版本库,签名材料留在本地用户目录里,clone 下来的仓库没有它。这本身是正确做法,但带来一个后果——当材料被自动签名流程原地刷新时,工程的历史里不会留下任何痕迹。要追这个痕迹,只能靠文件系统时间戳和 p7b 内部的字段。

顺带把同一份文件里另一处也看清:buildModeSet 声明了 debug 与 release 两项,但没有任何字段写"本次构建用哪一项"。这个空白在第十二节会变成一个解释。

于是有了第八节的两刀。


六、四项字段合起来:这套材料不是为发布准备的

把 build-profile.json5 与它指向的 Profile 一起读出来,主表只有一行:

== 发布态体检主表 ==
project       keyAlias    type    acls   devices  origin material     product      perms s/p   bundle 三处一致
SweetHome3D   debugKey    debug   0      1        auto   10-27 12:36  10-14 13:21  14/14       yes

【配图位 3:终端里跑交付脚本的截图,重点拍这一行的四列:keyAlias=debugKey、type=debug、devices=1、origin=auto。】

这四项合起来讲同一件事:这套材料不是为发布准备的。 逐条对上号:

  • keyAlias 叫 debugKey,是 IDE 自动签名生成的密钥别名,名字本身就把用途写明了;
  • Profile 的 type 是 debug,对应开发态授权,上架需要的是 type 为 release 的 Profile;
  • debug Profile 里带 debug-info 段,其中 device-ids 有 1 项——这个包只授权给一台具体设备,换设备、换用户都会被拒;
  • origin 是 auto,指材料落在自动签名缓存目录里;手动准备的发布材料不在这里。

这一条比第三节的空差集更值得写进结论,因为它是可提审性的直接判据:一个工程能不能提审,不看它构建得成不成功,看的是签名材料是不是 release 态、有没有绑设备。 这四项全在 p7b 里,两分钟就能读完;而它们的后果(装上就失败、换设备装不上)在本地几乎不可能被自然发现,因为你自己的设备永远在那台被授权的设备列表里。


七、把 Profile 拆开看:四项字段自证不能提审

第六节那四项的来源并不相同:keyAlias 读的是 build-profile.json5,type 与 debug-info 读的是 Profile,origin 则由材料所在目录推断。宅匠这份 p7b 解析出来只有 11 个顶层键:acls、baseapp-info、bundle-info、debug-info、issuer、permissions、type、uuid、validity、version-code、version-name。与发布就绪有关的是其中五个键(type、debug-info、acls、permissions、validity),可以归成四条判据:

== 宅匠 Profile 的字段解剖 ==
  type                 debug
  issuer               app_gallery
  acls                 []
  permissions          {}
  debug-info           present  device-id-type=udid  device-ids=1
  validity             not-before 2026-10-27 12:26:40 -> not-after 2027-10-27 12:26:40  跨度 365.0 天

四项判据都能在这一段里直接读出来:

  • type 是 debug:这是开发态授权,上架需要的是 type 为 release 的 Profile,两者在签发入口上不是一回事;
  • debug-info 里 device-ids 只有 1 项、类型是 udid:这个包只授权给一台具体设备,换设备、换用户都会被拒;
  • acls 是空列表、permissions 是空对象:这份 Profile 没有额外放行任何受限权限,与源码侧 14 项全是 normal 恰好对上;
  • validity 正好 365 天:一年期意味着材料本身会在下一年过期,而产物不会自动跟着重签。

把 debug 与 release 两种 Profile 摆在一起,差别不止 type 一个字段:

字段debug Profilerelease Profile
typedebugrelease
证书用途开发证书(development)分发证书(distribution)
debug-info(含 device-ids)存在,绑定具体设备不存在
validity 跨度一年按签发策略,通常数年
acls空列表由 AGC 侧单独申请后写入
permissions空对象通常仍为空
apl(应用侧声明的权限等级)normalnormal

表格最后三行同样重要:release 形态并不会顺手把 acls 填上,也不会改 apl。 从 debug 切到 release 只解决"授权给设备"变成"授权给分发渠道"这一件事。受限权限那条路要么本来就不走(宅匠这 14 项全是 normal,就不走),要么必须在 AGC 上单独申请 ACL 并把它们写进 release Profile。把这两件事混为一谈,就是"改成 release 就能上架"这个错觉的来源。

validity 的起止已经写在上面了:2026-10-27 到 2027-10-27。下一节要问的不是它有多长,而是它的起点与产物时间谁先谁后。

顺手补一条与直觉相反的观察:这份材料的四个字段都指向"不能提审",可它又是工程里唯一一份被 build-profile.json5 引用的材料。"配置指向它"和"它能用于发布"是两件事,前者只说明构建能跑起来。这也是我把它逐字段拆开、而不是看一眼 keyAlias 就下结论的原因。


八、时序对账的两刀:文件时间,然后证书生效时刻

第一刀看 mtime。把 build-profile.json5、它引用的三个材料文件以及两个产物文件的时间取出来排一遍:

== 材料时间 vs 产物时间 ==
  SweetHome3D   material=10-27 12:36  product=10-14 13:21(app)  -> 产物早于材料(差 13.0 天)

两条事实藏在里面:

  1. build-profile.json5 与三个材料的 mtime 都落在 2026-10-27 12:36;
  2. 外层产物的时间是 10-14 13:21,比材料早 13.0 天。

材料是 12:36 写下的,产物是 13 天前的。这不是"先有材料后有产物"的正常顺序,而是"材料被原地刷新过"。注意 p7b 是哈希命名:如果哈希不变而内容重签,新 Profile 就写进同名文件,原地覆盖。 被覆盖掉的那份,才是这个产物的实际签名者。所以手上这份 p7b 与外层产物没有任何因果关系。

第一刀的结论是:产物早于材料,说明当前配置描述的不是当前产物。它的实操含义很硬——任何人现在 clone 这个仓库,按 build-profile.json5 找到 p7b,解析出 device-ids 或 acls,然后拿去解释"为什么这个包只能装在一台设备上",是错的。

但 mtime 是最容易反驳自己的一种证据:文件被复制、被同步工具搬运、被 touch,时间都会变。所以要有第二刀。

== 第 2 判据:产物 mtime 对比 Profile 自身的证书生效日(not-before)==
  SweetHome3D   product=2026-10-14 13:21(app)  not-before=2026-10-27 12:26  not-after=2027-10-27  -> 产物早于证书生效日 差距 13.0 天 -> 这份 Profile 不可能是签名用的那份

Profile 是可解析的结构化文本,里面有一段 validity,not-before 就是这份 Profile 的生效时刻。它由签发方写进内容,不是文件系统元数据,想改它得重新签发。把产物时间与它比较:外层产物早于这份 Profile 的 not-before 13.0 天。

判据句:产物的时间戳如果早于这份 Profile 的生效时刻,那么这份 Profile 与这个产物的签名无关——不是"可能不是",是不可能是。

两条判据叠起来还露出一个细节:Profile 的 not-before 是 12:26:40,而它引用的三个材料文件与 build-profile.json5 是 12:36 落盘的。签发时刻写在内容里,落盘时刻写在文件系统里,两者相差 10 分钟——这 10 分钟就是自动签名从签发到写回的全部耗时。 想让文件时间提前 13 天很容易,想让 not-before 也提前 13 天则必须重新签发一份。

如果故事到这里结束,结论就是"材料覆盖了产物、历史不可恢复",一条虽然有用但相当简单的经验。真正把它掀起来的是下一节:我把取数范围从一条路径扩大到整个工程目录。


九、探针自己的盲区:磁盘上其实有两个签名产物

第八节用的是"外层 app 优先"这一条取数路径。它在宅匠上取到了值,所以看起来没有缺口——这正是问题所在:一条取到了值的路径,会让人以为它是唯一的一条。

把范围从"脚本认的那一条路径"扩大到"工程目录下所有已签名文件",第一个输出是:

== 只看脚本原先认的那一条路径 ==
  取到的产物  ohos_hap/build/outputs/default/ohos_hap-default-signed.app  87400818 B  10-14 13:21
  工程内其它 signed 落点:['ohos_hap/electron/build/default/outputs/default/electron-default-signed.hap']

路径有值,但只覆盖了一半。一个只找了一条路径的探针,比一个不跑的探针更危险,因为它的结论看起来是完整的。 判据句:任何"缺失"类判定,都要先证明自己在所有可能的落点上找过;任何"唯一"类判定,也要先证明另一个落点真的不存在。

把范围放大之后,真实落点是这样:

== 工程磁盘上真实存在的签名产物落点 ==
  SweetHome3D   2 个 signed 产物
      app          87400818 B  10-14 13:21  ohos_hap/build/outputs/default/ohos_hap-default-signed.app
      hap         197399137 B  10-27 12:37  ohos_hap/electron/build/default/outputs/default/electron-default-signed.hap

真正的信息量在这一张表里:两个已签名产物,落在两个不同路径:

  • 外层:ohos_hap/build/outputs/default/ohos_hap-default-signed.app,87 MB;
  • 内层:ohos_hap/electron/build/default/outputs/default/electron-default-signed.hap,197 MB。

而它们的时间分属两批:外层 10-14,内层 10-27。把三列时间并排打出来:

== 同一次签名的两个落点:外层 app 的时间 vs 内层 hap 的时间 ==
  SweetHome3D   bp=10-27 12:36  app=10-14 13:21  hap=10-27 12:37  -> hap 晚于 app 13.0 天

内层 hap 比外层 app 晚 13.0 天,而它与 build-profile.json5 的时间只差 1 分钟(12:36 写配置、12:37 出 hap)。10-27 那一轮,内层 hap 被重新构建并签名了,外层 app 没有重建。

这直接改写第八节那条结论的适用范围:

  • 第八节说"现存材料不是产物的签名者",对外层 app 成立;
  • 但对内层 hap 不成立——那份 hap 就是当前材料签的,时间关系完全正常。

所以"材料覆盖产物"这个说法要精确成:“当前材料覆盖了它最近一次构建的产物,但工程里准备提交的那个外层产物是上一轮的。”


十、两个 hap 差 2.6 MB:条目级摊开

发现同一工程有两个签名 hap 之后,第一个问题不是"时间为什么不一样",而是"它们是不是同一个东西的两个副本"。如果内容相同,那只是一份产物被复制到两处;如果内容不同,问题就大了。

把外层 app 里嵌的那个 hap 取出来,与磁盘上内层那个 hap 做 zip 条目级对账:

== app 内嵌的 hap 与磁盘上那个 hap,是不是同一个东西 ==
  SweetHome3D   内嵌     194779030 B / 磁盘     197399137 B  差 +2620107 B
               条目数 内嵌 30 / 磁盘 32  仅内嵌 none  仅磁盘 ['.pages.info', 'ets/sourceMaps.map']
               同名但 uncompressed 不同的条目数:3  ['ets/modules.abc', 'module.json', 'pack.info']

两列口径要说清:左边的 194,779,030 B 是 app 内嵌 hap 的文件大小,右边 197,399,137 B 是磁盘 hap 的文件大小,两者差 2.62 MB;把它按条目摊开,未压缩数据总量只差 1.18 MB,剩下的差额来自 zip 头与对齐。条目数多 2 个,多出来的两个名字是 .pages.info 与 ets/sourceMaps.map;同名但字节数不同的条目只有三个:ets/modules.abc、module.json、pack.info。

拿宅匠摊开到文件级:

== 拿 SweetHome3D 把差异摊开到文件级 ==
  .pages.info                                                内嵌 -           磁盘 21384      
  ets/modules.abc                                            内嵌 914216      磁盘 1500928    
  ets/sourceMaps.map                                         内嵌 -           磁盘 572033     
  module.json                                                内嵌 3636        磁盘 3633       
  pack.info                                                  内嵌 1089        磁盘 747        
  其余 27 个条目字节数完全相同(共 30 / 32)

  module.json 是否逐字节相同:False
  module.json 的 requestPermissions 项数:内嵌 14 / 磁盘 14
  内嵌 hap 与当前源码的权限差集:none
  磁盘 hap 与当前源码的权限差集:none

内嵌那侧 30 个条目、磁盘那侧 32 个条目,两边共有的 30 个里 27 个字节数完全相同——包括 libelectron.so、icudtl.dat、resources.pak、全部 resfile 下的业务文件。真正不同的只有 5 项,全部集中在 ArkTS 编译产物与包描述这一段。 而权限块在两个 hap 里都是 14 项,且与当前源码双向差集为空——也就是说这两份产物在声明面上完全等价,差异纯粹由构建模式造成。

这五项的含义是:

  • ets/sourceMaps.map:ArkTS 源码到字节码的符号映射,572,033 字节。发布包不该带它,它的作用是让崩溃栈能还原回源码行号;
  • ets/modules.abc:ArkTS 编译出的字节码,从 914,216 涨到 1,500,928 字节,涨 64%;
  • .pages.info:21,384 字节的页面索引信息;
  • module.json 与 pack.info:包描述本身,差 3 字节与 342 字节。

同一份源码,编译出两种字节码尺寸。 这已经不是时间戳的问题,而是构建模式的问题。


十一、判决字段:两个 hap 自己写着 release 与 debug

模块描述文件里其实自带答案,而且不需要任何推断。先做一个最省事的检查——两个 module.json 是不是逐字节相同;如果不是,把差异键逐个列出来:

== module.json 那 3 字节的差在哪 ==
  顶层 app          内嵌 {"iconId": 16777217, "debug": false, "targetPatchAPIVersion": 0, "conf / 磁盘 {"iconId": 16777217, "debug": true, "targetPatchAPIVersion": 0, "confi
  逐项比对 app 子对象里所有不同的键:
    buildMode                内嵌 release / 磁盘 debug
    debug                    内嵌 False / 磁盘 True
  • buildMode:app 内嵌的那个值是 release,磁盘那个是 debug;
  • debug:app 内嵌 false,磁盘 true。

顶层只有 app 这一个子对象有差异,module 子对象一个字段都没变。 换句话说,两个 hap 的 abilities、权限、入口、设备适配全同,唯一被构建模式改动的是包级的那两个标志位。

再把这个判断落回具体数字:

== 判决字段:两个落点各自 module.json 里的 app.debug ==
  project       app 内嵌 hap     磁盘 hap         modules.abc 内嵌/磁盘        sourceMaps.map
  SweetHome3D   debug=False    debug=True     914216 / 1500928         内嵌无 / 磁盘有 572033

内嵌那份 debug=False、磁盘那份 debug=True;modules.abc 从 914,216 涨到 1,500,928;sourceMaps.map 只有磁盘那份有。这不是推断,是产物自己声明的。

于是三个先前各自孤立的观察一次串起来:

  1. 时间上,外层是 10-14、内层是 10-27;
  2. 体积上,内层大 2.62 MB、多两个条目;
  3. 语义上,内层是 debug 态、外层是 release 态。

判据句:判断一个 hap 是发布态还是调试态,不要看它在哪个目录下、不要看文件名、也不要看时间,解出 module.json 读 app.buildMode 与 app.debug 这两个字段。 这一条比前面所有的时序推理都可靠,因为它读的是构建系统写下的结论,而不是外部证据。

顺手也解释了第十节里那个 3 字节的悬念:内嵌那份 3,636 字节、磁盘那份 3,633 字节,差的 3 字节正好由上一段那两个字段构成——buildMode 从 release 变成 debug 少 2 字节,debug 从 false 变成 true 少 1 字节,合起来恰好 3 字节,与文件体积差严格相等。一个 JSON 布尔字面量加一个模式字符串的长度,成了两种构建模式在字节级的签名。

【配图位 4:资源管理器并排窗口截图。左:宅匠 ohos_hap\build\outputs\default 下的 .app;右:同工程 ohos_hap\electron\build\default\outputs\default 下的 .hap。要能同时看出两个路径、两个扩展名、以及相差近两周的修改时间。】


十二、模式从哪来:"半次构建"的成因,和它的实操后果

为什么会出现外层旧、内层新?先把"配置里写了模式"这个直觉验一遍——把本工程 build-profile.json5 里 buildMode 与 buildModeSet 两个词的出现次数数出来:

== 构建模式是从配置里读来的吗:build-profile.json5 的 buildMode 计数 ==
  SweetHome3D   buildMode 出现 1 次, buildModeSet 1 次, 文件 60 行

这一行数字纠正了我自己上一版的错误。我最初搜的是带引号加冒号的完整键名,形如 “buildMode”:——它在这份文件里确实一处都没有,于是我下了"模式完全不在配置里"的结论;重跑时改成裸词计数,命中一次,而命中的那一行是第 17 行的 “buildModeSet”: ——一个声明"这个工程允许哪几种模式"的清单(debug 与 release 两项),而不是"本次用哪一项"的选择器。父串与子串刚好重合,带引号的键名搜索会把"没有选择器"错报成"这个词不存在"。

准确的表述是:build-profile.json5 只声明模式集合,不声明当次模式;products 段里也没有 buildMode 字段。 所以当次构建的模式来自 IDE 的构建入口(Run/Debug 走 debug,Build Hap/App 的 release 走 release),而不是来自仓库里任何一个可 review 的文件。这就解释了两个产物的全部形态:

  • 10-27 那一轮做的是"打开工程、触发自动签名、跑一次调试构建"。调试构建会把 ArkTS 编成带调试信息的 modules.abc、生成 sourceMaps.map 与 .pages.info、写出 debug 态的 module.json,并对内层 hap 签名。它不会重新组装外层 app;
  • 而外层 app 是更早那一轮(10-14)用 release 构建打出来的,里面嵌的是当时那份 hap。

于是这个工程的真实状态是:手上两个已签名产物,一个是发布态、一个是调试态,且两者文件名都不含任何模式标记,唯一区分方式是产物内部的 buildMode。

它的实操后果按严重程度排:

  1. 谁要把内层 hap 直接拖去提审或装机测试新版本,得到的是一个 debug 包:绑设备、带符号映射、buildMode 为 debug。审校侧命中"不允许提交调试构建产物"这类项目;本地侧表现为"只有我这台设备能装",很容易被误判成设备信任问题。
  2. 谁改了业务代码之后只跑了调试构建,然后拿旧的外层 app 去提审,提审包里没有他的改动——因为外层 app 不会自动重建。这是最容易发生也最难在本地发现的一种"改了但没生效"。
  3. 谁按现在的 build-profile.json5 去解析 Profile 解释旧 app 的行为,会在 device-ids 上得出一个与事实无关的答案(第八节)。

三条同一个根:这个工程链路上有两个签名落点,而构建动作只刷新其中一个。 反过来说,正确流程只需要一句话约束:提审前必须跑一次 release 态的整体打包,然后用 module.json 的 buildMode 字段复核你即将提交的那个 app 里嵌的 hap。 这句话的每一个字都有上文支撑。


十三、Profile 到底在不在产物里:一次子串搜索的自我翻案

前文推理有一半建立在"Profile 与产物是分离的两样东西"上。这个假设值得单独验一次,因为很多文章会顺手写成"签名信息就在包里"。

验证方式很笨:直接把产物当字节流做子串搜索。Profile 是 JSON 文本,如果它被打进包内,那么它的字段名一定会以明文出现。我挑了五个只在 Profile 里存在、在 module.json 里不存在的键名,加上一批签名容器的常见 magic(acls 这个键太短,全 STORED 的二进制里难免撞上同名子串,没有拿它当判据):

== 全文件 magic 搜索(子串命中的假阳性也在这里暴露) ==
magic candidates anywhere in the file:
  b'OHOS'        140 hits, first three [13528, 58771, 81996]
  b'HAP'         8 hits, first three [22453700, 22619697, 22640577]
  b'Sign'        1210 hits, first three [63486, 86255, 86385]
  b'sign-block'  0 hits, first three []
  b'\x00OHOS'    12 hits, first three [13527, 302385, 2634788]

看到 140 次 OHOS、1210 次 Sign 的时候,我第一反应是"找到了,签名块果然在包里"。这个反应只对了半秒。因为第二张表把命中位置摊开之后,事情完全反过来:

== 这些命中分别落在哪个条目上 ==
  b'BEGIN CERTIFICATE'  147 hits -> [('libs/arm64-v8a/libelectron.so', 147)]
  b'OHOS'             140 hits -> [('libs/arm64-v8a/libelectron.so', 113), ('libs/arm64-v8a/libadapter.so', 24), ('libs/arm64-v8a/libc++_shared.so', 1)]
  b'Sign'            1210 hits -> [('libs/arm64-v8a/libelectron.so', 1107), ('resources/resfile/locales/en-US.pak', 87), ('libs/arm64-v8a/libadapter.so', 7)]
  STORED (uncompressed) entries: 30 / 30, largest (167471912, 'libs/arm64-v8a/libelectron.so')

147 处 BEGIN CERTIFICATE 全部落在 libs/arm64-v8a/libelectron.so 一个条目里;Sign 的 1210 处里 1107 处在同一个 .so,87 处在 en-US.pak(那是 Chrome 策略模板里的字符串);OHOS 的 140 处分三个 .so。 没有一个命中属于签名容器。原因也很直白:这个 hap 的 30 个条目全是 STORED,压缩率 0%,所以任何在 .so 里出现的字符串都会原样出现在 hap 文件字节里,子串搜索在这种包上等价格林扫描二进制资源。

再补最硬的一刀——把五个 Profile 独有键名的命中数与包尾部结构一起打出来:

hap bytes 194779030
  bundle-info            0
  app-identifier         0
  developer-id           0
  BEGIN CERTIFICATE      147
  profile                601
  version-name           0
  not-before             0
  hap-signature          0
  META-INF               0
  SignatureBlock         0
  coding-block           0

entries 30  last header_offset 194775478  EOCD at 194779008  tail after EOCD 0 B

bundle-info、app-identifier、developer-id、version-name、not-before 五个键名各 0 命中;EOCD 之后剩余 0 字节,说明签名不是以"追加段"的形式挂在包尾的。profile 那 601 处命中是通用英文词——Chrome 的 profile 目录、用户配置之类,散落在 .so 与 pak 的字符串里,同样没有归属价值。

于是有两条结论,一条比一条更值得记:

  • "Profile 的明文内容不在产物里"是稳的。 五个独有键名在 194,779,030 字节里 0 命中,而唯一的证书字样都能一一对应到第三方 .so 的静态字符串。
  • 我没找到这个包的签名块实际落在哪。 有两种读法:签名在外层 app 这一级完成,或者签名块用的是我关键字表覆盖不到的容器格式。我没有继续往下拆,因为本文的推理链不需要这个答案。但我把它作为一条未验证项记在文末,而不是假装已经查清。

判据句:子串搜索在"全 STORED 的二进制资源包"上没有证据价值。要用它证明什么存在,必须先证明每个命中能归属到哪个条目;要用它证明什么不存在,必须先给自己一组"只有目标物才有、别处绝不会有"的独有串,并说明搜索的落点范围。 我第一版脚本两头都没做,所以它给了我一个假阳性。


十四、"产物为唯一真相源"这句话的作用域边界

有一条流传很广的判据:别信配置,信产物。这一篇差点被这条判据带沟里——如果我照它执行,就会拿内层那个 debug hap 的 module.json 去解释"这个工程是调试态",然后得出一个正确但没用的结论(因为外层那个 release hap 也是同一个工程的产物)。

所以这条判据需要一个作用域版本。把这个工程里能回答发布就绪问题的字段按"谁能回答"分成三栏,是我这次真正想留给读者的东西:

想确认的事唯一可靠来源为什么另一个来源不可信
包名、版本号、abilities、权限声明、资源引用产物内 module.json 与 pack.info源码 module.json5 可能已改动而未重新构建
本次产物是 debug 还是 release产物内 module.json 的 app.buildMode / app.debugbuild-profile.json5 只声明模式集合,不声明当次模式
有没有绑设备、acls 放行、证书有效期、Profile 类型配置指向的那份 p7b这些字段在产物里 0 命中(第十三节),包里查不到
这个产物究竟是哪套材料签的没有可靠来源p7b 会被自动签名原地覆盖,第八节两刀只能证伪、不能证实
外层 app 里的改动是不是最新的app 内嵌 hap 与磁盘内层 hap 的时间差与条目差只有构建日志才知道,而日志不入库

前三行是"能查的",第四行是"查不到但必须承认查不到",第五行是"这次才发现必须查的"。

判据句:"信产物"的适用范围是声明面,授权面必须回工程外的 Profile;当两边都答不上来时,比的不是谁更可信,而是时间戳与证书有效期谁先谁后。 一句判据如果不写清它管什么、不管什么,下一次遇到同类问题时它就会变成新的教条。

顺带把第六节主表的最后一列在这儿收口:外层 app 的 bundleName(com.wei.zhaijiang3d)、versionCode(1000000)与 versionName(1.0.0)在 app.json5、pack.info 与 Profile 三处一致,说明声明面这层生成路径统一且没有漂移;而它在模式上与当前配置脱节,说明授权面与时序这层没人管过。同一条工程链路上,被工具固化的部分一定一致,没被工具覆盖的部分一定全裸。


十五、渲染链路:主进程一行关硬件加速,渲染进程三级降级

签名这条主线之外,宅匠本身有一条很值得写的适配链,因为它解释了这个工程为什么能在没有 GPU 的鸿蒙 PC 环境里把 3D 视图跑起来。

起点在主进程:

app.disableHardwareAcceleration();
// 禁硬件加速后允许软件 WebGL 回退,供 Three.js 在无 GPU 环境创建渲染上下文
try { app.commandLine.appendSwitch('enable-unsafe-swiftshader'); } catch (e) { /* 老版本无此开关时忽略 */ }

第一行是全局决策:关掉硬件加速。这是 Electron 壳在鸿蒙适配层上避掉 GPU 进程问题的标准动作,代价是 WebGL 上下文可能创建不了。第二行是它的补丁:允许软件 WebGL 实现(SwiftShader)。注意它是包在 try 里的一行——老版本 Electron 没有这个开关,写死了会直接崩在启动阶段。

然后是渲染进程侧的三级降级。第一级看全局对象,第二级看上下文能否创建,第三级看构造过程是否抛异常:

function initThree() {
  if (typeof THREE === 'undefined') { initSoft('未加载 Three.js'); return; }
  if (!webglAvailable()) { initSoft('WebGL 不可用'); return; }
  try {
    var width = el.threedContainer.clientWidth || 400;
    var height = el.threedContainer.clientHeight || 400;
    var renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
    renderer.setSize(width, height);
    el.threedContainer.appendChild(renderer.domElement);
    el.threedHint.style.display = 'none';

    // 软件 WebGL(SwiftShader/llvmpipe)下关闭高开销画质项,硬件环境启用完整画质
    var lowSpec = detectSoftwareGL(renderer);
    if (!lowSpec) {
      renderer.outputEncoding = THREE.sRGBEncoding;
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1.05;
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    }

三级任何一级不过,都会落到 initSoft:一个 Canvas 2D 的等轴测实现,自己画墙、家具和地面,不碰 WebGL。

function initSoft(reason) {
  state.three.ok = false;
  var canvas = document.createElement('canvas');
  canvas.id = 'softCanvas';
  el.threedContainer.appendChild(canvas);
  state.soft.ok = true;
  state.soft.canvas = canvas;
  state.soft.ctx = canvas.getContext('2d');
  el.threedHint.style.display = 'none';
  resizeSoft();
  bindSoftEvents();
  renderSoft();
  setStatus('3D 软件渲染(' + reason + '):拖拽旋转视角,滚轮缩放');
}

而第二级与第三级都过了、但底层是软件实现时,还有一层画质自适应:

// 检测软件 WebGL 实现(SwiftShader/llvmpipe),据此降级高开销画质项
function detectSoftwareGL(renderer) {
  try {
    var gl = renderer.getContext();
    var dbg = gl.getExtension('WEBGL_debug_renderer_info');
    var name = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
    return /swiftshader|llvmpipe|software|basic render/i.test(name);
  } catch (e) { return false; }
}

detectSoftwareGL 用 WEBGL_debug_renderer_info 读真实渲染器名字,命中 swiftshader、llvmpipe、software、basic render 四个特征之一就把 lowSpec 置真;lowSpec 为真时,sRGB 输出编码、ACESFilmic 色调映射、阴影贴图与 PCFSoft 阴影这四组开销项全部不启用,同时把半球光从 0.95 抬到 1.2、主平行光从 1.1 压到 0.75、补光从 0.4 压到 0.25——软件光栅化没有阴影补偿,只能靠提高环境光保住可见性。这段调参是"关掉效果但保证能看清"的教科书写法。

整个渲染链的静态普查结果:

renderer.js lines: 1377
  WebGLRenderer                1  [333]
  preserveDrawingBuffer        1  [333]
  toDataURL                    3  [866, 880, 881]
  antialias                    1  [333]
  OrbitControls                2  [395, 396]
  PerspectiveCamera            1  [351]
  OrthographicCamera           0  []
  requestAnimationFrame        2  [71, 1286]
  canvas.getContext            2  [54, 436]
  Roaming                      0  []
  swiftshader                  1  [416]
  addFloor                     0  []
  Vector3                      0  []
  BoxGeometry                  1  [784]
  CylinderGeometry             1  [797]
  SphereGeometry               1  [809]
  ConeGeometry                 1  [821]
  MeshLambertMaterial          2  [745, 746]
  PCFShadowMap                 0  []
  shadowMap                    2  [344, 345]
  ipcRenderer.invoke           4  [833, 854, 870, 883]
  addEventListener            34  [37, 57, 164, 169, 213, 222, 226, 227, 228, 229, 230, 231]
  onclick                      0  []

== renderer.js: the 3D bootstrap block ==
   333| var renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });

three.min.js chars=603445  REVISION=not found
   THREE            533
   WebGLRenderer    82
   SwiftShader      0
   OrbitControls    0

OrbitControls.js chars=26375  lines(LF)=1046  CRLF=0
   THREE.OrbitControls assignment: True
   header:
      ( function () {
      
      // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
      //
      //    Orbit - left mouse / touch: one-finger move
      //    Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish

三个数字值得单独看。WebGLRenderer 命中 1 次(L333),preserveDrawingBuffer 与 antialias 都在同一行——preserveDrawingBuffer 是导出 3D 视图 PNG 的前提:不保留缓冲区,toDataURL 拿到的就是空图。这解释了 toDataURL 为什么有 3 处调用而只有一处创建渲染器。OrthographicCamera 命中 0 次,3D 视图只用透视相机,鸟瞰与漫游靠相机位置切换而不是投影方式切换。Roaming 命中 0 次,漫游不是独立渲染模式,只是同一套场景的另一种相机控制。

还有一条不太好听的记录:three.min.js 里我没能定位 REVISION 常量(普查表里那行 revision=not found),603,445 字符的压缩产物里唯一可辨识的归属信息是文件头三行注释。也就是说这个工程无法自查它带的到底是 three.js 哪个版本,只能在待办里补一条"版本可辨识性"。

【配图位 5:同一户型在两种渲染路径下的对比截图。左:硬件 WebGL 路径下的 3D 视图(有阴影、色调映射);右:强制走 initSoft 后的 Canvas 2D 等轴测视图,底部状态栏要拍到"3D 软件渲染(……)"的提示文字与降级原因。】


十六、把 home-engine.js 从 Electron 里摘出来跑

上面那些都是静态读代码。这一节开始动手:能不能不装包、不开窗,就把业务逻辑单独体检一遍?

home-engine.js 是全工程唯一的数据与几何核心(编辑器显示 983 行),它被 index.html 用普通 script 标签加载。我把它里面的 document、window、require 全数了一遍:子串命中 17 处、分布在 14 行里,而这 14 行没有一行真的引用全局对象——13 行是数据桶 windows 的属性访问,剩下 1 行是样式项 opt.windowColor;改用完整单词边界重数一遍,document、window、require 三个词各 0 处。需要画布上下文的地方(九个 draw 开头的绘制函数与 renderFloorPlan)都把 ctx 当参数接。这正是它能被摘出来的原因。

我在 Node 里直接读文件、eval 出模块对象,然后把十三组断言跑完。唯一需要 ctx 的 renderFloorPlan 我没有伪造上下文去调它,只把它的参数签名打出来——这个取舍的代价在下一段。脚本最后打印的那行汇总是它的自检:ok=14,DIFF=0(十三组断言里有 1 组含两处检查),也就是所有断言都命中,没有一条被实测推翻。

先看导出面,因为它决定了"哪些行为只能间接观察":

== 1. 导出面:28 项里哪些真的能无头调用 ==
  top-level functions declared : 39
  exported keys                : 28
  exported functions           : 25
  non-functions                : FURNITURE_PRESETS, FURNITURE_CATS, WALL_HEIGHT  (WALL_HEIGHT=250)
  declared but NOT exported    : 14 -> genId, pointToSegmentDist, getProjectionT, drawGrid, drawRoom, drawWall, drawDoor, drawWindow, drawFurniture, drawDimension, drawText, drawSelection, validNum, pickValid
  needs a ctx object           : renderFloorPlan only -> arity=3, params=ctx, data, options

39 个顶层函数只导出 25 个函数加 3 个常量,剩下 14 个(genId 与九个 draw 开头的绘制函数、两个数值守卫、两个几何辅助)是模块私有的。这意味着绘制逻辑整体在单测不可达:想验证它,只能通过 renderFloorPlan 这一个入口,或者去真机看画面。这也是我在待办里给"绘制层可测性"单独记一条的原因。

入参守卫的结果是干净的,两类非法输入都返回 null 而不是抛异常:

== 3. 入参守卫:谁返回 null ==
  ok   addRoom 两点  got=null
  ok   addRoom 三点  got="object"
  ok   addFurniture 未知 type  got=null
  ok   addFurniture 已知 type  got="object"
  addWall 产物       : {"id":"el_2","start":{"x":0,"y":0},"end":{"x":1000,"y":0},"thickness":10}
  addDoor 产物       : {"id":"el_4","wallId":"el_2","position":0.5,"width":80}

持久化这一对是这个工程最容易被用户碰到的一段,往返一致是底线:

== 9. serialize / deserialize 往返 ==
  序列化字节数=844 行数=61(indent=2)
  往返后再序列化是否逐字节相同 : true
  往返前/后字节数            : 844 / 844
  ok   往返后 nextId  got=5

而 3D 侧的数据构造是纯函数,不依赖 THREE,这就解释了为什么 initSoft 那条降级路径可以完全独立工作:

== 12. buildSceneData:纯数据 3D 描述 ==
  返回桶          : boxes=7  cyls=0  sphs=0  cones=0  floors=1
  墙盒            : {"w":400,"h":250,"d":10,"x":200,"y":125,"z":0,"rotY":0,"color":"#b8b8c4"}
  门盒 y=100 h=200 / 窗盒 y=150 h=120 opacity=0.5
  窗顶=210 窗底=90 墙高=250
  门窗指向不存在的墙时 boxes=0(findWallById 失败即 continue)
  长度 0.5cm 的墙 boxes=0(len<1 被跳过)

五桶结构(boxes / cyls / sphs / cones / floors)里,这个户型只命中 boxes 与 floors——家具预设全部由盒子拼成。长度小于 1 cm 的墙直接跳过,门窗如果宿主墙找不到就 continue,这两条守卫让 buildSceneData 在脏数据下不会崩。

无头体检本身不产出新故事,它产出的是下一节那个 bug 的定位方式:一个 20 分钟就能跑完、不需要设备、不需要打包的探针,能替你回答"这个几何函数到底假设了什么"。


十七、hitTest 不知道 rotation:十厘米的错位带

数据模型里家具有 rotation 字段(0/90/180/270),2D 绘制确实按它旋转。下面两段都摘自 home-engine.js:

function drawFurniture(ctx, furn, opt) {
  var preset = FURNITURE_PRESETS[furn.type];
  if (!preset) return;

  ctx.save();
  ctx.translate(furn.x, furn.y);
  ctx.rotate(furn.rotation * Math.PI / 180);

  var hw = furn.width / 2, hh = furn.height / 2;

  ctx.fillStyle = preset.color;
  ctx.globalAlpha = 0.6;
  ctx.fillRect(-hw, -hh, furn.width, furn.height);

而命中测试用的是另一套几何。同一个函数里紧接在家具之后的门与窗走的是圆心距判定,唯独家具这一档用了轴向包围盒:

// 点击检测:家具 > 门 > 窗 > 文本 > 房间 > 墙体
function hitTest(data, x, y) {
  var i, f, d, w, wall, pos;
  for (i = data.furniture.length - 1; i >= 0; i--) {
    f = data.furniture[i];
    var hw = f.width / 2, hh = f.height / 2;
    if (x >= f.x - hw && x <= f.x + hw && y >= f.y - hh && y <= f.y + hh) {
      return f.id;
    }
  }

rotation 在这个文件里一共被读了四处:2D 家具本体(L659)、2D 选中框(L730)、3D 家具布局的角度(L849)、以及旋转偏移量;而 hitTest 里家具那一档(L447 与 L448 两行)是唯一不读它的地方。把两者摆在同一个家具上对照,结果是这样的:

== 7. hitTest 的几何假设:AABB,不含 rotation ==
  bed 预设: width=200 height=180 -> 半宽 100 半高 90(两值不等是前提)
  视觉范围 rotation=0  : x [400,600]  y [410,590]
  视觉范围 rotation=90 : x [410,590]  y [400,600]
  hitTest 用的包围盒(不随 rotation 变): x [400,600]  y [410,590]
  切到 rotation=90:
  P1 [500,595]  视觉床上  hitTest=null
  P2 [595,500]  视觉床外  hitTest=el_1
  切回 rotation=0(包围盒与视觉重合):
  P1 [500,595]  视觉床外  hitTest=null
  P2 [595,500]  视觉床上  hitTest=el_1
  -> 两点的 hitTest 结果在两个 rotation 下完全不变:包围盒不随 rotation 变
  -> rotation=90 时 P1 漏命中、P2 假命中;rotation=0 时两者都与视觉一致
  -> 错位带宽 (width-height)/2 = 10 cm;width==height 时两盒重合,差异不可见

bed 预设是 200×180,两值不等,所以错位可见。rotation 切到 90 度时,视觉上的床占据 x 410 到 590、y 400 到 600,而 hitTest 用的包围盒始终是 x 400 到 600、y 410 到 590——它压根不看 rotation。于是两个测试点各命中一种失败模式:P1(500,595) 视觉在床上、hitTest 返回 null(漏命中);P2(595,500) 视觉在床外、hitTest 返回 el_1(假命中)。两点在两个 rotation 下的 hitTest 结果一字不变,这就是"包围盒不随旋转变化"的直接证据。

错位带宽是 (width-height)/2,对床来说是 10 cm。如果家具是正方形,两盒重合,这个 bug 永远不会显形——这也是它值得写进判据的原因:一个只在非正方形家具 + 非零旋转时出现的缺陷,靠肉眼点几下很难稳定复现。

用户侧的可感后果不是"画面错了",而是"点不动":在那个 10 厘米的边带里点击,选中的可能是下面压在床边的墙或房间,或者什么都没选中。反过来说,拖动、旋转、删改都作用于 selectedId 对应的元素,所以假命中不会画错东西,只会选错东西。

修法也只有一个正确方向:hitTest 与 drawFurniture 必须共用同一套几何。要么在命中前把点反旋转回家具局部坐标(一次矩阵求逆,或者等价地把偏移量按 -theta 旋转),要么把包围盒换成旋转后矩形(OBB)的四边判定。前者便宜且不改变绘制路径,是我会选的那个。

这条判据与前面的签名主线同构:当同一个事实有四个读法而其中一个没读它,不一致就会只在那一个读法里发生。 一致性必须由代码里的单一来源保证,而不是由"四处都写对了"这个祝愿保证。而验证它不需要真机,只需要一个能改 rotation 的函数和一个能返回 id 的函数。


十八、我怀疑过的两个 bug,都被否证了

一篇全绿的文章不好看,但伪造一个 bug 更糟。这一节老实记两个我怀疑过、实测站不住的地方。

第一个是层级。这个工程的 3D 视图容器、覆盖提示层、关于弹窗叠在一起,而关于弹窗(about-layer)是页面最后一个元素,正常情况下应当盖住所有内容。我担心它被 3D 画布压住,于是先看 CSS 用了什么分层手段:

== the layering question: what does the CSS actually use ==
  z-index                  0  []
  position\s*:\s*absolute  0  []
  position\s*:\s*fixed     0  []
  grid-column              5  [121, 218, 242, 289, 309]
  grid-row                 4  [219, 243, 290, 310]
  grid-area                2  [51, 393]

z-index、absolute、fixed 三项命中全是 0。 这个工程的布局完全靠 grid 的行列(grid-column 5 处、grid-row 4 处、grid-area 2 处),body 是 height 100vh 的 grid。既然没有任何显式层叠,层序就由 DOM 顺序决定:

== DOM order of the stacking elements (order IS the z-order here) ==
  L9    <div class="app-shell">
  L38   <div class="main">
  L97   <div class="about-layer" id="about-layer">

app-shell 在前、main 在中、about-layer 在后。默认绘制顺序里后写的在上,所以关于弹窗天然压住主界面,不需要 z-index。而且两套显隐机制是成对设计的:overlay 基线是 display flex、加 .hidden 变 none;about-layer 基线是 display none、加 .open 变 flex。一隐一现,两种语义方向都自洽。我怀疑的那个"层级压住"的场景,在这份 CSS 里根本没有产生的条件。

第二个是监听器堆积。家具列表每行都绑 click,而点击某一行又会重绘整个列表——这个组合是监听器泄漏的经典温床。我先查接线完整性,再查绑定位置:

== wiring audit part 1: every id the JS reaches for, does HTML declare it? ==
  ids referenced in renderer.js : 24
  ids declared in index.html    : 25
  referenced but NOT declared   : []
  declared but never referenced : ['properties']

renderer.js 伸手取的 id 有 24 个,index.html 声明的 id 有 25 个,取用未声明的为 0(反向那条里唯一一项是 properties,它是一个纯容器,从不被脚本取用,属于正常的静态 DOM)。接线这层没问题。

再看重绘路径。列表重绘这件事发生在 updateFurnishList 里:

function updateFurnishList() {
  el.furnishList.innerHTML = '';
    row.appendChild(icon);
    row.appendChild(label);
    row.appendChild(size);
    bindFurnishRow(row, f.id);
    el.furnishList.appendChild(row);
  }
function bindFurnishRow(row, id) {
  row.addEventListener('click', function() {
    state.selectedId = id;
    updateProperties();
    updateFurnishList();
  });
}

三步都在:入口先 innerHTML 清空,循环里 createElement 出新节点,bindFurnishRow 只作用在这个新节点上。旧节点连同它的监听器一起失去引用,被回收。updateFurnishList 一共有六个调用点,分别在 newHome(L91)、afterRestore(L123)、bindFurnishRow(L216)、openHome(L842)、onCanvasMouseDown(L972)与 afterChange(L1198),每一次都走同一套清空重建,所以监听器数量恒等于当前行数,不会堆积。这个怀疑到此关闭。

判据句:判断"重绘会不会累积监听器",看的是绑定目标是不是每次都是新建节点、容器有没有在绑定前被清空;而不是数 addEventListener 出现了几次。 静态数调用次数会误导——这个工程 addEventListener 命中 34 处,其中绝大多数是一次性绑定。


十九、换行符与许可证头:第三方库的两张脸

品牌清理与第三方归属这两件事,通常被写成"顺手检查一下"。这次我按文件逐个量化了,结论一好一坏。

先看品牌残留。把上游 SweetHome3D 的品牌词在工程内八个文件里分注释命中与代码命中两类统计:

== every brand hit outside comments, with its syntactic position ==
  index.html             L6     <link rel="stylesheet" href="styles/sweethome3d.css">
  index.html             L102   <p>本应用是运行于鸿蒙 PC 的 Electron 壳应用,提供家居户型设计与 3D 可视化的演示环境。3D 渲染引擎基于 Three.js(MIT 协议),2D 平面渲染与数据模型使用 JavaScript 独立实现。S
  main.js                L53    filters: [{ name: '户型图文件', extensions: ['sh3d'] }, { name: 'JSON 文件', extensions: ['json'] }],
  main.js                L73    defaultPath: '户型图.sh3d',
  main.js                L74    filters: [{ name: '户型图文件', extensions: ['sh3d'] }, { name: 'JSON 文件', extensions: ['json'] }]

(L102 那一行是脚本按列宽截断的输出,原文比这长。)注释侧 0 处,代码侧 5 处,而且这 5 处全部是应当保留的:一个是 href 里锁死的样式文件名 sweethome3d.css(改它就要改文件名,属于自伤),一个是关于弹窗里对上游与 Three.js 的致谢段,剩下三处是 .sh3d 扩展名——那是文件格式标识,不是品牌。所以这个工程没有"改名没改干净"的问题。

配套的资源完整性检查也过了:index.html 引用的五个外部文件全部在磁盘上存在,其中带品牌名的只有样式表那一个。

== reference integrity: what index.html actually loads ==
  referenced from index.html : ['styles/sweethome3d.css', 'three.min.js', 'OrbitControls.js', 'home-engine.js', 'renderer.js']
     styles/sweethome3d.css     exists on disk : True
     three.min.js               exists on disk : True
     OrbitControls.js           exists on disk : True
     home-engine.js             exists on disk : True
     renderer.js                exists on disk : True
  brand-bearing names among them : ['styles/sweethome3d.css']

真正的坏消息在许可证归属上:

== license header and newline style, all 8 files ==
  main.js                  crlf=107    lf=107    归属声明命中 0
  index.html               crlf=119    lf=119    归属声明命中 0
  package.json             crlf=8      lf=8      归属声明命中 0
  home-engine.js           crlf=982    lf=982    归属声明命中 0
  renderer.js              crlf=1376   lf=1376   归属声明命中 0
  styles/sweethome3d.css   crlf=451    lf=451    归属声明命中 0
  three.min.js             crlf=0      lf=6      归属声明命中 3
       L2      * @license
       L3      * Copyright 2010-2021 Three.js Authors
       L4      * SPDX-License-Identifier: MIT
  OrbitControls.js         crlf=0      lf=1045   归属声明命中 0

两个第三方文件,待遇差得很远。three.min.js 带着完整的 @license、Copyright 2010-2021 Three.js Authors 和 SPDX-License-Identifier: MIT 三行;OrbitControls.js 一行归属声明都没有,正文第一句是解释交互方式的普通注释。 它的 SPDX 归属实际上只能靠 three.min.js 那份横幅间接兜住,因为 OrbitControls 在 upstream 里属于同一个 MIT 仓库——但这个推理没有写进包里,审代码的人看不到。

顺带解释第二节留的那个对缝:这两个第三方文件的 crlf 都是 0、纯 LF;自己写的六个文件全是 CRLF。换行符在这里成了一条免费的供应链标记——不用读内容,只看 crlf 计数就能把这个目录切成"我们写的"和"外面拿来的"两类。这个技巧未必普遍成立(如果有格式化工具统一过换行符就会失效),但在这个工程里恰好可用。

待办因此很明确:给 OrbitControls.js 补一段与 three.min.js 同形的 MIT 横幅(Copyright 与 SPDX 两行,不改一行业务代码)。这不消耗任何回归预算,但能让"这个包里所有第三方代码都有归属声明"变成一句可以当场验证的话。

判据句:第三方代码的许可证合规,第一步不是读许可证,而是确认每个外来文件都自带可机读的归属横幅;缺失的那个文件,就是你无法向审校方解释的那个文件。


二十、两份交付脚本:把这七件事一次跑完

前面的结论全靠脚本。这一节把两个交付级的脚本贴出来,它们可以直接在任何一台开发机上跑,不需要设备、不需要网络、不需要构建。

第一个脚本回答"这套签名材料能不能提审"。它的核心是三个取数函数:从 build-profile.json5 里正则取四个 material 路径、从 p7b 里定位 JSON 起点并 raw_decode、从产物(外层 app 优先,回退内层 hap)里取包内 module.json 的权限集:

def p7b(path):
    raw = open(path, 'rb').read()
    i = raw.find(b'version-name')
    if i < 0:
        return None
    j = raw.rfind(b'{', 0, i)
    return json.JSONDecoder().raw_decode(raw[j:].decode('utf-8', 'ignore'))[0]


def src_perms(p):
    s = open(os.path.join(R, p, 'ohos_hap', 'web_engine', 'src', 'main',
                          'module.json5'), encoding='utf-8', newline='').read()
    got = set()
    for l in s.replace('\r\n', '\n').split('\n'):
        st = l.strip()
        if st.startswith('//') or st.startswith('*') or st.startswith('/*'):
            continue
        got.update(re.findall(r'ohos\.permission\.[A-Z_]+', st))
    return got


def pkg(p):
    outer = os.path.join(R, p, 'ohos_hap', 'build', 'outputs', 'default',
                         'ohos_hap-default-signed.app')
    inner = os.path.join(R, p, 'ohos_hap', 'electron', 'build', 'default',
                         'outputs', 'default', 'electron-default-signed.hap')
    if os.path.isfile(outer):
        kind, a = 'app', outer
    elif os.path.isfile(inner):
        kind, a = 'hap-only', inner
    else:
        return None
    z = zipfile.ZipFile(a)
    if kind == 'app':
        h = [n for n in z.namelist() if n.endswith('.hap')][0]
        inner_z = zipfile.ZipFile(io.BytesIO(z.read(h)))
        pi = json.loads(z.read('pack.info').decode('utf-8'))
    else:
        inner_z = z
        pi = None
    m = json.loads(inner_z.read('module.json').decode('utf-8'))
    perms = set(x['name'] for x in m['module'].get('requestPermissions', []))
    grant = {x['name']: ('reason' in x, 'usedScene' in x) for x in
             m['module'].get('requestPermissions', [])}
    return a, perms, pi, grant, kind

有了这三个函数,主表就只是一个循环;而"产物早于证书生效日"那条判据同样只有几行:

print('== 第 2 判据:产物 mtime 对比 Profile 自身的证书生效日(not-before)==')
for p in PROJ:
    f, d = bp(p)
    o = p7b(d['profile']) if d['profile'] and os.path.isfile(d['profile']) else {}
    nb = (o.get('validity') or {}).get('not-before')
    na = (o.get('validity') or {}).get('not-after')
    pk = pkg(p)
    if not pk:
        print('  %-13s 真无产物' % p)
        continue
    pm = os.path.getmtime(pk[0])
    verdict = ('产物早于证书生效日 差距 %.1f 天 -> 这份 Profile 不可能是签名用的那份'
               % ((nb - pm) / 86400)) if nb and pm < nb else '产物在证书有效期内'
    print('  %-13s product=%s(%s)  not-before=%s  not-after=%s  -> %s' % (
        p, datetime.datetime.fromtimestamp(pm).strftime('%Y-%m-%d %H:%M'), pk[4],
        datetime.datetime.fromtimestamp(nb).strftime('%Y-%m-%d %H:%M') if nb else '-',
        datetime.datetime.fromtimestamp(na).strftime('%Y-%m-%d') if na else '-', verdict))

第二个脚本回答"我手上这两个产物到底是哪一个"。它的判决段是全部推理里最短也最硬的一段代码——把 app 内嵌的 hap 与磁盘上的 hap 各自解出 module.json,只读 app 子对象里的 debug:

print('== 判决字段:两个落点各自 module.json 里的 app.debug ==')
print('  %-13s %-14s %-14s %-24s %s' % (
    'project', 'app 内嵌 hap', '磁盘 hap', 'modules.abc 内嵌/磁盘', 'sourceMaps.map'))
for p in PROJ:
    ap = os.path.join(R, p, 'ohos_hap', 'build', 'outputs', 'default', 'ohos_hap-default-signed.app')
    hp = os.path.join(R, p, 'ohos_hap', 'electron', 'build', 'default', 'outputs', 'default',
                      'electron-default-signed.hap')
    if not (os.path.isfile(ap) and os.path.isfile(hp)):
        print('  %-13s 缺一侧,无法比对' % p)
        continue
    ez2 = zipfile.ZipFile(io.BytesIO(zipfile.ZipFile(ap).read(
        [n for n in zipfile.ZipFile(ap).namelist() if n.endswith('.hap')][0])))
    dz2 = zipfile.ZipFile(hp)
    a = json.loads(ez2.read('module.json').decode('utf-8')).get('app', {})
    b = json.loads(dz2.read('module.json').decode('utf-8')).get('app', {})
    sa = {i.filename: i.file_size for i in ez2.infolist()}
    sb = {i.filename: i.file_size for i in dz2.infolist()}
    print('  %-13s %-14s %-14s %-24s %s' % (
        p, 'debug=%s' % a.get('debug'), 'debug=%s' % b.get('debug'),
        '%s / %s' % (sa.get('ets/modules.abc'), sb.get('ets/modules.abc')),
        '内嵌无 / 磁盘有 %s' % sb.get('ets/sourceMaps.map')))

两个脚本合起来覆盖七件事,每件都是一条命令能复现的:

  1. 权限的三方集合对账(源码 / 包内 / Profile);
  2. 包内权限有没有 reason,即授权期失败风险;
  3. 签名材料的 keyAlias、Profile type、device-ids、来源目录;
  4. 材料与产物的时间关系,以及证书 not-before 这第二把尺子;
  5. 版本号与包名在 app.json5、pack.info、Profile 三处是否一致;
  6. 一个工程在磁盘上到底有几个签名产物、分别在哪;
  7. 每个签名产物自己声明的 buildMode 与 debug 标志。

两个脚本都不接受命令行参数:仓库根路径 R 与工程名单写死在文件头部;这份名单在本篇里只有一项,换一台机器只需要改 R 那一行。第二个脚本的头部如下,它同时也是这份名单在全文里的唯一出处:

import os, sys, re, io, json, zipfile, datetime
sys.stdout.reconfigure(encoding='utf-8')
R = r'D:\hwAppLaunch'
PROJ = ['SweetHome3D']
ST = lambda p: datetime.datetime.fromtimestamp(os.path.getmtime(p)).strftime('%m-%d %H:%M')

print('== 同一次签名的两个落点:外层 app 的时间 vs 内层 hap 的时间 ==')
for p in PROJ:
    ap = os.path.join(R, p, 'ohos_hap', 'build', 'outputs', 'default', 'ohos_hap-default-signed.app')
    hp = os.path.join(R, p, 'ohos_hap', 'electron', 'build', 'default', 'outputs', 'default',
                      'electron-default-signed.hap')
    bp = os.path.join(R, p, 'ohos_hap', 'build-profile.json5')

有几处诚实的限制要说在前面:脚本假设产物在默认输出路径下、假设外层 app 里只有一个 .hap 条目、p7b 的 JSON 起点靠定位 version-name 再回溯左花括号。这三条在这台机器上全部成立,但换一个工程可能需要改;而"产物早于证书生效日"这类判据的结论本身与这些实现细节无关。


二十一、判据沉淀与提审前待办

把整篇的产出压成判据,共八条。每条都能在上文找到实测出处,找不到出处的那条我没写进来。

  1. 预设要能被"不适用"推翻。 台账里的驳回角度是推测,不是记录;实测证伪后就标不适用,不能替它编造轮次。
  2. 签名材料集中在自动签名缓存目录、且时间比产物新,先怀疑它是被工具一键刷出来的,而不是有人为它做过发布决策。
  3. 看权限会不会引发授权期失败,看包内每一项有没有 reason 字段,而不是权限名字里有没有 CAMERA、MICROPHONE 这类字样。
  4. 产物的时间戳如果早于这份 Profile 的生效时刻,这份 Profile 与这个产物的签名无关——不是"可能不是",是不可能是。
  5. 任何"缺失"类判定,都要先证明自己在所有可能的落点上找过。 只找了一条路径的探针,比不跑的探针更危险。
  6. 判断一个 hap 是发布态还是调试态,只看产物内 module.json 的 app.buildMode 与 app.debug。 目录、文件名、时间都不能代替它。
  7. "信产物"只管声明面;授权面必须回工程外的 Profile;两边都答不上来时,比的是时间戳与证书有效期的先后。
  8. 子串搜索要配"命中可归属"和"独有串"两个前提,否则在全 STORED 的二进制包上只会得到 147 处假阳性。

再加一条方法论,它来自第十七节而不是签名主线:同一个事实有两个表示处时,一致性必须由单一来源保证;而验证它常常只要一个离线探针,不需要设备。

提审前待办,按"能不能在本机解决"分组。

本机就能解决,且不消耗回归预算:

  • 给 OrbitControls.js 补 MIT 归属横幅(Copyright 与 SPDX 两行);
  • 把 en_US 资源里的 EntryAbility_label 填成真实英文名,或者明确记录"本应用不做英文资源";
  • 在仓库里加一条签核记录:每次提审前把内层 hap 的 buildMode 值抄进提交说明;
  • 跑一次 release 态整体打包,让外层 app 与内层 hap 同源;
  • 把 versionName 从 1.0.0 递增起来——app.json5 与产物 pack.info 两处对得上账,都是 1000000 / 1.0.0,说明这个字段从生成以来从未动过。

需要外部动作或需要真机:

  • 把这套 debugKey 材料换成 release Profile(并去掉 device-ids 绑定);
  • 确认 AGC 侧应用id 与 bundleName 的对应关系(第十二节说过,模式与材料都不在仓库里,这一步只能去后台看);
  • 采本文剩余四处配图位的真机截图,特别是 3D 视图在硬件路径与 initSoft 路径下的对比;
  • 复核一次 .sh3d 的向前兼容:截断 JSON 目前会抛 SyntaxError 且未被兜住(无头体检的第 10 组断言),调用方需要捕获,这条要在真机上按"打开一个损坏文件"的路径确认表现;
  • 补 three.js 的版本可辨识性:当前无法从包内确定它是不是仍在支持的修订线上。

还有两条明确未解决,写在这儿而不是藏着:一是这个包的签名块实际落点,我没有定位到;二是这个外层 app 到底是被哪一份 p7b 签的,那份材料已经被原地覆盖,不可恢复。第一条是工具能力问题,值得再拆一层;第二条是历史问题,只能靠"从现在起每次构建都留签名记录"来防它再次发生。

Logo

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

更多推荐