HarmonyOS Pasteboard 安全复制:MIME 分类、敏感内容与跨应用边界
HarmonyOS Pasteboard 安全复制:MIME 分类、敏感内容与跨应用边界

复制功能看起来只有一次 setData(),实际却把内容交给了系统级共享通道。若应用把登录令牌、完整订单对象或内部文件路径当普通文本写入,其他应用的粘贴场景可能接触到这些内容;若所有类型都用 text/plain,接收方又无法判断应该显示文字、打开 URI,还是解析结构化数据。功能能用,并不等于边界正确。
本文以“笔记应用复制标题、富文本和分享地址”为例,围绕 PasteData、MIME 类型、ShareOption、读取前校验和主动清理,建立一条可审计的剪贴板读写链路。重点不是封装一个工具函数,而是让每一种复制行为都有明确的数据最小集、作用范围和失效时机。
1. 先把复制操作当成一次数据发布
写入系统剪贴板后,原页面不再独占这份数据。设计复制功能时应先回答:用户明确选择了什么、接收方需要哪种格式、是否允许跨应用、内容应该保留多久。
export interface CopyIntent {
scene: 'note_title' | 'note_rich_text' | 'share_link';
displayText: string;
htmlText?: string;
uri?: string;
scope: 'in_app' | 'local_device';
sensitive: boolean;
}
export type PasteResult =
| { kind: 'text'; value: string }
| { kind: 'uri'; value: string }
| { kind: 'unsupported' };
CopyIntent 不接受任意业务对象,从入口就阻止调用方把整份用户模型传给剪贴板层。sensitive 不是装饰字段,它决定是否允许写入、是否仅应用内使用以及是否安排清理。
2. API 基线与 MIME 语义
本文以 Stage 模型、ArkTS、HarmonyOS SDK API 23 为基线,使用 @kit.BasicServicesKit 中的 pasteboard。createData()、SystemPasteboard.setData()、getData()、hasData() 与 clearData() 的 Promise 形式从 API 9 起可用;创建同一记录的多种 MIME 表示从 API 14 起可用。
| MIME 类型 | 常量 | 值的用途 | 不应装入什么 |
|---|---|---|---|
| 纯文本 | MIMETYPE_TEXT_PLAIN |
标题、摘要、普通文字 | JSON 化的完整账号对象 |
| HTML | MIMETYPE_TEXT_HTML |
富文本片段 | 未清理的脚本或私有属性 |
| URI | MIMETYPE_TEXT_URI |
资源引用、网页地址 | 无授权的沙箱路径 |
| Want | MIMETYPE_TEXT_WANT |
明确的能力意图 | 用户不可见的后台拉起参数 |
| PixelMap | MIMETYPE_PIXELMAP |
图像像素数据 | 可用 URI 表达的大文件副本 |
接收方应根据 MIME 解析,不能先调用 getPrimaryText(),失败后再猜是不是 URI。自定义 MIME 字符串也有长度约束,跨应用前还需约定格式版本。
3. 纯文本写入只保留用户看得见的字段
最常见的错误是为了“粘贴后还能恢复上下文”,把内部 ID、调试信息和鉴权参数一起拼进文本。复制标题时只写标题;业务关联信息留在应用内部。
import { pasteboard } from '@kit.BasicServicesKit';
export async function copyNoteTitle(title: string): Promise<void> {
const normalized = title.trim().slice(0, 500);
if (normalized.length === 0) throw new Error('empty note title');
const data = pasteboard.createData(
pasteboard.MIMETYPE_TEXT_PLAIN,
normalized
);
const property = data.getProperty();
property.shareOption = pasteboard.ShareOption.LOCALDEVICE;
data.setProperty(property);
await pasteboard.getSystemPasteboard().setData(data);
}
长度限制是项目策略,用于避免一次复制带出超长页面内容。LOCALDEVICE 允许本设备其他应用接收;只供本应用组件粘贴时应改为 INAPP。
4. 从构造到清理形成完整读写闭环

读写顺序应固定为:按场景构造最小数据、标注 MIME 与分享范围、在用户明确操作后写入、读取时先校验类型、敏感场景完成后主动清理。剪贴板不是应用数据库,不能承担长期保存职责。
import { pasteboard } from '@kit.BasicServicesKit';
export async function clearCopiedSecret(): Promise<void> {
const board = pasteboard.getSystemPasteboard();
if (await board.hasData()) {
await board.clearData();
}
}
主动清理要与业务状态绑定,例如一次性恢复码被粘贴成功或页面超时后清理。不要在任意页面进入时清空系统剪贴板,这会破坏用户从其他应用复制的内容。
5. ShareOption 决定数据能传播到哪里
PasteDataProperty.shareOption 可设置 INAPP 或 LOCALDEVICE。不显式设置范围会让行为依赖默认策略,安全边界难以审查。跨设备相关能力还受系统版本和设备策略影响,不能仅通过旧枚举推断可用性。
function limitScope(
data: pasteboard.PasteData,
scope: CopyIntent['scope']
): pasteboard.PasteData {
const property = data.getProperty();
property.shareOption = scope === 'in_app'
? pasteboard.ShareOption.INAPP
: pasteboard.ShareOption.LOCALDEVICE;
data.setProperty(property);
return data;
}
登录临时码、草稿恢复片段等即使允许复制,也更适合 INAPP。分享链接本来就是给其他应用使用的,才选择 LOCALDEVICE。
6. 读取前先确认是否有数据和目标类型
粘贴按钮点击后,先判断系统剪贴板是否有内容,再取得 PasteData,最后用 hasType() 验证。这样可以把空数据、类型不支持和读取异常分成不同页面状态。
import { pasteboard } from '@kit.BasicServicesKit';
export async function readPreferredContent(): Promise<PasteResult> {
const board = pasteboard.getSystemPasteboard();
if (!await board.hasData()) return { kind: 'unsupported' };
const data = await board.getData();
if (data.hasType(pasteboard.MIMETYPE_TEXT_URI)) {
return { kind: 'uri', value: data.getPrimaryUri() };
}
if (data.hasType(pasteboard.MIMETYPE_TEXT_PLAIN)) {
return { kind: 'text', value: data.getPrimaryText() };
}
return { kind: 'unsupported' };
}
读取顺序体现业务优先级。分享场景优先 URI,编辑器场景可能优先 HTML。不要把未知二进制记录强制转换成字符串。
7. 一份内容可以提供多种表示
API 14 起,createData(Record<string, ValueType>) 可以为同一份剪贴板数据提供多种 MIME 表示。富文本编辑器可以同时提供 HTML 和纯文本,使不支持 HTML 的接收方仍能降级粘贴。
import { pasteboard } from '@kit.BasicServicesKit';
export async function copyRichNote(plain: string, html: string): Promise<void> {
const data = pasteboard.createData({
[pasteboard.MIMETYPE_TEXT_PLAIN]: plain,
[pasteboard.MIMETYPE_TEXT_HTML]: html
});
limitScope(data, 'local_device');
await pasteboard.getSystemPasteboard().setData(data);
}
两种表示必须表达同一内容,不能在 HTML 中夹带纯文本版本没有的隐藏业务数据。HTML 还应在进入剪贴板前经过项目自己的安全净化流程。
8. URI 是引用,不等于接收方天然有权限
把文件路径写成 text/uri 只传递了定位信息,不会自动授予对应用沙箱文件的读取权。跨应用共享文件时应使用平台支持的文件分享机制和正确 URI,并确保接收方拥有必要访问能力。
function validateShareUri(uri: string): string {
const value = uri.trim();
const allowed = value.startsWith('https://') || value.startsWith('file://');
if (!allowed) throw new Error('unsupported share uri');
if (value.includes('token=') || value.includes('access_key=')) {
throw new Error('credential must not be copied');
}
return value;
}
示例只做最外层防线,生产项目还应解析 URI 结构,避免靠字符串包含判断安全性。带签名的临时下载地址也可能泄露访问能力,不应直接进入系统剪贴板。
9. 敏感内容需要显式用户动作和短生命周期
密码、令牌和身份凭据不应提供复制功能。业务确实需要一次性恢复码时,必须由用户点击触发,缩短内容、限制作用范围,并在完成后清理。
export class SensitiveCopySession {
private active: boolean = false;
async copyOnce(code: string): Promise<void> {
if (!/^\d{6}$/.test(code)) throw new Error('invalid one-time code');
const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, code);
limitScope(data, 'in_app');
await pasteboard.getSystemPasteboard().setData(data);
this.active = true;
}
async finish(): Promise<void> {
if (!this.active) return;
await clearCopiedSecret();
this.active = false;
}
}
清理时还要防止误删用户后来复制的新内容。更严谨的实现应保存本次复制的短期指纹,清理前重新读取并确认仍是本会话写入的数据。
10. 责任边界阻止页面直接操作系统剪贴板

业务层选择最小字段,编码层构造 PasteData,系统剪贴板承载共享状态,接收应用按用户粘贴动作读取。页面不应把任意对象直接传给 setData(),也不应在后台轮询其他应用复制了什么。
export interface ClipboardGateway {
write(intent: CopyIntent): Promise<void>;
readForEditor(): Promise<PasteResult>;
clearOwnedSensitiveData(): Promise<void>;
}
export class SystemClipboardGateway implements ClipboardGateway {
async write(intent: CopyIntent): Promise<void> {
if (intent.sensitive && intent.scope !== 'in_app') {
throw new Error('sensitive content cannot leave app');
}
const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, intent.displayText);
limitScope(data, intent.scope);
await pasteboard.getSystemPasteboard().setData(data);
}
readForEditor(): Promise<PasteResult> { return readPreferredContent(); }
clearOwnedSensitiveData(): Promise<void> { return clearCopiedSecret(); }
}
网关集中记录场景、范围和失败原因,也便于在不同设备形态下替换权限与交互策略。
11. 并发复制需要串行化而不是无限重试
系统可能返回“另一个复制或粘贴操作正在进行”的错误。连续点击复制按钮时,应用侧应防抖或串行执行,不能无间隔递归重试。
class ClipboardWriteQueue {
private tail: Promise<void> = Promise.resolve();
enqueue(action: () => Promise<void>): Promise<void> {
const current = this.tail.then(action, action);
this.tail = current.catch(() => undefined);
return current;
}
}
队列保证应用自身的写入顺序,但无法控制其他应用。系统忙时给用户明确反馈,稍后由用户重新操作,比后台不断覆盖剪贴板更符合预期。
12. 不同设备形态的读取规则需要单独确认
手机、平板、PC/2in1 与原子化服务的剪贴板权限和交互策略并不完全相同。例如鸿蒙电脑应用读取剪贴板可能涉及 ohos.permission.READ_PASTEBOARD。发布前应以目标设备当前官方文档、系统能力和审核要求为准。
{
"requestPermissions": [
{
"name": "ohos.permission.READ_PASTEBOARD",
"reason": "$string:read_pasteboard_reason"
}
]
}
不要为了兼容所有设备无条件声明权限。只有目标设备和真实功能需要时才添加,并给出用户能理解的用途说明。
13. 页面反馈要区分复制成功和内容可用
setData() 成功表示数据已写入系统服务,不表示任意目标应用都能理解该 MIME、访问 URI 或保留富文本样式。页面提示可以写“已复制”,不要承诺“所有应用都能打开”。
写入成功 -> 提示“已复制到剪贴板”
类型不支持 -> 提示“当前内容无法粘贴到此编辑器”
URI 无权限 -> 引导用户通过系统分享入口发送文件
系统正忙 -> 保留按钮,让用户稍后重试
敏感内容过期 -> 清理本应用写入的数据并关闭会话
这组状态能让用户知道下一步,而不是把所有异常都折叠成“复制失败”。
14. 常见问题按传播边界排查
| 现象 | 优先核对 | 处理方式 |
|---|---|---|
| 其他应用读不到 | ShareOption 是否为 INAPP |
公开内容改为 LOCALDEVICE |
| 富文本只剩纯文字 | 接收方是否支持 HTML MIME | 同时提供纯文本降级 |
| 文件 URI 无法打开 | 是否只有路径而没有访问能力 | 使用规范文件分享链路 |
| 复制后敏感信息长期存在 | 是否定义失效时机 | 会话完成后核对并清理 |
| 连续点击偶发报错 | 应用写入是否并发 | 加入防抖或串行队列 |
| PC 读取失败 | 设备权限是否配置 | 按目标设备文档申请最小权限 |
先查类型和分享范围,再查权限与接收方能力。不要因为一次粘贴失败就把数据降级为无类型的大字符串。
15. 发布前清单与资料索引
[ ] 每个复制入口都由用户明确触发
[ ] 写入内容只包含接收方真正需要的字段
[ ] MIME 与值类型一致,富文本提供纯文本降级
[ ] 每份 PasteData 显式设置 INAPP 或 LOCALDEVICE
[ ] 读取前调用 hasData 与 hasType
[ ] URI 不包含令牌、内部密钥或未授权沙箱路径
[ ] 一次性敏感内容有会话结束和安全清理策略
[ ] 应用自身写操作已防抖或串行化
[ ] 设备权限只按真实目标形态申请
[ ] 页面文案不夸大接收应用的兼容能力
资料来源:
- 华为开发者文档:剪贴板术语表
- 华为开发者文档:跨设备剪贴板概述
- 华为开发者文档:鸿蒙电脑应用开发入门
- 本机
D:/harmonyos/SDK/23/ets/api/@ohos.pasteboard.d.ts,用于核对 API 23 的 MIME、PasteData、ShareOption与SystemPasteboard声明。
剪贴板安全不是在复制按钮外再包一层 try/catch,而是从数据最小集、MIME 语义、传播范围、读取校验到失效清理形成完整责任链。每次写入都能解释“为什么复制、谁能读取、何时失效”,系统级共享通道才不会变成无意的数据出口。
更多推荐




所有评论(0)