Electron ClipboardBookmark 深度解析:`electron application/bookmark` 剪贴簿格式的读写与源码实现
本文以 Electron 官方文档中的 ClipboardBookmark 结构定义(docs/api/structures/clipboard-bookmark.md)为核心,讲解这个用于 electron application/bookmark 自定义剪贴格式的结构体:它由 title 与 url 两个字段组成,作为 ClipboardItem 的 MIME 键值写入 clipboard.write(),并在 clipboard.read() 后通过 getType('electron application/bookmark') 解析回 { title, url } 对象。读完本文,你将掌握书签数据在 Electron 剪贴板上的完整读写流程、其在原生层(ui::Clipboard::WriteURL / ReadURL)的底层落地方式、旧版 readBookmark/writeBookmark API 的迁移路径,以及跨平台测试所揭示的实际行为差异。
一、ClipboardBookmark:结构与 MIME 定位
ClipboardBookmark 的定义非常精简,完整定义见 clipboard-bookmark.md:
| 字段 | 类型 | 说明 |
|---|---|---|
title |
string |
书签的标题 |
url |
string |
书签的 URL |
它服务于 Electron 的自定义剪贴格式 electron application/bookmark。按照文档描述,这个结构的“生命周期”由两端构成:
- 写入端:作为
clipboard.write()的参数,以ClipboardItem中data记录的一个 MIME 键值出现——键为'electron application/bookmark',值就是这个{ title, url }对象; - 读取端:通过
clipboard.read()拿到ClipboardItem数组后,getType('electron application/bookmark')解析(resolve)出的结果类型,而不是像其他 MIME 类型那样解析为Blob。
这是整个 Electron 剪贴板 API 中唯一一个解析结果为结构化对象而非 Blob 的 MIME 类型。原因在于:书签天然是“标题 + 链接”的结构化数据,直接暴露 { title, url } 对象比让使用者手动反序列化一段字节载荷更友好。
在 JavaScript 侧的 JS 门面类中,这个 MIME 常量被定义为:
// lib/browser/api/clipboard-item.ts
const BOOKMARK_MIME = 'electron application/bookmark';
并且 getType() 对该 MIME 做了特殊分流——命中时直接返回 ClipboardBookmark 对象,其余 MIME 一律包装成带 MIME 标签的 Blob(见 clipboard-item.ts):
async getType(type: string): Promise<Blob | Electron.ClipboardBookmark> {
if (this.#native) {
const payload = await this.#native.getType(type);
if (type === BOOKMARK_MIME) return payload as Electron.ClipboardBookmark;
return new Blob([payload as Blob], { type });
}
// ...构造侧同样对 BOOKMARK_MIME 直接返回对象
}
测试代码中对应的类型也直接使用了 Electron.ClipboardBookmark(见 api-clipboard-spec.ts),说明该结构在 TypeScript 类型定义中是 clipboard 模块的一等公民。
二、写入书签:作为 ClipboardItem 的 MIME 键值
ClipboardItem 的构造器接受 Record<string, string | ClipboardBookmark | Blob | Promise<Blob | string>>:任意 MIME 都可以接受 string 或 Blob(string 会被 UTF-8 编码为载荷字节),而 electron application/bookmark 是唯一例外——它只接受 ClipboardBookmark 对象。完整的写入示例(源自 clipboard-item.md 的官方示例):
// 每个 ClipboardItem 描述一条带一个或多个 MIME 表示的剪贴板条目。
// bookmark 自定义格式接受结构化的 { title, url } 对象,而不是 Blob。
const { clipboard, ClipboardItem, nativeImage } = require('electron')
const png = nativeImage.createFromPath('/path/to/icon.png').toPNG()
clipboard.write([
new ClipboardItem({
'text/plain': 'hello',
'text/html': '<b>hello</b>',
'image/png': new Blob([png], { type: 'image/png' }),
'electron application/bookmark': {
title: 'Electron',
url: 'https://electronjs.org'
}
})
])
几个值得注意的行为细节:
- 原子提交:同一次
clipboard.write()调用中的全部 MIME 条目(包括书签)作为一个原子事务提交到系统剪贴板,要么全部成功要么全部失败,不会出现“文本已写入但书签丢失”的中间态。 - 异步载荷:非书签载荷也可以是
Promise(resolve 为Blob或string),在clipboard.write()调用时才被 await;书签载荷本身是同步对象,不走 Promise 路径。 - 类型校验发生在原生构造器:JS 门面类在 clipboard-item.ts 的
[kToNative]()中把Blob解析为Buffer后交给原生NativeClipboardItem构造器,真正的“载荷与 MIME 是否匹配”校验由 C++ 侧完成,不匹配的载荷会让clipboard.write()的 Promise 以TypeErrorreject(对应测试见 api-clipboard-spec.ts 的“rejects an invalid payload at write() time”用例)。
三、读取书签:getType 返回对象而非 Blob
读取侧的标准流程(示例来自 clipboard-item.md 的 clipboardItem.getType(bookmark) 一节):
const { clipboard } = require('electron')
async function dumpClipboard () {
const bookmarkType = 'electron application/bookmark'
const items = await clipboard.read()
for (const item of items) {
if (item.types.includes(bookmarkType)) {
const bookmark = await item.getType(bookmarkType)
console.log('Bookmark found: ', bookmark)
} else {
console.log('There is no bookmark present')
}
}
}
行为要点:
clipboard.read()返回Promise<ClipboardItem[]>,每个条目的types数组列出当前平台剪贴板可提供的 MIME 类型;书签是否存在于剪贴板,应先通过item.types.includes('electron application/bookmark')判断。- 当书签不存在时,
getType(bookmarkType)会 reject(错误信息为The type '<mime>' was not found in the ClipboardItem),而非 resolve 空对象。 - 与其他 MIME 的关键区别在于返回类型:
getType('electron application/bookmark')返回Promise<ClipboardBookmark>,而getType('text/html')之类返回Promise<Blob>。这一重载约定在 clipboard-item.md 的clipboardItem.getType(type)与clipboardItem.getType(bookmark)两个小节中均有声明。
四、源码实现:从 JS 对象到平台剪贴簿 URL 格式
书签功能在原生层的实现集中在 electron_api_clipboard_item.cc 与 electron_api_clipboard_item.h,可以沿着“写入 → 提交”和“读取 → 解析”两条链路观察。
4.1 数据结构:BookmarkInfo
C++ 侧用如下结构承接 JS 的 { title, url } 对象(electron_api_clipboard_item.h):
// Structured payload for the `electron application/bookmark` MIME — a
// pre-parsed `{ title, url }` object the W3C clipboard write path
// commits via `ScopedClipboardWriter::WriteURL`.
struct BookmarkInfo {
std::u16string title;
std::string url;
};
它是 ClipboardItemPayload 这个 std::variant 的三个分支之一——原始字节(std::vector<uint8_t>)、UTF-16 文本(std::u16string)、以及书签(BookmarkInfo)。也就是说,书签在载荷层面就与“文本”“二进制”分道扬镳,拥有独立的类型路径。
4.2 写入链路:构造期校验 + WriteURL 提交
原生构造器遍历用户传入的 { [mime]: payload } 对象时,对书签 MIME 做了专门分支(electron_api_clipboard_item.cc):
if (mime == electron::api::clipboard_util::kBookmarkMime) {
// Bookmark MIME takes a structured `{ title, url }` object.
if (!value->IsObject()) {
isolate->ThrowException(v8::Exception::TypeError(gin::StringToV8(
isolate,
"ClipboardItem payload for `electron application/bookmark` "
"must be an object with `title` and `url` properties")));
return;
}
gin_helper::Dictionary dict{isolate, value.As<v8::Object>()};
BookmarkInfo info;
dict.Get("title", &info.title);
dict.Get("url", &info.url);
payloads_.emplace(mime, std::move(info));
types_.push_back(std::move(mime));
continue;
}
这里有两点从源码结构看值得注意:其一,载荷不是对象时会同步抛出 TypeError,错误信息明确提示“必须是含 title 和 url 属性的对象”;其二,title/url 通过 gin_helper::Dictionary::Get 提取,属于宽松读取——若字段缺失会得到空串而非报错。
真正提交到系统剪贴板发生在 WriteTo(electron_api_clipboard_item.cc),书签走 Chromium 的 URL 剪贴格式写入:
if (std::holds_alternative<BookmarkInfo>(payload)) {
const auto& bm = std::get<BookmarkInfo>(payload);
writer.WriteURL(
ui::ClipboardUrlInfo{.url = GURL(bm.url), .title = bm.title});
continue;
}
即 ClipboardBookmark 最终落到 Chromium ui::ScopedClipboardWriter::WriteURL 所对应的平台“URL 剪贴格式”(Windows 的 CF_URL、macOS 的 public.url 等)。这意味着 Electron 写入的书签与其他应用(浏览器“复制链接”等)写入的 URL 在平台层面是同一格式,具备跨应用互通性。
4.3 读取链路:ReadURL → ResolveAsBookmark
读取侧的 MIME 分发函数 ReadMime 对书签有独立分支(electron_api_clipboard_item.cc):
if (mime == cu::kBookmarkMime) {
// Unlike every other MIME type, `getType('electron application/bookmark')`
// resolves to a plain `{ title, url }` object rather than a Buffer — a
// bookmark is inherently structured data, so this is far easier to use
// than a serialized byte payload.
ui::Clipboard::GetForCurrentThread()->ReadURL(
/* data_dst = */ std::nullopt,
base::BindOnce(
[](gin_helper::Promise<v8::Local<v8::Value>> promise,
ui::ClipboardUrlInfo url_info) {
ResolveAsBookmark(std::move(promise),
electron::api::BookmarkInfo{
url_info.title, url_info.url.spec()});
},
std::move(promise)));
return handle;
}
它调用 ui::Clipboard::ReadURL 异步读取平台剪贴板中的 URL 信息,然后由 ResolveAsBookmark 组装出 JS 对象(electron_api_clipboard_item.cc):
void ResolveAsBookmark(gin_helper::Promise<v8::Local<v8::Value>> promise,
const electron::api::BookmarkInfo& info) {
...
auto dict = gin_helper::Dictionary::CreateEmpty(isolate);
dict.Set("title", info.title);
dict.Set("url", info.url);
promise.Resolve(dict.GetHandle());
}
源码注释特别说明了 url.spec() 的使用:ReadURL 回调给出的是 GURL 对象,取其 spec() 字符串作为 url 字段。这也解释了测试中断言的是 'https://electronjs.org/'(带尾斜杠,GURL 规范化后的完整形式)。
从源码结构看,这条链路是惰性读取的:clipboard.read() 时刻只枚举 MIME 类型,并不真正读取载荷;只有调用 getType() 时才发起对平台剪贴板的 ReadURL 请求(读侧 ClipboardItem 只保存 ui::ClipboardBuffer 与类型列表,见 electron_api_clipboard_item.h 的类注释)。
五、API 演进:从 readBookmark/writeBookmark 到 MIME 化
在 clipboard 模块按 W3C Clipboard API 重构之前,书签读写有专用的窄化 API,现在已被移除并统一到 MIME 模型中。breaking-changes.md 给出了新旧 API 的对照(节选与书签相关的部分):
| 旧 API | 新 API |
|---|---|
clipboard.readBookmark() |
clipboard.read() + electron application/bookmark 自定义格式 |
clipboard.writeBookmark(title, url[, type]) |
clipboard.write() + electron application/bookmark 自定义格式 |
文档同时说明 clipboard.read() 的返回形态变化:返回 Promise<ClipboardItem[]>,每个条目提供 types 数组与 getType(type) → Promise<Blob>,而 getType('electron application/bookmark') 是唯一的例外——它解析为 { title, url } 对象。
官方迁移示例(breaking-changes.md)展示了如何用新 API 复刻旧 readBookmark/writeBookmark 的行为:
const BOOKMARK_MIME_TYPE = 'electron application/bookmark'
async function readBookmark (clipboardType) {
const clipboardToUse = getClipboardToUse(clipboardType)
const clipboardItems = await clipboardToUse.read()
for (const clipboardItem of clipboardItems) {
if (clipboardItem.types.includes(BOOKMARK_MIME_TYPE)) {
// getType('electron application/bookmark') resolves to a
// { title, url } object rather than a Blob.
return clipboardItem.getType(BOOKMARK_MIME_TYPE)
}
}
}
async function writeBookmark (title, url, clipboardType) {
const clipboardToUse = getClipboardToUse(clipboardType)
await clipboardToUse.write([
new ClipboardItem({
[BOOKMARK_MIME_TYPE]: { title, url }
})
])
}
这段“手写封装”实际上是书签读写的推荐通用模式:先遍历条目检查 types,命中再 getType。
六、测试视角:平台差异与边界行为
Electron 的官方测试套件 spec/api-clipboard-spec.ts 对书签行为做了系统验证,几个关键用例揭示了实际的平台边界:
1. 基本往返(非 Linux 平台)
ifdescribe(process.platform !== 'linux')('reading bookmarks via clipboard.read()', () => {
it('returns title and url via the electron application/bookmark MIME type', async () => {
await clipboard.write([
new ClipboardItem({
[BOOKMARK_MIME]: {
title: 'a title',
url: 'https://electronjs.org/'
}
})
]);
const bookmark = await readBookmark();
expect(bookmark).to.be.an('object');
if (process.platform !== 'win32') {
expect(bookmark!.title).to.equal('a title');
}
expect(bookmark!.url).to.equal('https://electronjs.org/');
...
});
});
从测试结构可以读出三条平台事实:
- Linux 上不启用该组测试(
process.platform !== 'linux'),即 Linux 下书签 MIME 的可用性未被官方测试覆盖,使用时应自行验证; - Windows 上不校验
title——仅断言url。可以推断 Windows 平台的 URL 剪贴格式在“标题”的存取上与其他平台存在差异,写应用时不应依赖 Windows 下title的精确回读; - 清空/覆盖为纯文本后,若平台仍报告该格式存在,则读回的是空结构
{ title: '', url: '' }(见 api-clipboard-spec.ts)。
2. 混合 MIME 写入中的书签
在 clipboard.write() 的“mixed data”用例(api-clipboard-spec.ts)中,同一条 ClipboardItem 同时携带 text/plain、text/html、text/rtf、image/png 与书签:
const bookmark = { title: 'a title', url: text };
await clipboard.write([
new ClipboardItem({
'text/plain': text,
'text/html': '<b>Hi</b>',
'text/rtf': rtf,
'image/png': new Blob([i.toPNG()]),
[BOOKMARK_MIME]: bookmark
})
]);
...
const readBookmarkValue = await readBookmark();
if (process.platform !== 'win32') {
expect(readBookmarkValue).to.deep.equal(bookmark);
} else {
expect(readBookmarkValue!.url).to.equal(bookmark.url);
}
该用例验证了“一条剪贴板条目同时携带文本、HTML、RTF、PNG 与书签”的原子写入,且读回的 ClipboardBookmark 在 macOS 上与写入对象深度相等。测试文件顶部的 readBookmark 辅助函数(api-clipboard-spec.ts)则示范了生产代码中最稳妥的读取写法:
async function readBookmark(): Promise<Electron.ClipboardBookmark | undefined> {
const items = await clipboard.read();
for (const item of items) {
if (item.types.includes(BOOKMARK_MIME)) {
return (await item.getType(BOOKMARK_MIME)) as Electron.ClipboardBookmark;
}
}
return undefined;
}
3. 与 MIME 探测的配合
clipboard.has(mimetype) 返回 Promise<boolean>,对自定义 MIME 同样适用。判断书签是否可用的完整模式可以组合为:先 await clipboard.has('electron application/bookmark') 做廉价探测,命中后再 clipboard.read() + getType 取结构化数据。
七、使用建议与安全边界
结合 clipboard-item.md 中的安全警告与上述源码实现,使用 ClipboardBookmark 时建议遵循以下实践:
- 只在主进程使用:
clipboard与ClipboardItem是 Main 进程 API(见 clipboard-item.md 的 Process 标注)。渲染进程应使用navigator.clipboard;确需高级能力时通过 preload 脚本与contextBridge暴露受控辅助函数,且避免向不可信内容暴露完整clipboardAPI。 - 不要直接消费不可信数据构造 MIME 键值:文档明确指出 MIME 键是“能力面”——例如
text/uri-list会在系统剪贴板放置真实文件引用。若载荷来自 IPC 渲染侧或网络,必须先校验并白名单化 MIME 类型与载荷形状,再构造ClipboardItem。对书签而言,至少应校验title/url确为字符串且url为合法地址。 - 防御式读取:先用
types判断 MIME 存在再getType;对读回对象做空值容忍(平台可能给出{ title: '', url: '' });跨平台时注意 Linux 覆盖与 Windowstitle回读的差异。 - 理解惰性读取:
clipboard.read()不立即读取任何字节载荷,getType()才触发平台读取(对图片 MIME 还要求app ready,见 electron_api_clipboard_item.cc 中image/*的就绪检查)。书签读取同样异步,UI 上应基于 Promise 处理。
小结
ClipboardBookmark 虽然只有 title 与 url 两个字段,但它背后串联了 Electron 剪贴板 W3C 化重构后的完整链路:JS 门面类(lib/browser/api/clipboard-item.ts)负责 Blob/Promise 的异步解析与 MIME 分流,原生 ClipboardItem(shell/browser/api/electron_api_clipboard_item.cc)负责构造期类型校验,并最终通过 ScopedClipboardWriter::WriteURL / ui::Clipboard::ReadURL 与操作系统的 URL 剪贴格式交互。它也是旧 readBookmark/writeBookmark API 在新架构下的唯一归宿。掌握这套“MIME 键值 + 结构化解析”的模式,即可同时驾驭书签与其他自定义格式(如 web 前缀自定义 MIME、electron application/osclipboard)的读写。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00