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 中的 pasteboardcreateData()SystemPasteboard.setData()getData()hasData()clearData() 的 Promise 形式从 API 9 起可用;创建同一记录的多种 MIME 表示从 API 14 起可用。

MIME 类型常量值的用途不应装入什么
纯文本MIMETYPE_TEXT_PLAIN标题、摘要、普通文字JSON 化的完整账号对象
HTMLMIMETYPE_TEXT_HTML富文本片段未清理的脚本或私有属性
URIMIMETYPE_TEXT_URI资源引用、网页地址无授权的沙箱路径
WantMIMETYPE_TEXT_WANT明确的能力意图用户不可见的后台拉起参数
PixelMapMIMETYPE_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 可设置 INAPPLOCALDEVICE。不显式设置范围会让行为依赖默认策略,安全边界难以审查。跨设备相关能力还受系统版本和设备策略影响,不能仅通过旧枚举推断可用性。

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 不包含令牌、内部密钥或未授权沙箱路径
[ ] 一次性敏感内容有会话结束和安全清理策略
[ ] 应用自身写操作已防抖或串行化
[ ] 设备权限只按真实目标形态申请
[ ] 页面文案不夸大接收应用的兼容能力

资料来源:

剪贴板安全不是在复制按钮外再包一层 try/catch,而是从数据最小集、MIME 语义、传播范围、读取校验到失效清理形成完整责任链。每次写入都能解释“为什么复制、谁能读取、何时失效”,系统级共享通道才不会变成无意的数据出口。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐