鸿蒙 PC Markdown 编辑器跨应用分享权限验证
摘要
Markdown 编辑器里的“分享”并不是把一个按钮接到系统接口就算完成。对本地优先的桌面编辑器来说,真正需要证明的是一条跨越编辑缓冲区、应用沙箱、系统能力匹配和接收应用权限边界的完整数据链:用户尚未保存的正文必须进入快照;快照不能暴露原工作区文件;发送端只能授予必要的临时读取能力;另一个独立进程必须能按声明的 MIME 类型获得 URI、完整读取字节并严格解码;失败时编辑器还要保留当前正文、文档身份和脏状态。
本文记录 OhMarkdown 在 HarmonyOS 6.1.1、API 24、MateBook Pro 2in1 模拟器上完成的一次受控跨应用验证。仓库地址为 https://gitcode.com/VON-/codex_md_oh,分享接收测试应用与证据进入提交d9c8384。发送端输入未保存标记3G_08_MARKDOWN_SHARE_20260721,独立包名com.example.ohmarkdown.sharereceiver的接收端最终显示29 字符 / 29 字节,正文逐字符一致。这项结果证明当前模拟器上的sendData + text/markdown + 临时 URI主链可工作,但它不等价于第三方产品兼容性,也不能替代鸿蒙 PC 真机验证。
一、为什么必须从“系统面板出现”继续向后验证
系统分享面板出现,只能说明发送端发起了一个系统请求。它不能证明系统找到正确接收方,不能证明 MIME 过滤正确,不能证明 URI 权限已经转交,更不能证明接收进程读到的是编辑器当前缓冲区。此前模拟器没有兼容 Markdown 接收 Ability,OhMarkdown 发起分享后系统最终返回No matching ability is found。这个结果验证了失败恢复,却没有覆盖成功路径。
成功路径至少包含六个可独立观察的事实。第一,编辑器读取的是 CodeMirror 当前值,而非最后一次落盘值。第二,发送端在自己的缓存目录创建独立快照并完成truncate、完整写入和fsync。第三,Want 的 action、type 和 stream URI 同时满足系统能力匹配要求。第四,系统给目标 Ability 临时只读授权,而不是让接收方永久访问发送端沙箱。第五,接收端通过 Core File Kit 读取全部字节,并使用严格 UTF-8 解码。第六,跨应用结束后发送端仍维持未保存状态,不把分享误当保存。
如果只截一张系统分享页面,就会把上述六条压缩成一个无法审计的视觉印象。此次验证专门增加了独立测试应用,使发送端和接收端拥有不同 bundle、不同 Ability 生命周期和不同应用沙箱。这样才能把“应用内自读缓存”提升为真正的跨应用权限验证。
二、受控接收器与产品代码必须隔离
接收器位于仓库tools/markdown-share-receiver/,它不是 OhMarkdown 的 entry 模块,也不会随产品 HAP 打包。测试工程使用独立 bundlecom.example.ohmarkdown.sharereceiver,只支持2in1,其目标是给模拟器提供一个行为明确、可复现、可审计的接收方。根目录脚本scripts/build-share-receiver.sh负责使用同一 DevEco Studio SDK 独立构建测试 HAP。
这种隔离有三个价值。其一,接收成功不能借助 OhMarkdown 自身的内存对象,正文必须经过系统跨进程 URI。其二,测试能力不会扩大产品权限清单,正式应用仍不需要 INTERNET,也不会因为测试而声明接收外部分享。其三,接收端可以主动采用更严格的读取限制,从而暴露发送协议中的 MIME、URI 或编码错误。
接收器的模块声明只匹配明确的发送动作和 Markdown 类型:
{ "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true, "skills": [ { "actions": [ "ohos.want.action.sendData" ], "uris": [ { "scheme": "file", "type": "text/markdown" }, { "scheme": "datashare", "type": "text/markdown" } ] } ] }这里没有使用宽泛的*/*。如果接收器接受任意类型,发送端即使错误地标记为纯文本或二进制,也可能“碰巧成功”,测试就失去了协议约束。file与datashare两种 scheme 则用于兼容 HarmonyOS 系统分发时可能提供的授权 URI 形式,接收端不会自行推导原始路径。
三、未保存正文才是分享一致性的关键语料
对编辑器而言,最危险的分享错误不是调用失败,而是调用成功却分享旧内容。用户打开一个磁盘文件,继续输入但没有保存,此时内存正文、磁盘正文和恢复快照可能分别处于不同修订号。若分享服务重新从文件 URI 读取,就会静默发送旧版本,界面却没有任何异常。
因此设备验证没有使用已经保存的样例文件,而是在Untitled.md中输入唯一标记3G_08_MARKDOWN_SHARE_20260721,保持标签脏标记,再从 OhMarkdown 的导出设置中触发“分享 Markdown”。这个字符串全为 ASCII,字符数与 UTF-8 字节数都应是 29,便于第一轮快速核对链路是否发生截断、额外换行或编码替换。后续真机矩阵还应增加中文、组合字符、emoji、CRLF 和接近大小上限的语料,但唯一 ASCII 标记更适合定位首次跨进程接通问题。
发送端的正确事实来源是当前编辑会话:
constmarkdown:string=this.getCurrentDocumentContent();constsharedUri:string=awaitthis.exportService.createMarkdownShareSnapshot(this.context,this.currentDocumentName,markdown);awaitthis.context.startAbility({action:'ohos.want.action.sendData',type:'text/markdown',uri:sharedUri,parameters:{'ability.params.stream':sharedUri},flags:wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION});这段示意对应当前仓库的真实实现边界:内容来自编辑缓冲区,分享对象是缓存快照,Want 同时携带 Markdown MIME 和 stream URI,并只授予读取能力。分享不会修改文档的原 URI、保存基线或脏状态。即使目标应用启动失败,用户也可以继续编辑和再次选择目标。
四、为什么不能直接分享工作区原文件
直接把工作区原文件 URI 交给目标应用看似省掉一次写入,却会混淆两个授权域。用户授予 OhMarkdown 的工作区访问权,不代表用户同意任何接收应用获得同样的长期访问。原文件还可能包含旧版本,因为当前内容尚未保存。更糟的是,某些目标应用可能持有 URI 较长时间,导致编辑器难以说明权限生命周期。
独立缓存快照把分享语义变为“本次用户动作产生的一份只读内容”。它有明确文件名、明确正文和受控清理策略。当前实现只清理严格匹配share-*.md的普通文件,跳过目录、符号链接和无关缓存;最近最多保留 32 份,并清理超过 24 小时的旧快照。当前正要交给系统的 URI 始终保留,清理失败只记录为尽力而为,不反向破坏本次分享。
这仍不是绝对保密机制。任何获得临时 URI 的接收方都可以在授权有效期间复制正文,这正是用户主动分享的含义。安全目标不是阻止目标读取,而是把读取限制在用户明确选择的快照,避免额外暴露工作区结构和其他文档。
五、接收端只相信 Want 中的受限输入
接收 Ability 可能通过首次启动的onCreate收到 Want,也可能在已经存在的进程中通过onNewWant收到后续分享。如果只处理首次启动,第二次分享可能继续显示旧正文;如果每次启动都清空界面但异步读取结果发生乱序,又可能让早先请求覆盖后来请求。当前测试应用规模很小,先同时覆盖两个生命周期入口,后续多分享并发测试可以再增加代际编号。
URI 的读取只接受want.uri或系统约定的ability.params.stream字符串:
privategetShareUri(want:Want):string{if(want.uri&&want.uri.length>0){returnwant.uri;}conststream=want.parameters?.['ability.params.stream'];returntypeofstream==='string'?stream:'';}privateasyncreceiveMarkdown(want:Want):Promise<void>{constsharedUri:string=this.getShareUri(want);if(want.type!=='text/markdown'||sharedUri.length===0){AppStorage.setOrCreate('receiverStatus','未收到有效 Markdown 分享');return;}// 仅在类型与 URI 同时成立时继续读取。}接收端不会把 parameters 中的任意对象转成路径,也不会根据显示文件名拼接沙箱地址。严格比较text/markdown可以及时暴露发送端协议退化。生产级通用接收器可能还要支持text/plain等兼容输入,但这个受控工具的目的不是提高匹配率,而是证明 OhMarkdown 按约定发送了准确类型。
六、临时 URI 读取必须经过 Core File Kit
跨应用 URI 不是普通 POSIX 路径。接收端不应裁剪file://前缀后自行拼路径,也不应假设发送端缓存目录在自己的文件系统命名空间可见。正确方式是把系统分发的 URI 直接交给 Core File Kit,由系统同时校验临时授权和目标进程身份。
读取实现使用READ_ONLY | NOFOLLOW,在打开后先获取 stat,并拒绝超过 4 MiB 的验证输入:
privateasyncreadUtf8Content(sharedUri:string):Promise<ReceivedMarkdown>{constfile=awaitfileIo.open(sharedUri,fileIo.OpenMode.READ_ONLY|fileIo.OpenMode.NOFOLLOW);try{conststat=awaitfileIo.stat(file.fd);if(stat.size<0||stat.size>4*1024*1024){thrownewError('分享文件超过 4 MiB 验证上限');}constbytes=newUint8Array(stat.size);lettotalBytesRead:number=0;while(totalBytesRead<stat.size){constrequestedBytes=Math.min(64*1024,stat.size-totalBytesRead);constchunk=newArrayBuffer(requestedBytes);constbytesRead=awaitfileIo.read(file.fd,chunk,{length:requestedBytes});if(bytesRead<=0){thrownewError('分享文件读取未完成');}bytes.set(newUint8Array(chunk,0,bytesRead),totalBytesRead);totalBytesRead+=bytesRead;}constdecoder=util.TextDecoder.create('utf-8',{fatal:true,ignoreBOM:false});return{content:decoder.decodeToString(bytes),bytes:totalBytesRead};}finally{awaitfileIo.close(file);}}NOFOLLOW避免测试接收器无意跟随符号链接;4 MiB 是验证工具自身的防御上限,不是 OhMarkdown 产品文档上限;64 KiB 分块循环避免把一次read误认为必然返回完整文件。fatal: true让非法 UTF-8 明确失败,不用替换字符掩盖字节错误。finally 关闭句柄保证成功、短读、解码失败和界面异常都不泄漏文件描述符。
七、字符数与字节数必须同时显示
只比较 UI 文本“看起来一样”不够稳健。JavaScript/ArkTS 字符串长度使用 UTF-16 代码单元,而文件大小来自 UTF-8 字节;中文和 emoji 会让两者不同。接收器同时展示content.length与实际读取字节数,因此后续语料可以发现编码边界问题。
本轮标记由 29 个 ASCII 字符组成,期望值恰好是29 字符 / 29 字节。截图中接收器显示文件share-Untitled.md、成功状态、精确计数和完整标记;设备 hilog 只输出Markdown received: 29 characters, 29 bytes。日志故意不包含正文、完整 URI 或路径,这样证据既可判断读取结果,又不会把用户内容复制到系统日志。
下一轮中文语料可以选择鸿蒙PC分享验证,此时字符数与字节数会显著不同。验收应比较原始 UTF-8 哈希或明确的字节数组,而不是错误地要求两个计数相等。本文记录 29/29 只是本次受控输入的事实,不是协议的一般性质。
八、模拟器操作链与证据闭环
验证使用正在运行的 MateBook Pro 2in1 模拟器,物理显示为 3120×2080。首先独立构建并安装测试接收 HAP,再安装当前 OhMarkdown Debug HAP。确认bm dump中两个 bundle 分离后,通过桌面界面打开 OhMarkdown,在未命名文档输入唯一标记,保持标签未保存状态,进入设置中的导出区域并点击“分享 Markdown”。
系统根据sendData、text/markdown与 URI scheme 匹配到测试接收器,启动独立窗口。接收器通过临时授权 URI 读取缓存快照,显示成功状态。屏幕中同时能看到 OhMarkdown、独立接收窗口和文件管理器,说明结果不是 Web 页面内模拟的弹层。证据文件保存在docs/test/ohmarkdown/2026-07-20-g3-08-export-share/evidence/emulator-cross-app-share-receiver.png,原图为 3120×2080 PNG。
接收测试 HAP 大小为 137710 字节,SHA-256 为02f26ba578a15fbe00dd7c1a6890d71bc728dea18f34ff44e115f1b7f46c20e5。它通过以下脚本复现:
./scripts/build-share-receiver.sh# 产物仅用于测试,不进入正式包tools/markdown-share-receiver/entry/build/default/outputs/default/entry-default-unsigned.hap随后执行主仓库统一门禁,Playwright44/44通过,精确 10 MiB Web 保护模式回归在当前环境记录 80 ms,生产单 HTML、Debug HAP 和 ArkTS UnitTestBuild 均成功。这组回归用于确认新增测试工程和证据没有改变正式产品构建,不把接收工具误并入应用依赖。
九、测试接收器自身也要遵守最小权限
测试工具常被当成临时代码,反而容易引入比产品更宽的权限。这个接收器不申请 INTERNET,不读取剪贴板,不访问账号,不扫描公共目录,也不声明后台任务。它只在系统显式启动 Ability 时读取一个 Want 提供的 URI。正文仅保存在当前界面的 AppStorage 中,hilog 只记录公开计数。
接收器的exported: true是系统跨应用启动所必需的攻击面,因此必须和精确 skills、MIME 校验、大小上限、严格解码共同使用。如果它接受任意 action、任意 URI 并在日志打印正文,模拟器验证虽然方便,却会建立错误的安全示范。受控工具的价值就在于用很小的代码把授权边界写清楚。
HAP 是未签名 Debug 测试产物,只用于当前模拟器。仓库通过嵌套.gitignore排除.hvigor/、entry/build/、oh_modules/和本地配置,提交只包含可审查源码、构建脚本与真实截图。正式构建不会引用该工程。
十、成功读取不等于兼容所有目标应用
这次接收成功的含义非常具体:当前 HarmonyOS API 24 模拟器能够把 OhMarkdown 创建的 Markdown 快照 URI 和临时读权限交给另一个独立 bundle;接收方能完整读出 29 字节。这已经关闭“模拟器完全没有兼容接收方,无法观察成功路径”的局部证据缺口。
它不能证明三个更大的结论。第一,第三方笔记、邮件、聊天或云盘应用可能只声明text/plain、只读取PARAMS_STREAM数组,或对文件名、URI scheme 有额外约束。第二,真机系统的分享选择器、目标排序、跨应用权限生命周期和多窗口行为可能与模拟器不同。第三,接收方可能异步延迟读取,如果发送端过早删除当前快照,会出现启动成功但稍后读取失败。
因此 G3-08 仍然是“条件保留”。受控跨应用分享已经通过,真正的第三方接收应用和鸿蒙 PC 真机仍是 RC 门槛。竞争优势记分卡也不能因为这次测试从 2 分直接升到 4 分。证据边界清楚,比把一次成功包装成全面兼容更重要。
十一、分享缓存生命周期与临时授权的配合
发送端当前最多保留最近 32 份或 24 小时内的分享快照。这一策略控制的是本地敏感内容留存,不直接定义系统授予目标的 URI 权限持续时间。两者必须分开理解:文件存在不代表目标仍有权读取,目标有短暂授权也不代表文件可以在交付前删除。
当前策略的关键点是清理时永远跳过本次将要分享的 URI。完成fsync后再启动系统 Ability,避免目标读到半写文件。其他旧快照按修改时间和数量清理;mtime 在 HarmonyOS 模拟器上可能返回秒级,服务已把秒与毫秒统一后通过连续 36 份设备测试,最终保留 32 份。
受控接收器的即时读取证明当前时序可用,但还应设计延迟目标:接收 Ability 启动后等待 1 秒、10 秒和进程重启再读取,观察系统授权与缓存清理的组合行为。该测试必须在真机和至少一个真实第三方目标上执行,才能决定 24 小时是否只是隐私上限,还是还需增加“系统返回后延迟清理”的兼容策略。
十二、失败模型比成功截图更有工程价值
跨应用分享可能在五个阶段失败:能力匹配失败、目标启动失败、URI 授权失败、文件读取失败、UTF-8 解码失败。发送端能直接观察的通常只有前两项,后面三项发生在目标进程中。没有受控接收器时,错误很容易被归因于“模拟器问题”,无法知道协议到底走到哪一步。
接收器把后三类错误转成界面状态,并在不暴露正文的前提下记录错误说明。短读会得到“分享文件读取未完成”,超过 4 MiB 会得到明确上限错误,非法 UTF-8 会由 fatal 解码抛出,错误后正文区域清空。这样测试不会残留上一次成功内容,让观察者误认为本次也成功。
发送端仍需保持自己的恢复语义:失败不能清空编辑器,不能移除脏标记,不能把缓存 URI写入文档会话,也不能把“分享失败”记成“保存失败”。此前无匹配 Ability 的模拟器路径已经验证应用可继续编辑;本轮成功路径补齐了对称证据。
十三、后续语料矩阵
单一 ASCII 标记适合接通链路,不足以覆盖 Markdown 文档真实复杂度。下一组自动化和真机用例应至少包含以下语料:UTF-8 BOM 与无 BOM;中文标题和中文文件名;emoji 与代理对;LF、CRLF 和混合换行;空文档;末尾有无换行;包含 NUL 或非法 UTF-8 的拒绝路径;1 MiB 与接近 4 MiB 的分块读取;包含图片相对链接但不携带图片的标准 Markdown;同一目标连续接收两次以覆盖onNewWant。
每项都应记录发送缓冲区 UTF-8 SHA-256、接收字节 SHA-256、字符数、字节数、目标 bundle、系统版本与时间。对文本保真而言,哈希比截图更强;截图用于证明真实系统窗口和用户路径,哈希用于证明字节级一致。两者互补,不能相互替代。
还应加入拒绝语料:text/plain不应被这个严格测试接收器匹配;无 URI 的 Want 应显示无效分享;超限文件应拒绝;符号链接 URI 应因NOFOLLOW失败;目标取消或被强杀后发送端正文不变。只有把失败路径也纳入,分享才是可靠能力,而不只是一次演示。
十四、与 PDF 闸门的关系
G3-08 同时包含 HTML、PDF、PNG 和分享,但四类输出的系统依赖不同。HTML 与 PNG 已经通过系统选择器写入,并分别由独立浏览器和图片查看器打开;Markdown 现在又完成受控跨应用接收;PDF 仍停留在系统打印预览,因为当前模拟器显示“无可用打印机”,无法生成实际 PDF 文件。
分享接收器不会帮助关闭 PDF 闸门。应用复用 ArkWeb 打印适配器与 HarmonyOS 系统打印服务,必须在提供“保存为 PDF”或可用打印目标的环境生成真实产物,再由独立阅读器打开,核对分页、字体、公式、图表、代码块、主题与危险内容净化。两条系统链应分别报告,不因分享成功而合并判定。
这也是阶段状态保持“研发条件通过、公开发布资格未通过”的原因。没有真机时可以继续 G4 的硬件无关开发,但不能删除 PDF、真实第三方接收方和真机矩阵这些 RC 硬门槛。
十五、可复现的最小验证清单
复现本次结果时,先构建 OhMarkdown 与接收器两个 HAP,确认 bundle 不同;安装后清空旧任务,启动 OhMarkdown;输入一个从未落盘的唯一标记并记录预期 UTF-8 字节;发起text/markdown分享;在独立接收器核对文件名、字符数、字节数与正文;回到 OhMarkdown 核对脏标记和编辑能力;最后执行主仓库统一测试。
验收记录至少包括下面这些字段:
发送应用:com.example.ohmarkdown 接收应用:com.example.ohmarkdown.sharereceiver 动作:ohos.want.action.sendData MIME:text/markdown 输入:3G_08_MARKDOWN_SHARE_20260721 预期:29 字符 / 29 字节 实际:29 字符 / 29 字节 平台:HarmonyOS 6.1.1 / API 24 / MateBook Pro 2in1 模拟器 结论:受控跨应用读取通过;真机与真实第三方目标待验任何一项缺失都应降低结论强度。例如只有接收端截图而没有发送端未保存状态,就不能证明缓冲区一致性;只有 hilog 而没有独立 bundle 信息,就不能排除应用内调用;只有模拟器成功而没有环境标记,就不能写成鸿蒙 PC 真机兼容。
十六、工程结论
本轮最大的进展不是增加一个测试窗口,而是把 G3-08 的系统分享从“发送端调用与失败恢复已验证”推进到“模拟器受控跨应用权限链已验证”。独立 bundle 按精确 MIME 被系统匹配,通过临时 URI 读取 OhMarkdown 尚未保存的 29 字节快照,严格 UTF-8 解码后正文一致;测试应用不联网、不读取原工作区、不记录正文,并对文件大小、符号链接和短读设置防线。
与此同时,证据没有被夸大。这个接收器是仓库内可审计的工程测试工具,不是真实第三方软件;模拟器不是鸿蒙 PC 真机;即时读取不能替代延迟读取生命周期;分享成功不能替代系统 PDF 产物。因此当前可以确认方案和权限链成立,可以继续后续开发,却仍需在 RC 前完成真实目标应用、两档真机、复杂语料和实际 PDF 的独立验收。
对本地优先 Markdown 编辑器而言,这种结论边界本身就是可靠性的一部分:每次只声称证据真正覆盖的范围,让代码、设备、截图、哈希和阶段门槛能够互相对照。这样开发速度不会被暂缺真机无限冻结,产品质量也不会因为“模拟器看起来能用”而提前透支。