鸿蒙 PC 桌面端应用上架实战:图匠流程图编辑器从上游 draw.io 发行包到 AGC 过审的 SIGABRT 收敛之路
鸿蒙 PC 桌面端应用上架实战:图匠流程图编辑器从上游 draw.io 发行包到 AGC 过审的 SIGABRT 收敛之路
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_drawio



有的工程是自研引擎:业务代码六个文件平铺,全部逻辑自己写的,改起来想怎么改就怎么改。图匠流程图编辑器不是这种形态,它的业务本体是draw.io 官方自托管发行包——2929 个文件、68 MB,核心逻辑集中在一个 9883 KB 的压缩产物 app.min.js 里,一个字符都不该动。就因为这个差别,它上鸿蒙 PC 桌面端时踩的坑完全是另一套:三轮 AGC 驳回里有两轮的意见都是"应用整体退出",而根因最后收敛成了同一个——鸿蒙适配层会把四类原生 UI 都实现成 SubWindow,触发 XComponent 等待超时直接 SIGABRT。
这篇复盘把这条线完整记录:怎么在不能改上游代码的前提下接管它的行为(官方配置面 + 原型 hook + 实例覆盖三层手段)、商标混淆驳回后的 290 处品牌残留怎么穷举、READ_PASTEBOARD 这个坑怎么一步步定案,以及"新建弹系统对话框"“样式编辑概率闪退”"点叉叉没反应"这几条功能异常各自怎么定案。所有配置与代码片段均摘自工程真实文件,文中每一个计数都用脚本在本工程目录跑过,结论经过真机打包与反复验证。
一、先分清两类工程:自研壳 vs 上游压缩发行包
如果你手上也是一个把成熟开源 Web 应用塞进 Electron 壳的工程,那自研引擎项目里的经验在这里只能复用一半。配置侧(签名、包名、权限、图标、名称)是通用的,但业务侧的工程量级和性质完全不同。
| 维度 | 自研引擎工程 | 上游压缩发行包工程(本工程) |
|---|---|---|
| 业务代码形态 | 6 个文件平铺,全部自研 | 2929 个文件、68 MB 的官方发行包 |
| 可修改范围 | 全量可改 | 主压缩产物不可改,其余 241 个文本文件逐个过 |
| 核心引擎体积 | 数十 KB 到几百 KB | app.min.js 9883 KB、extensions.min.js 4474 KB、stencils.min.js 3779 KB |
| 功能异常成因 | 自己的逻辑分支写错 | 上游走了壳环境不支持的原生 API |
| 修复手段 | 直接改函数 | 官方配置面 + 原型 hook + 实例覆盖三层 |
| 品牌残留 | 改自己写的文案 | 上游标识散落在不可改产物里,只能覆盖显示层 |
三条硬约束决定了后面所有技术选择:
- 压缩产物不可改。 app.min.js 是官方构建产物,手改等于自断升级路径,而且压缩后的单行代码根本没法维护。任何行为调整都只能在它外面包一层。
- 官方流程不可直接砍。 用户要的是"文件 - 新建弹出模板对话框、选类型、点创建"这条官方流程,不是绕过它直接建空白图。审核员同样会按官方功能预期去点。
- 原生路径必崩。 上游代码里任何调用原生 select、原生对话框、window.open、mxWindow 的路径,在鸿蒙壳里都可能变成一次进程级崩溃——这不是"功能不可用",是"应用整体退出"。
顺带说明一个目录事实:本工程的业务资源整体平铺在 web_engine/src/main/resources/resfile/resources/app 下,根目录有 index.html、main.js(Electron 主进程,128 行)、package.json 以及一批上游遗留的功能页(云端存储、导出、导入等入口);js 子目录里除了压缩产物,还有两个自研文件——**PreConfig.js(79 行,官方配置面)**和 shell-adapter.js(1093 行,全部适配逻辑)。找适配代码只认这两个文件就够了。(行数口径统一说明:本文按编辑器显示的总行数计,含末尾空行;你若用脚本按 ReadAllLines 统计,每个文件会各少 1 行。)
二、商标混淆驳回:改名只是开始,残留要分"可改"与"不可改"两层
第一轮的驳回意见里有一条是商标:应用名与图标与 draw.io 相似。这类驳回在开源项目上鸿蒙商店时非常常见,处理方式也唯一:换完全原创的中文名 + 通用功能后缀,图标一并重做。本工程由 DrawIOWar 改为"图匠 流程图编辑器",包名 com.wei.drawiowar。
改完 string.json 就交付的话,一定会被第二轮挑出来别的残留。名称真值源共有四处,必须逐个核对,本工程当前实测值如下(注意 base 与两套本地化的优先级关系,改一处不生效是常态):
// AppScope/resources/base/element/string.json
{ "string": [ { "name": "app_name", "value": "图匠 流程图编辑器" } ] }
// electron/src/main/resources/base|en_US|zh_CN/element/string.json(三套同值)
{ "name": "EntryAbility_label", "value": "图匠 流程图编辑器" }
桌面图标名与最近任务卡片读的是 EntryAbility_label,不是 app_name,两处不同步就会得到"名称不一致"的驳回,这是四套资源里最容易漏的一对。
真正属于本工程特有的是残留量的量级。用下面这段脚本把业务目录按"可改文本文件"和"不可改压缩产物"分组统计:
$app = "web_engine\src\main\resources\resfile\resources\app"
$min = @('app.min.js','extensions.min.js','stencils.min.js','shapes-14-6-5.min.js',
'drawio-plantuml.min.js','drawio-mermaid.min.js','drawio-elk.min.js',
'drawio-gliffy.min.js','libavoid.min.js','orgchart.min.js','bridge.min.js','drawio-vsd.min.js')
$all = Get-ChildItem $app -Recurse -File
$mine = $all | Where-Object { $min -notcontains $_.Name -and $_.Extension -in @('.js','.html','.json','.css') }
Write-Output "MODIFIABLE_FILES=$($mine.Count)"
$hits = 0
foreach ($x in $mine) {
$t = [System.IO.File]::ReadAllText($x.FullName, [System.Text.Encoding]::UTF8)
$hits += ([regex]::Matches($t, 'draw\.io|DrawIO|drawio|DRAWIO')).Count
}
Write-Output "BRAND_HITS_IN_MODIFIABLE=$hits"
本工程实跑结果:可改文本文件 241 个,其中品牌命中 290 处;按名单排除的 12 个主压缩产物内另有 1216 处不可改命中。这里要提一个口径上的坑:业务目录里后缀为 .min.js 的文件实际上有 25 个,上面名单只列了 12 个(app / extensions / stencils / shapes 以及各插件子目录里的转换引擎)。差别实测只有一处命中:把 25 个全量统计是 1217,统计这 12 个是 1216——剩下的 13 个是 jquery、jszip、pako、purify 这类第三方基础库,它们体量小、无品牌字样,留在 241 里逐个过一遍就行。写脚本时把排除名单穷举完整很难,但一定要把“统计口径到底覆盖了哪些文件”写清楚,不然一个 1216 / 1217 的差异就能让人怀疑整个分类。
这两个数字对应的处理口径完全不同,必须分开讲:
- 290 处可改命中:逐个分类。HTML 的 title 与 meta、manifest.json 的 name / short_name、package.json 的 name 与 description、自研文件的注释与日志前缀、文件对话框过滤器名——凡用户可见或可被静态分析直接读到的都要改。本工程 package.json 最终为 name “tuj-flowchart”、description “图匠流程图编辑器 - 鸿蒙 Electron 壳应用”,manifest.json 的 name / short_name 已改为"图匠 流程图编辑器 / 图匠"。
- 1216 处不可改命中:不要去动压缩产物。正确做法是覆盖运行时显示出来的那部分——上游内部变量名、资源键、注释、CSS 类名里的 drawio 字样用户看不到,也不必看得到。工程里用 mxResources.get 取到的官方文案键(例如 fromTemplateUrl)就是这种思路:比较键值而不是比较字面量,才能既不碰产物又精准命中要隐藏的那个按钮。
在这 290 处里还有一类绝对不能改的:上游文件头的版权声明。把命中按行分类再跑一遍就能量化它:
$rx = 'draw\.io|DrawIO|drawio|DRAWIO'
$inCopy = 0; $other = 0; $files = 0
foreach ($f in $mine) {
$hit = 0
foreach ($x in [System.IO.File]::ReadAllLines($f.FullName, [System.Text.Encoding]::UTF8)) {
$c = ([regex]::Matches($x, $rx)).Count
if ($c -eq 0) { continue }
$hit += $c
if ($x -match 'Copyright') { $inCopy += $c } else { $other += $c }
}
if ($hit -gt 0) { $files++ }
}
Write-Output "IN_COPYRIGHT_LINES=$inCopy OTHER=$other FILES_WITH_HITS=$files"
实跑结果:290 处里有 156 处落在含 Copyright 的行上,命中分布在 164 个文件里。这一类一个都不能删——Apache License 2.0 要求再分发时保留上游的版权与许可声明,PreConfig.js 头部那两行 Copyright(JGraph Holdings Ltd 与 draw.io AG)就是典型。商标混淆这道门禁针对的是“让用户误认为这是官方应用”的界面元素,不是许可证要求的署名;真要逐个判断的是那 134 处非版权命中。先按行分类再动手,比看到命中就删省事得多,也安全得多。
有一条容易漏的落点值得单列:文件对话框的过滤器名。main.js 里两处 filters 的 name 字段写着"draw.io 绘图",这个字符串会直接出现在鸿蒙系统保存对话框的格式下拉里,属于用户可见的品牌残留,改起来只是换一个字符串,但如果不做穷举扫描基本发现不了:
// main.js —— 保存通道的过滤器(name 字段会显示在系统对话框里)
filters: [
{ name: 'draw.io 绘图', extensions: ['drawio'] },
{ name: 'XML 文件', extensions: ['xml'] },
{ name: '所有文件', extensions: ['*'] }
]
上面这段是工程当前真实内容,这个 name 至今仍带旧标识,是我在做完名称同步之后由穷举脚本捞出来的,属于当前交付记录里的待办项而不是已完成项,列出来是提醒同样做穷举的朋友:过滤器名、占位符、title 属性这些"非正文"字符串是残留扫描的高发盲区。
三、还有两处描述性风险没清完:4 处"在线"字样与 1 处外链
这一节是诚实披露,也是一条很实用的判据。
本工程实际完全离线运行,PreConfig.js 里四个 URL 配置全部置空是证据:DRAWIO_PUBLIC_BUILD 为 true,EXPORT_URL、DRAWIO_BASE_URL、DRAWIO_VIEWER_URL、DRAWIO_LIGHTBOX_URL 全为 null,urlParams 的 sync 固定 manual。也就是说没有任何服务端导出、没有云端存储、没有外链跳转。
但 index.html 的 meta 与 noscript 兜底段落是从上游继承下来的,全文件实测仍留着 4 处"在线"表述和 1 处外部图片链接,其中 3 处在下面这几行 meta 里:
<!-- index.html —— 上游遗留的 meta(当前工程真实内容,尚未收敛) -->
<meta name="Description" content="图匠流程图编辑器 - 免费在线流程图制作工具,支持 UML、ER 图、网络拓扑图等多种图表">
<meta name="Keywords" content="流程图, 流程图制作, 在线流程图, uml, erd, 图表编辑器, diagram">
<meta itemprop="description" content="图匠流程图编辑器是一款免费的在线图表绘制工具,支持制作流程图、UML图、ER图、组织架构、BPMN、数据库模型、网络拓扑等多种图表。">
<meta itemprop="image" content="https://lh4.googleusercontent.com/-cLKEldMbT_E/Tx8qXDuw6eI/AAAAAAAAAAs/Ke0pnlk8Gpg/w500-h344-k/BPMN%2Bdiagram%2Brc2f.png">
noscript 兜底段落里还有第 4 处"在线",manifest.json 的 description 同样写着"免费在线流程图制作工具"。
两条风险分别是:
- "在线"与实际行为不一致。 桌面端本地安装包、完全离线运行,却把应用描述成"在线工具",属于描述性问题;AGC 对功能与宣传一致性的判定是逐项比对的,这类文案比技术缺陷更容易被人工审核抓到。
- 残留一条 googleusercontent 外链。 它在 meta 里不会真的被请求(不是 script 或 link),但静态分析会把它当成"存在外部网络依赖"的线索,也可能被判定为加载失败的无效资源。
处理口径:离线应用要把这类描述改成"本地/离线",并把 meta 里的外部资源引用删干净。 之所以单独写一节,是因为这类内容不会导致构建失败、不会导致功能异常,只会在人工审核或用户投诉时暴露,是自查清单上最容易被跳过的一类。我把它留在这里,是因为截至本文整理时它仍未修完——这比假装"已全部处理干净"有用得多。
四、READ_PASTEBOARD:三轮修复尝试才定案的一次误判
剪贴板权限这一条在图匠上踩出来,中间还走错过一次,值得把三轮修复尝试完整写下来。
第一轮:按"权限声明要完整"的直觉加声明。 READ_PASTEBOARD 属于 user_grant,直接写进 requestPermissions 会在构建阶段被拦(缺 reason 与 usedScene)。补齐之后构建确实通过了:
// 第一轮的写法(已废弃,仅作过程记录)
{
"name": "ohos.permission.READ_PASTEBOARD",
"reason": "$string:pasteboard_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
结果是真的装不上。 安装阶段报 error code:9568289,提示 grant request permissions failed——构建门过了,安装门直接挂。
第二轮:只删声明。 module.json5 里干净了,应用也装上了,但一触发粘贴就弹"去系统设置"的引导,而鸿蒙 PC 的设置里根本没有对应开关。用户点进去找不到东西,AGC 直接按功能异常驳回。
第三轮(定案):删声明 + 在适配层授权方法体内旁路。 当前工程状态是 module.json5 里 READ_PASTEBOARD 出现 0 次,改为在 web_engine/src/main/ets/adapter/PermissionManagerAdapter.ets 的两个授权方法最前面提前返回成功:
// PermissionManagerAdapter.ets —— requestPermissions 方法体内旁路
requestPermissions(permissionType: string, callback: (granted: number) => void) {
const permissions = this.needPermissions.get(permissionType);
if (!permissions) {
LogUtil.error(TAG, 'unsupported permission:' + permissionType);
callback(REQUEST_PERMISSION_FAIL);
return;
}
// pasteboard: 不声明权限,直接返回成功(配合 checkPermissions 旁路)
if (permissionType === 'pasteboard') {
callback(REQUEST_PERMISSION_SUCCESS);
return;
}
let activeContext = this.ctxAdapter.getActiveContext();
// ……后续走标准 atManager.requestPermissionsFromUser
}
// PermissionManagerAdapter.ets —— checkPermissions 方法体内旁路
async checkPermissions(permissionType: string, callback: (granted: boolean) => void): Promise<void> {
const permissions = this.needPermissions.get(permissionType) as Array<Permissions>;
if (!permissions) {
LogUtil.error(TAG, 'unsupported permission:' + permissionType);
callback(false);
return;
}
// pasteboard: 不声明 READ_PASTEBOARD 权限(该权限在当前环境安装授予失败 err 9568289),
// 直接放行让 PasteBoardAdapter 执行 API 调用,由其后端 try/catch 静默处理错误
if (permissionType === 'pasteboard') {
callback(true);
return;
}
// ……后续走标准 checkAccessTokenSync
}
这里必须给一条判据,否则很容易自我误判:该文件的 needPermissions 映射表里本来就有条目(pasteboard 对应 ohos.permission.READ_PASTEBOARD),文件里也搜得到 openPasteboardPermissionConfirm 这个构造"去系统设置"文案的私有方法。这两样都在,并不代表旁路已生效。 判定只看一件事——授权方法体内是否存在对 pasteboard 的提前 return。映射表有条目只说明"这个类型被支持",构造函数存在只说明"它曾经准备弹引导",都不等于运行时会走到那里。我在这个文件上就是先看了映射表就下结论"已旁路",白测了一轮。
至于权限清单本身:web_engine/src/main/module.json5 精简到 14 项,同文件 definePermissions 段又写了 LOCK_WINDOW_CURSOR,所以正则扫 ohos.permission. 得到 15 条命中属正常基线(14 项 requestPermissions + 1 项 definePermissions);文件里若还有注释掉的权限行,统计前先剔除。这 14 项是:
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{ "name": "ohos.permission.GET_NETWORK_INFO" },
{ "name": "ohos.permission.ACCESS_CERT_MANAGER" },
{ "name": "ohos.permission.RUNNING_LOCK" },
{ "name": "ohos.permission.PRINT" },
{ "name": "ohos.permission.PREPARE_APP_TERMINATE" },
{ "name": "ohos.permission.ACCESS_BIOMETRIC" },
{ "name": "ohos.permission.PRIVACY_WINDOW" },
{ "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" }
]
顺带修正一条早期判断:我曾把 GYROSCOPE 与 ACCELEROMETER 归入"引擎确实依赖",后来用关键词反向核查本工程的 ets 适配层,发现 sensor、gyroscope、accelerometer 全部零命中。“审核没驳"不等于"裁剪到位”,这两项属于待复核项,别当证据照抄。
五、一个统一根因支配了所有闪退:四类原生 UI 都会走 SubWindow
第二轮驳回的口径是"应用整体退出",第三轮还有一条是"样式编辑有概率闪退"。这类问题最难的地方在于表现分散:改纸张尺寸会崩、触发确认框会崩、点颜色选择器会崩、保存时也会崩。如果按现象逐个查,会以为是四个独立 bug。
真相是一条:鸿蒙适配层把这些原生 UI 全部实现成 SubWindow,而 SubWindow 创建路径上的 XComponentManager::WaitForXComponentCreated 会等待超时,超时即 SIGABRT。 shell-adapter.js 的头部注释把四类路径都记了下来:
| 原生 UI 类型 | 上游触发点 | 在鸿蒙壳里的后果 |
|---|---|---|
| 原生 select 下拉 | 纸张尺寸、单位等 | 第一次展开可成功,第二次展开超时 SIGABRT(真机连续切换实证) |
| 原生对话框三件套 | alert / confirm / prompt,经 mxUtils 裸调用 | 调用即闪退 |
| mxWindow 系窗口 | ColorWindow(继承 mxWindow)非模态路径 | 连续操作时超时 SIGABRT |
| _blank 新窗口提交 | 官方保存流程 mxXmlRequest.simulate | SubWindow 等待超时 SIGABRT |
所以适配层在这条线上的全部工作可以概括成一句话:把这四类路径在渲染层提前接管,用纯 DOM 自研控件替换掉。 上游代码一行不改,但它再也走不到原生 UI 上。
主进程侧还有一道总闸——不管渲染层漏了哪条路径,只要不许开新窗口,就不会进 SubWindow 创建流程:
// main.js —— 拦截所有新窗口创建(表单 target=_blank / 链接 / 脚本触发)
// 避免鸿蒙适配层创建 SubWindow(XComponent 等待超时会导致 SIGABRT 崩溃)
if (mainWindow.webContents != null && typeof mainWindow.webContents.setWindowOpenHandler === 'function') {
mainWindow.webContents.setWindowOpenHandler(function () { return { action: 'deny' }; });
}
同文件在创建窗口前还有一句 app.disableHardwareAcceleration()。这是硬件加速兼容性的收敛项,和 SubWindow 无关,但在 ArkWeb 这类嵌入式容器里关闭 GPU 加速是常见的稳定性取舍,遇到渲染异常或偶发白屏时可以优先验证这一项。
排查方法论层面值得记住的是:当同一应用出现多个"看起来无关"的崩溃点时,先找它们的共同调用面。 本工程的四类闪退如果分开看,我会写出四个 Q&A;找到 SubWindow 这个共同根因之后,适配策略一下就明确了——这不是四个 bug,是一个能力的四次暴露。
六、先穷尽官方配置面,再谈 hook
PreConfig.js 是这类工程性价比最高的一个落点。draw.io 留了全局配置对象 DRAWIO_CONFIG 与 urlParams 机制,在 App.main 启动时经 Editor.configure 生效。能用官方配置关掉的东西,绝不要用 hook 去关:官方配置是上游支持的稳定入口,命中准确、跨语言跨主题都不会漂。本工程的真实配置如下:
// PreConfig.js —— 用官方配置面裁剪上游功能(真实内容节选)
window.DRAWIO_PUBLIC_BUILD = true;
window.EXPORT_URL = null;
window.DRAWIO_BASE_URL = null;
window.DRAWIO_VIEWER_URL = null;
window.DRAWIO_LIGHTBOX_URL = null;
window.DRAW_MATH_URL = 'math4/es5';
window.DRAWIO_CONFIG = null;
urlParams['sync'] = 'manual';
// 默认界面语言固定简体中文(官方语言读取链优先采用该参数)
urlParams['lang'] = urlParams['lang'] || 'zh';
window.DRAWIO_CONFIG = {
hideMenus: ['help'],
hideMenuItems: [
// "其它"菜单:Electron 环境专属项(Google 字体/拼写检查/自动备份/草稿)
'googleFonts', 'spellCheck', 'autoBkp', 'drafts',
// "其它"菜单:主题/语言切换入口整项移除
'theme', 'language',
// "其它"菜单:默认主题分支其余项
'mathematicalTypesetting', 'autosave', 'collapseExpand',
'diagramLanguage', 'editDiagram', 'plugins', 'configuration',
// 文件菜单:嵌入/发布/新增库
'embed', 'publish', 'newLibrary',
// 便签本入口:其它菜单开关项 + 右键"添加到便签本"
'scratchpad', 'addToScratchpad'
]
};
实测统计口径:hideMenus 1 项(help),hideMenuItems 18 项。去掉"帮助"菜单这一刀尤其值:它的下面挂着搜索框、快捷键表、快速入门视频、获取桌面版、支持、关于等一堆外链与联网子项——在上架语境里,每一个外链都是一个"点了跳浏览器/点了没反应"的风险点。菜单项隐藏后残留的分隔线官方有 smartSeparators 机制自动清理,不用自己补刀。
PreConfig.js 里还藏着两条从上游缺陷自救的例子,都很典型:
1)主题切换后无法恢复。 官方极简主题(ui=minimal)切过去之后界面上没有恢复入口,用户会以为应用坏了。上游把这个值持久化在 localStorage 的 .drawio-config 里,本工程的做法是在应用脚本读取它之前把 ui 字段删掉,配合 hideMenuItems 移除主题入口,运行期再无途径改变主题:
// PreConfig.js —— 清除历史主题值,保证每次启动均为默认外观
(function () {
try {
var raw = localStorage.getItem('.drawio-config');
if (raw != null) {
var cfg = JSON.parse(raw);
if (cfg && cfg.ui) {
delete cfg.ui;
localStorage.setItem('.drawio-config', JSON.stringify(cfg));
}
}
} catch (e) { /* 配置解析异常时保持原值不动 */ }
})();
2)file:// 协议下 service worker 注册必然失败。 本应用以 loadFile 加载,协议是 file:,官方初始化链会调 navigator.serviceWorker.register 并抛未捕获异常。做法是把它替换成惰性空实现:
// PreConfig.js —— file: 协议下把 service worker 注册替换为惰性空实现
(function () {
try {
if (location.protocol === 'file:' && window.navigator && window.navigator.serviceWorker) {
var noopReg = {
scope: './', active: null, waiting: null, installing: null,
update: function () { return Promise.resolve(); },
unregister: function () { return Promise.resolve(false); }
};
window.navigator.serviceWorker.register = function () { return Promise.resolve(noopReg); };
}
} catch (e) { /* 忽略适配异常 */ }
})();
这两处的共性是:协议限制与持久化状态导致的问题,都发生在初始化极早期,必须在应用脚本读到这些值之前动手。 所以它们放在 PreConfig.js 而不是 shell-adapter.js——PreConfig 由上游 bootstrap 链最先加载,这个加载顺序本身也是适配方案的一部分。
优先级结论:官方配置面(PreConfig.js)能覆盖的绝不上 hook;配置面覆盖不到的交互(菜单构建结果、实例方法、DOM 清扫)才交给 shell-adapter.js。 这个顺序能显著降低后续维护成本,也让 hook 集合保持最小。
七、适配层的时序骨架:500 毫秒轮询、九个标记、120 秒超时
shell-adapter.js 在 index.html 底部同步加载,而目标对象所在的 app.min.js 经 bootstrap.js 以 defer 异步加载。也就是说,适配脚本开始执行的那一刻,window.App、window.EditorUi、window.Menus 全都不存在。工程的做法是轮询:
// shell-adapter.js —— 主轮询(真实代码,文件末尾)
var timer = setInterval(function () {
var saveOk = hookSave();
var trimOk = hookDeviceOnlySubmenus();
var iconOk = replaceGitHubIcon();
var padOk = hookScratchpadOff();
var dlgOk = hookNativeDialogs();
var helpOk = hookHelpIcons();
var newOk = hookNewAction();
var colorOk = hookColorPicker();
var ctxOk = hookContextMenu();
// 兜底一:覆盖官方注册函数本身,防属性定义失效时官方再次注册
try {
if (window.EditorUi != null && window.EditorUi.prototype != null
&& window.EditorUi.prototype.__warUnloadHooked !== true
&& typeof window.EditorUi.prototype.addBeforeUnloadListener === 'function') {
window.EditorUi.prototype.addBeforeUnloadListener = function () { /* 关闭拦截不注册 */ };
window.EditorUi.prototype.__warUnloadHooked = true;
}
} catch (eU) { /* 忽略 */ }
// 兜底二:直接清除已注册的拦截器
try { window.onbeforeunload = null; } catch (eB) { /* 忽略 */ }
if (saveOk && trimOk && iconOk && padOk && dlgOk && helpOk && newOk && colorOk && ctxOk) {
clearInterval(timer);
// 兜底:customLibraries 直载为异步,可能晚于轮询完成把便签本装回,稍后再校验一次
window.setTimeout(hookScratchpadOff, 3000);
}
}, 500);
window.setTimeout(function () { clearInterval(timer); }, 120000);
这段骨架里有三个当时踩过的坑,每一个都能让 hook 静默失效且不报错:
坑一:完成标记的赋值位置。 每个 hook 函数内部都有形如“先判标记、后干活、最后置位”的结构,置位语句必须排在成功校验之后。一旦把 window.__warXxxHooked = true 写到函数开头或校验之前,目标对象还没就绪也会返回 true,轮询直接 clearInterval,后续永不再试——表现就是“代码明明写了,功能一点没变”。hookColorPicker 是标准写法:任何前置条件不满都 return false,只有替换真落地才置位。
坑二:完成判据不能依赖实例捕获。 hookScratchpadOff 早期把“成功”定义为“拿到了 EditorUi 实例”,而实例要在菜单构建链里才捕得到,存在时序窗口,导致这个条件长期不满足、轮询永不终止。当前实现已改为只看 hook 状态本身,源码里那段注释就是这次修正的现场记录:
// shell-adapter.js —— 判定只看 hook 状态,不依赖 ui 实例捕获
// (捕获依赖菜单构建期 get 调用,存在时序窗口,曾导致判定永不达成)
window.__warScratchpadOff = proto.__warScratchpadHooked === true
&& proto.__warLibHooked === true;
window.__warPadCaptured = capturedUi != null;
两个变量各管一件事:能不能停轮询看前者,诊断实例有没有拿到看后者,把诊断量掺进完成条件里就是一个定时炸弹。
坑三:超时兜底不能省。 120 秒硬上限的作用是“万一某个 hook 永远满不了,也不要让 500ms 轮询转到天荒地老”。但注意它是 clearInterval 而已,不会补装 hook,所以它只能保证不耗资源,不能保证功能生效——真正发现失效要靠状态输出,而不是靠它。
关于状态输出还要提一个我自己留下的隐患:replaceGitHubIcon 的 return 写的是 removed || true,这个表达式恒为 true。对“装上关于入口即可”这个目标它不影响功能,但它已经不再表达“图标是否真的被移除了”,iconOk 永远满足。日后如果我要用这九个标记做诊断输出,这一项会误导——这类为了“先让它能停”而写的恒真返回,必须在写的时候就标出来,不然后排查的人会白忙。
八、原型链三级覆盖:谁的层级最近,谁说了算
这一节是整篇里最费时间、也最值得复用的一段。
本工程要覆盖一个上游方法时,需要判断它会被几层遮蔽。以保存为例,只 hook EditorUi.prototype 是不够的,因为 App 继承 EditorUi 且自带更近的 save / saveFile:
// shell-adapter.js —— 保存入口:两层原型都要覆盖
function hookSave() {
if (window.__warSaveHooked === true) { return true; }
try {
if (window.EditorUi != null && window.EditorUi.prototype != null) {
window.EditorUi.prototype.saveFile = function () { saveViaShell(this); };
window.EditorUi.prototype.save = function () { saveViaShell(this); };
}
if (window.App != null && window.App.prototype != null
&& typeof window.App.prototype.saveFile === 'function'
&& typeof window.App.prototype.save === 'function') {
window.App.prototype.saveFile = function () { saveViaShell(this); };
window.App.prototype.save = function () { saveViaShell(this); };
window.__warSaveHooked = true;
}
} catch (e) { /* 忽略 */ }
return window.__warSaveHooked === true;
}
注意置位条件只看了 App.prototype 分支——子类原型存在才算装完,这是故意的:如果 App 尚未出现就先置位,就会在只覆盖了父类的状态下提前停止轮询,而官方调用实际命中子类方法,表现就是“保存仍弹官方位置选择框”。这个现象我在真机上先遇到过一次,源码注释里也记了下来。
再往上一层是实例级覆盖,也是最阴的一层。右键菜单的“在此粘贴”我 hook 了 Menus.prototype.addPopupMenuEditItems 完全无效,后来在压缩产物里 grep 到了真正的元凶(app.min.js 第 15091 行,变量名因压缩而异,特征串如下):
// app.min.js 压缩产物内的实例级覆盖模式(仅能读到这一个位置)
var S = Menus.prototype.addPopupMenuEditItems;
this.menus.addPopupMenuEditItems = function (N, W, ma) { /* … */ S.apply(this, arguments); };
先在构造函数里把 prototype 方法存进局部变量 S,再往 this.menus 上赋一个同名实例方法。实例属性遮蔽原型链,无论何时替换 prototype 都不会被调用。因此 hookContextMenu 必须等实例就绪后直接覆盖实例方法,且它拿不到实例就只能 return false 让轮询继续:
// shell-adapter.js —— 右键菜单:必须覆盖实例方法,不能只动 prototype
function hookContextMenu() {
if (window.__warCtxHooked === true) { return true; }
// 需要等 capturedUi 就绪(EditorUi 构造完成后)
if (capturedUi == null || capturedUi.menus == null) { return false; }
try {
var menus = capturedUi.menus;
if (typeof menus.addPopupMenuEditItems !== 'function') { return false; }
if (menus.__warCtxHooked === true) { return false; }
var origAddPopupEdit = menus.addPopupMenuEditItems;
// ……替换为“空画布时把 pasteHere 换成导入,有选中时回落原实例方法”
menus.addPopupMenuEditItems = function (menu, cell, evt) {
// 完整实现见工程文件,本处只保留层级结构
};
menus.__warCtxHooked = true;
window.__warCtxHooked = true;
} catch (e) { }
return window.__warCtxHooked === true;
}
实例从哪来?draw.io 没有在任何全局变量上暴露编辑器实例。本工程的取法是 hook Menus.prototype.get——它是菜单读取的唯一入口,顺手把 this.editorUi 抽出来:
// shell-adapter.js —— 在菜单读取的唯一入口里捕获实例
window.Menus.prototype.get = function (name) {
var menu = origGet.apply(this, arguments);
if (capturedUi == null && this.editorUi != null) { capturedUi = this.editorUi; }
// ……
return menu;
};
给同样做这类工程的朋友一套固定动作:
- hook 前先回答“这个方法有几层”:grep 压缩产物找 子类定义与实例赋值,不要默认 prototype 就是终点。
- 搜实例级覆盖用可量化特征,本工程的计数是:this.menus.addPopupMenuEditItems 形式 1 处,而把模式放宽成 this.任意属性.任意方法=function(不带空格)再数一遍,共 34 处——这 34 就是“hook 了但不生效”的潜在名单。
- 拿不到实例就从“必经的唯一入口”里捕获,并把捕获与完成判定拆开(同第七节坑二)。
最后记一条很具体的修正:shell-adapter.js 的注释里把这个位置写成了“第 15092 行”,而我用脚本定位到的真实行号是 15091。差异很小,但足够让你在 IDE 里跳到错误的代码上。引用压缩产物时不要拿行号当锚点,要拿可搜索的特征串(比如 var S=Menus.prototype 这段),行号会随产物版本、随计数方式漂移,而特征串不会。
九、功能异常之一:新建流程改了三轮才找对调用链
AGC 意见原文的意思是:“新建时弹出不可用的系统对话框”。这个功能异常我修了三版才定案,前两版都是错的,过进程值得完整复盘。
第一版(方向错):绕过模板对话框直接建空白图。 技术上最简单,但它删掉了用户明确要保留的官方流程——“文件 - 新建 → 模板对话框 → 选图表类型 → 创建”。需求不允许,就不是修复,是破坏功能。 这一版被直接否决。
第二版(函数猜错):覆盖 EditorUi.prototype.openInNewWindow。 理由是“它看起来就是开新窗口的”,真机验证“新建 → 创建”仍弹多余弹框。后来直接追压缩产物的调用链才确认真实路径是:
NewDialog 点“创建” → 内部回调 → App.prototype.createFile
→ showSaveFilePicker / pickFolder
openInNewWindow 根本不在这条路径上(它主要用于保存/导出)。第二版只是碰巧覆盖了一个相近名字的方法。
第三版(定案):覆盖 App.prototype.createFile,跳过选择器直接装载。 保留 data(用户选中的模板 XML)与官方 fileCreated 装载链,只是不碰任何文件选择器:
// shell-adapter.js —— 新建动作第一层:覆盖 App.prototype.createFile
window.App.prototype.createFile = function (title, data, libs, mode) {
try {
// 停止 spinner(与官方逻辑一致)
if (this.spinner != null) {
try { this.spinner.stop(); } catch (eSp) { }
}
// 使用传入的 data 或空白图表 XML
var xml = (data != null && data.length > 0) ? data : this.emptyDiagramXml;
// 直接创建 LocalFile 并装载到编辑器(不弹任何对话框)
if (typeof window.LocalFile === 'function') {
var file = new window.LocalFile(this, xml, title || this.defaultFilename, null == mode);
if (typeof this.fileCreated === 'function') {
this.fileCreated(file, libs);
return;
}
}
} catch (eCf) {
if (typeof this.handleError === 'function') {
this.handleError(eCf);
}
}
};
同一函数里保留了两层兜底:openInNewWindow 的覆盖仍然留着(防其他路径调 window.open),另用一个 MutationObserver 持续把模板对话框里的“从模板 URL 地址”按钮藏掉。藏按钮的写法有个细节很实用——不比较中文字面量,比较官方资源键的当前值,这样切换语言也不会失效:
// shell-adapter.js —— 按资源键比较,不写死中文
resKey = window.mxResources.get('fromTemplateUrl');
if (resKey != null && txt.trim() === resKey.trim()) {
btns[i].style.display = 'none';
}
另外还有一道前置闸门,值得把上游的真实判据抄下来。是否走原生文件选择器由 EditorUi.nativeFileSupport 决定,而这个开关在压缩产物里只有一个赋值点,条件是一串合取:
// app.min.js —— 上游原生选择器开关的真实判据(按可读性重新排版,逻辑未改)
EditorUi.isElectronApp = null != window && null != window.process
&& null != window.process.versions && null != window.process.versions.electron;
EditorUi.nativeFileSupport = !mxClient.IS_OP && !EditorUi.isElectronApp
&& '1' != urlParams.extAuth
&& 'function' === typeof window.showSaveFilePicker
&& 'function' === typeof window.showOpenFilePicker;
这条判据里有两层信息:showSaveFilePicker / showOpenFilePicker 是否存在只是其中两个条件,isElectronApp 才是决定性的那个——鸿蒙壳跑的就是 Electron 运行时,window.process.versions.electron 存在,nativeFileSupport 直接为 false,上游自己就不会走原生选择器分支。所以下面那四行屏蔽代码属于纵深防御(万一某个上游版本改了判据、或运行时信息缺失时仍不至于把原生 UI 放出来),而不是主要修复手段——把防御性代码当成主要修复手段记录下来,会让你在排查时找错重点:
// shell-adapter.js —— 防御:屏蔽系统文件选择器接口(防原生系统 UI 被唤起)
try { delete window.showSaveFilePicker; } catch (e) { /* 忽略 */ }
try { delete window.showOpenFilePicker; } catch (e) { /* 忽略 */ }
try { window.showSaveFilePicker = undefined; } catch (e) { /* 忽略 */ }
try { window.showOpenFilePicker = undefined; } catch (e) { /* 忽略 */ }
delete 与赋值 undefined 各写一遍是有原因的:严格模式下对不可配置属性 delete 会抛,两个动作互为兜底,每个都单独包 try。
这一节的方法论只有一句:修“点了不对”的问题,先追调用链,再挑覆盖点。 按函数名猜,就会得到第二版那种“代码合了、现象未变、还没报错”的最难排查状态。
十、功能异常之二:概率闪退与“在此粘贴”
第三轮意见里有一条是“样式编辑有概率闪退”。“概率”两个字当时把我误导了——我以为是并发或时序问题,其实“概率”只反映了审核员先点了哪一项:只要进了非模态颜色选择路径,就是必崩。
成因回到第五节的统一根因:官方 pickColor 在非模态路径下创建 ColorWindow,而 ColorWindow 继承 mxWindow,一进 mxWindow 就走 SubWindow。修复方式是把入口强制拐到模态路径,模态路径用的 CustomDialog 是纯 DOM、不创 mxWindow,功能完全保留:
// shell-adapter.js —— 颜色选择器:强制走模态路径,避免 mxWindow 引发 SIGABRT
function hookColorPicker() {
if (window.__warColorHooked === true) { return true; }
try {
var proto = window.EditorUi != null ? window.EditorUi.prototype : null;
if (proto == null || proto.__warColorHooked === true) { return false; }
if (typeof proto.pickColor !== 'function' || typeof proto.pickColorModal !== 'function') { return false; }
proto.pickColor = function (a, b, c, e, f, h, g, n) {
// 强制走模态路径(pickColorModal 使用 CustomDialog,不创建 mxWindow)
this.pickColorModal(a, b, c, e, f, h, g, n);
};
proto.__warColorHooked = true;
window.__warColorHooked = true;
} catch (e) { /* 忽略 */ }
return window.__warColorHooked === true;
}
保留参数个数原样透传,不去“优化”签名——上游传了几个就接几个,这是接管第三方方法时最低的兼容成本。同型问题的另两项处理写在文件头注释的第七、第八条职责里:原生 select 下拉隐藏元素但保留 value / options / change 语义,自研 div 下拉接管视觉;确认框与提示框统一换成自研 DOM 对话框。
“在此粘贴”是另一条线,但成因与第四节同根:官方粘贴入口在权限不足时会提示“去系统设置”,而那里没有开关,审核员看到的就是功能异常。所以本工程的做法不是修引导文案,而是直接把菜单项换成一个不依赖粘贴权限的动作:空画布右键时不再添加 pasteHere,改为添加“导入”,接到 ui.importLocalFile(true),与“文件 - 导入自 - 设备”同一条能力;有选中内容时回落原实例方法,保持菜单原有形态:
// shell-adapter.js —— 空画布右键:不添加 pasteHere,改为添加“导入”
var label = (window.mxResources != null && typeof window.mxResources.get === 'function')
? (window.mxResources.get('import') || 'Import') + '...'
: 'Import...';
menu.addItem(label, null, function () {
try {
if (typeof ui.importLocalFile === 'function') {
ui.importLocalFile(true);
}
} catch (eImp) {
setStatus('导入失败: ' + (eImp != null && eImp.message ? eImp.message : ''));
}
});
// 追加 copyAsImage/copyAsSvg(保持原实例覆盖中这些项的添加)
menus.addMenuItems(menu, ['copyAsImage', 'copyAsSvg'], null, evt);
这里补了一个很容易漏的细节:追加 copyAsImage / copyAsSvg。因为我覆盖的是实例方法,原来的实例覆盖体就不再执行,它顺带添加的那几项也一并消失了。接管一个方法时要把它的隐带副作用一同补回,否则修好了 A、静默弄坏了 B,而下一次驳回就是 B。
到这一步可以归纳出一整类驳回的修法了:当一个入口在壳里不可用、而它背后能力有替代路径时,直接把入口换掉,比修错误提示更干净。 权限引导、新窗口提交、云端位置打开(本工程把“导入自 / 从…打开 / 打开库”三个子菜单全部只留“设备”项)都是这么处理的。
十一、点右上角叉叉没反应:关闭拦截的三处封堵
这类现象很特别,它不是闪退、也不是报错,而是点了没反应。成因在第五节表格里其实已经出现过一次:官方 webapp 启动时会注册 window.onbeforeunload,文档已修改时返回一个提示字符串;Electron 收到 truthy 返回值就取消本次关闭——在标准桌面 Electron 里靠用户“再点一次”强制关闭,而鸿蒙适配层没实现这套二次确认逻辑,于是表现成叉叉完全失效。
这个问题上游是从两个方向注册的(直接写属性、以及调 addBeforeUnloadListener),所以本工程的封堵也做了三层,而且三层分别位于两个不同的执行时机:
// shell-adapter.js —— 第一层:本脚本先于官方代码执行,把属性定义为只写空值
// 官方任何注册被吞掉,叉叉直接关闭;保存已由系统弹框接管,
// 关闭拦截不再承担防丢失职责(main.js 另有 will-prevent-unload 主进程兜底)
try {
Object.defineProperty(window, 'onbeforeunload', {
configurable: true,
get: function () { return null; },
set: function () {
// 记录吞掉次数(诊断痕迹:>0 说明官方确实尝试注册关闭拦截器)
try { window.__warUnloadSwallows = (window.__warUnloadSwallows || 0) + 1; } catch (eC) { /* 忽略 */ }
}
});
window.__warUnloadGuard = true;
} catch (e) { /* 忽略:下方轮询兜底仍会置空 */ }
这段代码位置很关键——它写在脚本主体的靠前部分,靠的是 shell-adapter.js 在 index.html 里比官方初始化链更早执行这个事实,才能抢在注册之前把属性改成“写不进去”。set 里那个计数不是装饰:它是用来验证“官方确实试过了”的证据,大于 0 才能确认封堵真的在工作,而不是本来就没人在注册。
剩下两层一个在轮询里(第七节代码块的兜底一 / 兜底二:覆盖 addBeforeUnloadListener 本身 + 直接置空属性),另一个在主进程:
// main.js —— 主进程兜底:忽略渲染层发起的卸载阻止
// 疏通窗口关闭:webapp 注册的关闭拦截器会让 beforeunload 阻止窗口卸载,
// 表现为点右上角叉叉无响应;此处忽略该阻止、放行关闭
if (mainWindow.webContents != null && typeof mainWindow.webContents.on === 'function') {
mainWindow.webContents.on('will-prevent-unload', function (event) {
try { event.preventDefault(); } catch (e) { /* 忽略 */ }
});
}
为什么渲染层已经封死了还要主进程兜底?因为我无法保证上游所有版本的所有注册途径都只走这两个点。三层封堵的共同特点是都是幂等的、代价都接近零,但缺了任一层都可能在一个此前没见过的分支上失效。注意主进程这一层是 Electron 能力,跟壳版本相关而不是跟上游产物相关,所以升级 draw.io 不会把它弄坏。
还有一处取舍要说明:关掉拦截意味着未保存内容会直接丢。这个风险在本工程里是被前置动作消化的——保存已经完全收口到鸿蒙系统保存对话框,而“文档已修改”的提醒本身就会走原生对话框(一弹就闪退)。保留一个会崩的拦截器去防数据丢失,代价大于它能防的损失,这是一个应当写下来的显式取舍,而不是默默删代码。
十二、合规声明入口:把 GitHub 图标换成“关于”
开源应用必须把开源协议与免责声明放到用户能看到的位置,这是内容合规门会查的一项。上游在界面右下角放了一个指向 github.com/jgraph 的图标,直接删掉它又浪费了这个位置,所以本工程的做法是:移除外链,原地放一个“关于”文字入口。
难点仍是不能改产物,所以靠 DOM 查询双策略定位:
// shell-adapter.js —— 第一策略按 href 特征,第二策略按图标源反查父级 a
var allA = document.querySelectorAll('a[href*="github.com/jgraph"]');
// ……未命中时退到第二策略:
var allImg = document.querySelectorAll('img[src*="github-logo"]');
for (var j = 0; j < allImg.length; j++) {
var p = allImg[j].parentNode;
if (p && p.nodeName === 'A' && p.href && p.href.indexOf('github.com') >= 0) {
ghLinks.push(p);
}
}
“关于”入口不依赖图标位置,固定放在右下角;面板则是复刻官方 Dialog 的 DOM 结构(geBackground 遮罩 + geDialog 主体),让定位与外观继续由官方样式表驱动:
// shell-adapter.js —— 关于面板三段式合规声明(真实代码节选)
var bg = document.createElement('div');
bg.id = 'warAboutBg';
bg.className = 'geBackground';
var dlg = document.createElement('div');
dlg.id = 'warAboutDlg';
dlg.className = 'geDialog';
dlg.style.width = '560px';
var title = document.createElement('div');
title.textContent = '关于 图匠';
var sections = [
['开源声明', '本应用基于开源流程图引擎构建,界面与编辑功能均来自社区开源项目,遵循 Apache License 2.0。'],
['免责申明', '本软件按“现状”提供,不对适用性、稳定性作任何明示或暗示的担保;因使用本软件产生的任何数据丢失或其它损失,均由使用者自行承担。'],
['完全开源', '本应用所依赖的流程图引擎及其全部源代码完全开源,遵循 Apache License 2.0,可自由查看、修改与再发布;本应用不含任何闭源或商业组件。']
];
// ……逐段拼接后 appendChild(bg) 与 appendChild(dlg),最后按 box.scrollHeight 定高
这里有三个细节值得抄走:
- 复用官方样式类而不是自带 fixed 定位。 用 geBackground / geDialog 可以让面板跟着主题与尺寸规则走,也不会因为自己写了 position:fixed 而去碰工程里其他定位约束。
- 协议表述要与 package.json 一致。 本工程 package.json 的 license 是 Apache-2.0,面板里两段都写 Apache License 2.0。反例并不少见:关于面板写 MIT、package.json 写 Apache-2.0,两边直接矛盾——本工程里这一项是预先对齐的,上架前用第二节那个计数脚本再复核一遍即可。
- 面板里的“完全开源”不是套话。 这里依赖的确实是上游开源引擎本身,声明只讲引擎、不声称自己重写了它——描述真实来源比声明“从零自研”安全得多,也经得起仓库里翻 LICENSE 文件的对照。
十三、保存与打开:IPC 契约、降级容错,以及“绝不用弹窗”
主进程两个通道都走鸿蒙原生对话框,但多了一层降级容错:对话框接口在当前适配层不可用时,保存回退为直写桌面,不让功能直接消失。
// main.js —— 保存通道:弹鸿蒙系统保存对话框,接口异常时回退桌面直存
var suggested = pathMod.join(desktop, name);
try {
var result = await dialog.showSaveDialog(mainWindow, {
defaultPath: suggested,
filters: [ /* 见第二节,name 字段仍含旧标识,待收敛 */ ]
});
if (result != null && result.canceled) { return { ok: false, canceled: true }; }
if (result != null && result.filePath != null && result.filePath.length > 0) {
fs.writeFileSync(result.filePath, data, 'utf8');
return { ok: true, path: result.filePath };
}
} catch (eDlg) {
console.warn('[DrawIOWar] 保存对话框不可用,回退桌面直存:', eDlg.message);
}
// 回退:对话框异常/无返回时直写桌面(旧行为兜底)
fs.writeFileSync(suggested, data, 'utf8');
return { ok: true, path: suggested };
注意返回值契约里三个分支都不同:成功是 ok / path,取消是 canceled,失败是 error。渲染层必须三个分叉都写反馈,否则“用户取消”会被当成“保存失败”,这本身就是一个可见的功能异常。
还有一处字段名的坑要提醒:showSaveDialog 返回的是单数 filePath,而 showOpenDialog 返回的是复数 filePaths 数组,两个通道读法必须分开。主进程打开通道里判的是 result.filePaths.length 然后取 [0],保存通道判的是 result.filePath.length,两者不能混用:
// main.js —— 打开通道:复数 filePaths,与保存通道的单数 filePath 不同
var result = await dialog.showOpenDialog(mainWindow, options);
if (result == null || result.canceled === true
|| result.filePaths == null || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
var file = result.filePaths[0];
var data = fs.readFileSync(file, 'utf8');
return { ok: true, path: file, name: pathMod.basename(file), data: data };
渲染层这一侧有两条收口规则。
其一,结果一律进状态位,不弹窗。 在第五节的基础上再推一层:不只是“不能用原生弹窗”,而是连反馈路径都不新开窗口。本工程的 setStatus 写的是菜单栏的 geStatus 区域:
// shell-adapter.js —— 在 draw.io 菜单栏状态位显示结果(不使用任何弹窗)
function setStatus(msg) {
try {
var st = document.querySelector('.geStatus');
if (st == null) { st = document.getElementById('geStatus'); }
if (st != null) {
while (st.firstChild) { st.removeChild(st.firstChild); }
st.appendChild(document.createTextNode(msg));
st.setAttribute('data-tip', msg);
}
} catch (e) { /* 忽略 */ }
try { console.log('[DrawIOWar] ' + msg); } catch (e2) { /* 忽略 */ }
}
先 querySelector 后 getElementById 是为了同时兼容“面板已渲染”和“只有初始 DOM”两种时刻;额外写一个 data-tip 属性是为了可在真机上用自动化脚本回读验证——反馈链路本身也要能被核查,不然审核员说“没看到提示”,你连反驳证据都没有。
其二,不要经官方那些会拐进对话框的入口。 打开图表时工程没有复用 ui.openLocalFile,而是直接走浏览器模式同款的装载链:
// shell-adapter.js —— 不经官方 openLocalFile(其内部在文档已修改时会走 confirm/新窗口链)
ui.fileLoaded(new window.LocalFile(ui, String(res.data), name));
原因写进了注释:openLocalFile 在当前文档已修改时会弹 confirm——而 confirm 一弹就是 SubWindow。同理,保存前要先 ui.editor.graph.stopEditing() 把正在编辑的文本提交掉,再取 ui.getFileData(true),最后把路径回写给官方编辑器(setModified(false) / setFilename / updateDocumentTitle),让标题栏与修改标记跟真实磁盘状态一致——接管一个功能就要把它维护的内部状态一并接管,否则官方界面的“未保存”星号会一直挂着,那又是一个新的功能异常。
文件名清洗也值得一提,它是上游行为与本地习惯不一致的地方:
// shell-adapter.js —— 文件名清洗:去除路径分隔符等非法字符,保留中文,统一 .drawio 后缀
function sanitizeName(raw) {
var name = String(raw == null ? '' : raw).trim();
name = name.replace(/\.[a-zA-Z0-9]+$/, '');
name = name.replace(/[\\\/:*?"<>|]+/g, '_');
name = name.replace(/\s+/g, ' ').trim();
if (name.length === 0) { name = '未命名图表'; }
return name + '.drawio';
}
上游默认文件名会带时间戳或后缀,而鸿蒙保存框会把建议名直接预填;不洗一遍的话,中文图表名里碰上 / : 这类字符就会写盘失败。这是“壳环境里真实存在的文件系统差异”,不是防御性冗余代码。
十四、后台元数据:一个“基于上游发行包”的应用该怎么描述
壳工程改完、包打出来、功能复测过,还剩 AGC 后台那一堆元数据。对自研工程来说这只是填表;而对一个本体是上游发行包的应用,描述的分寸感本身就是审核点:写得比实际少是浪费了功能,写得比实际多是宣传不符,声称引擎是自己写的则一查仓库就穿。
先说名称这个权威源问题。本工程实测有两种写法并存:
// images/manifest.json —— 节选(icons 与 file_handlers 略),名称带空格、描述不带空格,且描述里仍留着“在线”
{
"name": "图匠 流程图编辑器",
"short_name": "图匠",
"description": "图匠流程图编辑器 - 免费在线流程图制作工具",
"theme_color": "#DF6C0C",
"background_color": "#DF6C0C",
"display": "fullscreen"
}
AppScope 的 app_name、三套 EntryAbility_label、index.html 的 title、manifest.json 的 name 都是“图匠 流程图编辑器”(中间一个空格);而 package.json 的 description、manifest.json 的 description、上游遗留页面的 meta 写的是“图匠流程图编辑器”(无空格)。提审材料以桌面显示名为准,也就是带空格那一版,后台应用名称与包内严格一致(包括那个空格)。版本号两处已经对齐:app.json5 的 versionName 与 package.json 的 version 同为 1.0.0,后台版本名称就填 1.0.0,不要把上游发行包的版本号写进来,那是另一套编号体系,写进去只会造成包内外对不上。
然后是描述分寸。本工程的真实情况是:引擎与形状库来自上游,壳与适配逻辑是自研,运行完全本地。因此应用介绍要按这个顺序成文(约 200 字一段连续文本,不分点堆参数):开源免费 → 核心编辑能力(流程图 / UML / ER / 网络拓扑 / BPMN / 组织架构,形状库与样式编辑)→ 文件形态(drawio 与 xml 本地读写、导出图片、打印)→ 离线与隐私(图表文件只存在本机,不上传不同步)→ 适用人群。两句不能写:一是不能写“从零自研的绘图引擎”,仓库里那个 9883 KB 的产物一翻就知道;二是不能写“支持云端保存 / 团队协作 / 多端同步”。
第二条不是臆想的风险,而是这个工程里真实存在的诱导。app 根目录除 index.html 外还留着 9 个上游页面——clear.html、dropbox.html、export3.html、github.html、gitlab.html、onedrive3.html、open.html、teams.html、vsdxImporter.html,文件名本身就带着云端品牌名。它们在壳内没有任何入口能够直达(保存与打开已全部收口到系统对话框,子菜单只留“设备”项),但会被静态扫描读到,而且 teams.html 里实测同样有 4 处“在线”字样。处理口径二选一:要么删干净,要么在提审说明里写清“上游遗留页面,壳内不可达”——留着不删又不解释,是描述性驳回里最常见的一种,本工程属于后者,也在待办单上。
一句话简介(小编推荐)同样按字符计数,中文、字母、数字、标点全部占位,17 字以内。本工程的建议写法:
- 主选“鸿蒙离线流程图编辑工具”,只占 11 个字符,余量充足且结尾词完整。
- 备选“离线流程图绘制与编辑工具”,12 个字符,把绘制与编辑两件事都带上。
- 不要为了把功能写全而加“在线”两字。第三节已经论证过:完全本地运行的应用把自己描述成在线工具,属于描述不一致,这一句在提审时会被直接拒。也不要写到 16、17 字去卡线,实测卡线写法会在展示位被截半字。
截图这一项在本工程尤其重要,因为裁剪结果本身就是卖点,也是证据。除了主编辑区(带左侧形状库与右侧格式面板、可看到流程图内容),建议再交三张:“文件 - 新建”模板对话框(证明只剩本地入口、无云端位置项);空画布右键菜单(证明没有“在此粘贴”);右下角“关于”面板(证明开源声明与免责条款可被用户看到)。这四张图覆盖了三轮驳回里提过的每一个点位,审核员按可见元素判,你就按可见元素交,比在备注里写文字解释有效得多。
隐私政策这一项要回到权限表。本工程 module.json5 里保留着 INTERNET 与 GET_NETWORK_INFO,所以不要在隐私政策里写“本应用不申请任何网络相关权限”——那与包内声明直接矛盾,属于一拉就能对上的硬伤。正确口径是分开写:“应用不收集、不上传用户图表内容;图表文件的读写全部发生在本地磁盘”;网络权限存在是壳与引擎能力(证书管理、打印、组件加载)所需,不用于数据上报。同理,第四节那条反向复核在这里也适用:GYROSCOPE 与 ACCELEROMETER 在适配层零命中,下一版删除它们之前,不要把“仅申请必需权限”写成一个已完成的承诺。
协议表述则已经在第十二节对齐过了:关于面板两段都写 Apache License 2.0,package.json 的 license 是 Apache-2.0,上游 LICENSE 文件也是 Apache 2.0,三处同一个答案。最后提醒一句发布设置:文章与应用都不要设成关注博主可见或 VIP,元数据要对所有审核人员可见。
十五、上架前自查清单(对着勾)
把上面所有点收敛成一份可复用清单。分三段,因为它们的核查手段完全不同:前段靠脚本穷举,中段靠真机走查,后段靠两处对读。
配置与品牌残留(可静态穷举)
- 应用名逐处同值:app_name、base / en_US / zh_CN 三套 EntryAbility_label、index.html 的 title、manifest.json 的 name(含中间那个空格)
- bundleName 全仓统一,与 p12 / cer / p7b 签名材料同轮修改;DevEco 运行配置的 Bundle Name 已手工同步
- vendor 为 AGC 真实开发者名;versionName 与 package.json 的 version 两处一致
- AppScope 三张图标哈希同源(实测 app_icon / product_logo_32 / startIcon 三文件 MD5 相同);商店图标与桌面图标同一张
- 品牌残留已分两层:241 个可改文本文件里 290 处逐个分类;1216 处不可改命中只覆盖显示层,产物未动
- 统计口径已写明(全目录 25 个 .min.js,名单排除 12 个,两者命中 1216 / 1217)
- 上游版权头一行未删(290 处命中里有 156 处属 Apache 2.0 要求保留的声明,不可当作残留处理)
- 文件对话框两处 filters 的 name 字段待改(旧标识仍会出现在系统对话框的格式下拉里)
- index.html 与 teams.html 的“在线”描述、meta 里的 googleusercontent 外链、manifest.json 的 description 待收敛
- manifest.json 的 theme_color 与 index.html 的 theme-color 已统一(现为 #DF6C0C 与 #d89000 两值并存)
- 上游遗留的 9 个云端页面已删除或在提审说明中声明不可达
权限与行为接管(需真机走查)
- 权限清单 14 项,grep ohos.permission. 得 15 属正常基线(14 加 definePermissions 重复的 LOCK_WINDOW_CURSOR);若文件里有注释掉的权限行,统计前先剔除
- 保留项反向复核:每一项在 ets 层能找到对应 API 调用(sensor / gyroscope / accelerometer 零命中,属待删项)
- READ_PASTEBOARD 声明 0 命中,且 PermissionManagerAdapter 两个授权方法体内均有 pasteboard 提前 return(判据只看方法体)
- 四类原生 UI 入口全部有替代路径:原生 select 下拉、alert / confirm / prompt、mxWindow 系窗口、target=_blank 表单提交
- 主进程 setWindowOpenHandler 返回 deny,will-prevent-unload 里 preventDefault
- 能用官方配置面裁掉的项没被 hook 重复实现:hideMenuItems 实测 18 项、hideMenus 1 项
- 轮询内九个 hook 都返回 true;其中 iconOk 因“removed || true”恒真,不具备诊断价值(见第七节)
- 保存 hook 确认命中 App.prototype 分支(仅 EditorUi 层被覆盖会被子类挡住)
- 实例级覆盖处已把隐带副作用补回(copyAsImage / copyAsSvg)
- 新建走 App.prototype.createFile,“从模板URL地址”按钮已隐藏;颜色选择强制模态;空画布右键无“在此粘贴”
- window.onbeforeunload 吞掉计数大于 0(证明封堵真在工作,而不是本来就没人在注册)
- 便签本不出现,且 3 秒延时复查已跑过
交付一致性(两处对读)
- 保存 / 打开的成功、取消、失败三个分支都有状态位反馈,无一处弹窗
- showSaveDialog 读单数 filePath、showOpenDialog 读复数 filePaths,两个通道未串用
- 保存成功后 setModified(false) / setFilename / updateDocumentTitle 已回写,标题栏不留虚假的修改星号
- 开源协议三方一致:关于面板文案、package.json 的 license、仓库 LICENSE 同一个答案
- 后台应用名 / 介绍 / 一句话 / 隐私政策 / 截图与包内表现完全一致,未声称自研引擎也未声称云端能力
- 最后一轮回读确认无 IDE 旧缓冲区还原(构建前先关掉 DevEco 里本工程的旧标签页)
十六、总结
图匠这一轮上架最大的特点是:能改的代码一行都没有多改,所有行为调整全部在产物外面包层。三轮驳回里两轮“应用整体退出”、一轮“功能异常”,最后收敛到一个根因和三层手段上。最值得记住的是五点:
- 先判断工程类型,再选方法论。 自研工程改函数,发行包工程改可见行为。拿到一个工程,先数一下后缀为 .min.js 的文件和它们的体积——这个比例决定了你的修复手段只能“包在外面”,也决定了后续所有排查思路。
- 分散的崩溃先找统一根因。 下拉框闪退、弹框闪退、改样式闪退、保存闪退看上去是四个问题,实际都是“鸿蒙适配层把这类原生 UI 实现成 SubWindow,XComponent 等待超时即 SIGABRT”。一旦把根因归并成一页表,修法就自动统一了:要么把交互换成纯 DOM,要么把入口直接替掉。先把入口穷举成表再开工,比一个一个修快很多。
- 官方配置面优于原型 hook,原型 hook 优于实例覆盖。 本工程的菜单裁剪全部走 PreConfig.js 的 hideMenus(1 项)与 hideMenuItems(18 项),零维护成本;hook 只用在配置面盖不住的行为上。而实例覆盖是最脆的一层,它在产物里只有 34 处,却能让你的原型 hook 完全失效——所以排查 hook 为何不生效时,第一件事是搜实例级赋值,而且用可搜索特征串而不是行号(产物行号会因构建漂移,工程注释里记的那个行号就已经差了一行)。
- hook 的完成判据必须独立于外部状态。 三个坑全部实测过:完成标记排在成功校验之前会让轮询提前退出且永不重试;拿实例捕获做判据会因菜单构建时序窗口而永不达成;120 秒超时只是省资源,不会把没装上的 hook 补上。轮询骨架里九个标记、幂等实现、先校验后置位,缺一个都会变成“真机时对时错”的难查问题。
- 接管一个功能,就要接管它的内部状态和隐带副作用。 覆盖了 save 就得把 setModified / setFilename / updateDocumentTitle 补回来;覆盖了实例菜单构造就得把 copyAsImage / copyAsSvg 补回来;吞掉关闭拦截就得把“未保存会丢”当作显式取舍写下来。修好了 A、静默弄坏了 B,下一次驳回就是 B,而 B 在代码里根本没有痕迹。
最后一句给同样在改上游发行包的朋友:清单上未处理完的项要老实写出来。本文里过滤器名、四处“在线”文案、meta 外链、主题色双值并存这四个都是当前真实状态,它们不会导致构建失败,却最容易在人工审核里暴露。按这份流程走完,欢迎加入文首的开源鸿蒙 PC 社区,一起交流更多踩坑经验。
更多推荐



所有评论(0)