一段文字从另一款应用复制过来,编辑器收到的可能不是一段普通字符串。它有时同时带着富文本片段、图片引用、链接和用于回退的纯文本。界面只要有一句“粘贴成功”,开发者就容易把来源已经可信、附件一定存在、内容可以安全展示混为一谈。对于学习笔记编辑器,这种误判尤其隐蔽:字能显示,不代表图片还在;今天能显示,不代表下一次启动仍能解析;粘贴动作已经取消,不代表前一次异步读取不会晚到。

我把这个矛盾拆成一个小型应用侧准入器,叫 PasteEvidenceGate。它没有伪装成系统剪贴板或 UDMF 的替代实现,只负责接收上游已经解析出的结构化记录,然后给编辑器产生可以复核的业务决定:保留受信任的富文本、回退到预备纯文本,或者整条拒绝。真正的系统剪贴板读取和富文本解析在本轮都明确标记为未运行。

一、复制结果与业务承诺不是同一件事

剪贴板的系统职责,是让来源应用把数据交给目标应用;编辑器的业务职责,是决定这些数据能否进入自己的内容树。前者涉及权限、数据对象生命周期和用户触发条件,后者还要解决资源引用、显示策略以及持久化后的可重放性。把二者合成一个布尔值,会让错误在保存后才暴露。特别是复制一篇包含三张图片的说明文字时,富文本主体和图片文件的可用性可能并不同时成立。

华为的“使用剪贴板进行复制粘贴”文档列出了文本、HTML、URI、Want、PixelMap等基础类型,并展示 getSystemPasteboard、getData、getUnifiedData 的读取路径。文档也明确提示较新API版本对剪贴板读取增加隐私权限管控。因此不能在页面创建时悄悄读取,更不能把示例模型的八条记录称为真实系统回调。本文只将官方接口当作未来连接系统的边界点,不把任何应用自定义业务方法冒充平台API。

为什么不收到 HTML 就直接渲染?因为链接和资源引用有不同的风险。一个指向应用内已管理资源的标识,可以在保存之前核对闭包;一个未知外站链接即使能在浏览器打开,也不代表应当由编辑器自动加载。更麻烦的是,有些标记不受当前渲染器支持,这时应该明确回退到来源附带、经过可信解析准备的纯文本,而不是尝试用几条正则表达式“清洗任意 HTML”。后者很容易留下脚本、事件属性和编码绕过。

我把业务规则定义得相当克制。只接受本轮资源清单已经存在的 A01、A02、A03,允许没有外部跳转的普通文本;对不能受信任地展示的标记走纯文本回退;对于孤儿附件、非受控外链与旧代次回调直接拒绝。这个门禁在产品里还需要用户确认、来源权限判断、实际URI打开失败处理,但当前固定夹具先证明最基本的状态决策不会互相穿透。

二、先固定八条输入的证据契约

Demo 使用项目名 PasteEvidenceGate,展示页 PasteInboxPage,诊断页 PasteAuditPage。本轮任务ID固定为 PST-1011-30,数据集合叫 clipboard_bundle_08,当前应用粘贴代次是 epoch=4,页面示意时间05:41、状态栏电量88%。所有配图、代码和后文的日志都使用这套约定。这里的条目大小是输入清单自述的字节长度,不是通过系统 API 测出的粘贴板占用量。

八条数据分别是:R01 普通文本120B;R02 引用A01的富文本512B;R03 包含不支持标记但具备可信纯文本回退180B;R04 引用A02的富文本400B;R05 引用不存在的A09资源320B;R06 包含未经允许的外部链接256B;R07 引用A03的富文本384B;R08 来自epoch3的过期数据80B。A01、A02、A03已在固定资源目录声明存在,A09不在清单中。任何展示出来的图片缩略图都只是示意图片,不代表实际读取了其它应用的图片文件。

结果契约是八条里接受五条、阻断三条;接受记录包含富文本三条和纯文本两条,其中纯文本两条之一就是R03的安全回退。拒绝原因各一条:ORPHAN_ASSET、UNSAFE_LINK、EPOCH_STALE。最终批次状态 PASTE_PARTIAL。这个状态不能被当成整个编辑器“全部粘贴成功”,而是说有些记录可被继续处理,另一些必须由用户看到清楚的提示。

还有一个容易误会的数字:资源数量三,指的是这批已登记且可用于示意的资源ID数量,而不是系统已经读取或复制过三个附件。只有在业务校验接受一条富文本后,后续保存模块才可以在权限仍有效的前提下处理它关联的文件;保存成功仍要另外拿到落盘回执。我们用两个阶段的语言划清这条界线,避免用界面“存在”二字替代真正的I/O证据。

三、系统接入只做取数,决策必须留在业务层

在真正的用户粘贴按钮触发点,可以先通过官方剪贴板对象取得统一数据。下面这段ArkTS只展示官方接入边界,故意不把未知记录属性硬编码成不存在的字段;实际的 UnifiedData 记录转换器应按当前SDK类型定义另行适配,并完成权限与异常处理。它解决的是“把系统读取与应用规则解耦”,不是宣称调用后就获得了本例的八条固定样例。

import { pasteboard } from '@kit.BasicServicesKit';
import { unifiedDataChannel } from '@kit.ArkData';

export async function readOnExplicitPaste(): Promise<unifiedDataChannel.UnifiedData | null> {
  try {
    const board = pasteboard.getSystemPasteboard();
    return await board.getUnifiedData();
  } catch (error) {
    console.error(`explicit paste failed: ${String(error)}`);
    return null;
  }
}

这段调用本身不应放进页面的 aboutToAppear 中悄悄执行。页面退场或下一次用户按粘贴时,要递增应用代次;转换器若后来完成,也只能提交给当代页面。读取失败、用户没有授权、统一数据不包含可用记录都应进入明确空态。业务层不依赖“上次读到过”这种记忆,防止旧缓存被误认为今天用户的操作。实际环境的权限和接口支持范围需要随构建目标SDK重新核对,本轮没有做任何系统请求。

工程上我把输入转换为自己的 PasteRecord:它包含 id、epoch、kind、byteLength、assets、links、supportedMarkup 和 fallbackText。这些全是应用自己的字段,不是HarmonyOS UDMF标准对象的固定签名。上游适配层要提供不可变快照,不能在验证过程中继续往数组里追加数据,否则先验证R05再填进一个同名资源就会出现非确定结果。一个用户操作只对应一份冻结的清单,才能把八条分类写进可复核台账。

下图是按这份契约制作的白色主题DevEco Studio风格示意。左边放工程目录,中间是可复核的应用规则,右侧模拟器展示批次结果,底部HiLog是虚构固定数据的展示文本;它不是DevEco运行截图,也不是实际剪贴板读取、编译成功或系统能力验证证据。读图时应把“系统读取 NOT_RUN”与“模型分类已经计算”分开看。

四、附件闭包先于富文本渲染

接下来解决资源引用是否闭合的问题。这里没有尝试执行任意HTML,也不对来源应用给出的 URI 直接授权。上游将实际文本解析成受限结构后,业务判定先检查会话代次,再检查链接指向是否是当前受控资源协议,随后检查所有附件ID是否存在,最后决定是否允许富文本或使用可信的纯文本回退。顺序不是随意的:旧代次记录根本不应该再获得重新打开资源的机会;不安全链接也不应因为有纯文本备用就被悄悄接受成可点击实体。

export function classifyRecord(record, context) {
  if (record.epoch !== context.epoch) return 'EPOCH_STALE';
  if (record.links.some(link => !link.startsWith('asset://'))) {
    return 'UNSAFE_LINK';
  }
  if (record.assets.some(id => !context.assetIds.has(id))) {
    return 'ORPHAN_ASSET';
  }
  if (record.kind === 'plain') return 'ACCEPT_PLAIN';
  if (!record.supportedMarkup && record.fallbackText.length > 0) {
    return 'FALLBACK_TEXT';
  }
  return record.supportedMarkup ? 'ACCEPT_RICH' : 'ORPHAN_ASSET';
}

这是标准JavaScript业务函数,不是系统提供的函数名。这里的 asset:// 只代表应用内部约定的抽象资源引用,并非任意字符串以前缀开头就自动获得文件读取权限。实际工程至少还要校验资源ID映射到的文件作用域、资源归属和有效期,也要对大小、媒体内容、路径规范化做防御性验证。本文模型刻意停在“已解析结构化摘要”的层次,不能作为通用HTML安全解析器或系统权限证明。

判定逻辑中,R01走ACCEPT_PLAIN,R02、R04、R07走ACCEPT_RICH,R03因不支持标记走FALLBACK_TEXT;R05因A09找不到被拒绝,R06因外部链接被拒绝,R08因旧epoch被拒绝。五条能继续编辑并不表示一定提交数据库,它只决定“哪些草稿片段可以交给下一层”。真正的写入必须按接收记录序列生成一个新的草稿事务,否则部分接收场景可能在用户撤销时把已存在的正文也回滚掉。

五、让调试日志能说明失败,而不是只写一个false

UI显示时,应当把可接受片段和被拒绝片段分开。当前页面示意中有R01至R08八行,R03的回退文案要让用户看见“降级成纯文本”,不能把橙色的提示误当成普通错误。R05应该明确说“A09不在受控清单”,R06应该说“外链策略不允许”,R08则说“这属于上一次粘贴操作”。这里的阻断是批次内的局部失败,而不是整个输入流程立即抛异常终止。

为了让演示结果可复算,下面使用Node.js固定输入运行一个小统计器。它不连接剪贴板,读取的八条清单数据由脚本写死;正式产品会改为从可信转换器拿到不可变对象。这样一来截图上的 5/3、3/2、资源A01/A02/A03、PASTE_PARTIAL 都有同一份数学来源,而不是作者在不同截图上随意填数字。

const ctx = { epoch: 4, assetIds: new Set(['A01', 'A02', 'A03']) };
const rows = [
  ['R01','plain',120,[],[],true,4],
  ['R02','rich',512,['A01'],['asset://A01'],true,4],
  ['R03','rich',180,[],[],false,4],
  ['R04','rich',400,['A02'],['asset://A02'],true,4],
  ['R05','rich',320,['A09'],['asset://A09'],true,4],
  ['R06','rich',256,[],['https://bad.example'],true,4],
  ['R07','rich',384,['A03'],['asset://A03'],true,4],
  ['R08','rich',80,[],[],true,3]
].map(([id,kind,byteLength,assets,links,supportedMarkup,epoch]) => ({
  id,kind,byteLength,assets,links,supportedMarkup,epoch,
  fallbackText: 'trusted fallback'
}));
const results = rows.map(row => [row.id, classifyRecord(row, ctx)]);
const accepted = results.filter(r => ['ACCEPT_PLAIN','ACCEPT_RICH','FALLBACK_TEXT'].includes(r[1]));
console.log('PST-1011-30 accepted=' + accepted.length + ' blocked=' + (8-accepted.length));

这段脚本只依赖Node.js原生JavaScript,没有借用尚未核实的HarmonyOS扩展包。classifyRecord与上一段代码放在同一模块或通过显式导入即可在本地执行。fallbackText是已经由可信解析流程得到的文本,不允许将原始HTML当作回退文本直接展示。这个区别会决定安全边界:回退是改变展示能力,而不是取消恶意内容检查。

六、回调乱序是第二条必须守住的线

用户有可能连续按两次粘贴。第一次系统读取已经发起,第二次操作使页面的活动代次由三变为四;如果第一次读取后来才完成,R08就可能携带上一次状态返回。此时无论它是什么文本,也不能覆盖当前编辑器草稿。EPOCH_STALE是应用自己定义的拒绝原因,不能把它描述成系统剪贴板返回的错误码。不同阶段的异常必须被分层记录:系统读入失败、结构适配失败、业务策略拒绝、保存失败,分别对应不同修复路径。

对于附件资源,还要把“验证时存在”与“使用时仍有效”分开。如果R02已通过闭包检查,实际读取A01时可能遇到文件被移动、临时授权过期或内存压力。读入层需要在拿到实际可读资源后做二次确认,并保证已打开的句柄在成功、用户取消和异常时都关闭。不能把资源句柄存到长寿命的页面状态里等待下一次重用。对于来自别的应用的数据,拷贝到本应用沙箱与保留来源URI的授权模型也是不同的工程取舍,不应混着处理。

用户撤销操作时,应该取消尚未提交的导入任务,并提升代次;已经写入的草稿片段则由独立撤销记录处理。不能为了撤销最后一条富文本,直接清空整篇文章。对同步读取的错误也要保留细颗粒度:无权限不会经过我们的 ORPHAN_ASSET 分支,解析失败更不能被包装为“富文本不支持”而悄悄降级。只有策略层确实判为兼容降级,才允许输出FALLBACK_TEXT。

还有编码和大小预算问题。本轮每条记录的byteLength是固定输入,不代表真实剪贴板负载大小;要在生产里做限制,需要核实UTF-8长度、图片字节数、单条和整批预算,防止几条大图将编辑器带入不可恢复的内存峰值。真实粘贴时的权限检查和前台交互入口也不能被这个测试器替代。相反,这个测试器应放进CI,让输入契约变更时能明确提示策略分类发生了什么变化。

七、边界测试比一张成功的截图更重要

本轮最重要的反例是R05与R08。R05并非“文件名不存在那么简单”,它证明了富文本主体里出现的资源ID必须能在已经冻结的资源目录中闭合;否则保存一段能显示标题却缺图片的内容,会给后续搜索索引制造语义不一致。R08说明资源即使存在、类型也受支持,旧代次仍然应该被丢弃。这两种失败的原因完全不同,处理方法不能共用一个“重试”按钮。缺附件可能需要用户补充文件或重复制;旧回调则应直接终止,而不是自动重试上一轮操作。

R06也值得单独强调。外链不是天然的坏东西,但“可浏览”与“可自动嵌入”不是一回事。外部URL可以由产品设计为点击后确认打开;模型当前直接拒绝,因为演示编辑器没有建立外链白名单、来源审计和权限隔离。把外链放进附件字段、仅靠字符串前缀将它改造成内部链接,只会把路径授权问题隐藏起来。安全策略宜宁可收窄能力,也不要给出看似便捷的通用HTML支持承诺。

R03则代表另一种取舍:遇到当前渲染器不支持的标记,与其存一个将来可能不可解释的半结构化片段,不如保留可信纯文本并告知用户格式已经简化。但这个降级必须与用户行为相匹配,不能连图片说明和引用上下文一起丢掉。如果用户的编辑场景必须保留全部格式,就应该让R03进入待确认队列,而不是自动接受。当前FIXTURE_ONLY结果只说明选择了后一种简化策略,不能反推所有产品都应该这样做。

页面里的“3条富文本、2条纯文本”强调的是最终进入下一阶段的内容形态,而非来源类型的原始分布。这样定义的好处,是后续排查内容展示不一致时,能准确知道哪个记录发生了兼容回退。独立审计日志应同时保存原始记录ID、接受形态、阻断原因、批次ID和发生代次,却不应该将敏感粘贴正文原样写入HiLog。实际环境下必须再考虑日志脱敏、用户授权、数据保留期和跨应用来源证明。

要验证这一设计,推荐从本地八条夹具开始:逐条断言R01/R02/R03/R04/R07可接受,R05/R06/R08拒绝;随后验证整批聚合值等于5/3、富文本/纯文本为3/2,最后检查关闭页面后旧读入不会变成新草稿。真实设备阶段需要另外覆盖无数据、权限拒绝、连续复制、来源应用退出、超大图片、URI过期、页面旋转以及剪贴板隐私提示。由于本轮没有这套环境,这些全部是待执行用例,不应出现在“已通过”的列表里。

八、把实现留在可以逐步接入系统的位置

最终形态不是让 PasteEvidenceGate 代替剪贴板,而是把系统数据、资源解析、策略判断和草稿存储拆成四层。系统适配器只回答“得到什么”;解析器回答“可靠地认出什么”;策略层回答“允许哪一种使用方式”;草稿事务才决定“保存哪部分”。失败时每层返回自己的证据,用户才能看到正确的后续操作。当前样例的 PASTE_PARTIAL应在界面中保持警示态,而不是显示成全绿的发布成功。

从开发者角度看,这篇的结果并不在于实现了一个能粘贴任何富文本的编辑器。更有价值的是把系统能力边界讲清楚,并给业务内容的落地补上一道可计算、可重放的准入门槛:8条输入、5条接受、3条拒绝,三类明确阻断原因,一个可以追踪的代次。等到真正接入HarmonyOS剪贴板接口,还需要用DevEco和真机独立补齐权限、URI资源和异步生命周期的证据;在那之前,不应拿模拟图当作系统成功的证明。

官方参考:华为《使用剪贴板进行复制粘贴》与《标准化数据类型》文档。本文代码中的 classifyRecord、资产ID与 FIXTURE_ONLY 均为应用自定义,不属于系统接口。

Logo

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

更多推荐