让浏览器插件调用本地能力:Native Host 的边界和协议
在浏览器扩展开发里,有些问题纯靠扩展代码解决不了。比如右键复制 GIF 时要写系统级剪贴板,从网页里调用本地 CLI,或者检查本机某个二进制是否安装,这些动作都已经越过了浏览器扩展的沙箱边界。
这种场景可以通过 Chromium 系浏览器提供的 Native Messaging 通信能力处理:扩展声明 nativeMessaging 权限后,浏览器按本机 manifest 找到一个 native messaging host,并通过标准输入输出交换带长度前缀的 JSON 消息。基于这条通道,我们把本机端做成一个通用 native messaging host,让不同浏览器扩展调用同一个程序,把剪贴板、诊断和后续本地 CLI 调用收敛到同一套协议、权限和错误处理里。
文中的 native.ping demo 用 Node.js 实现,可以实际运行。结合项目排查安装、剪贴板和跨浏览器兼容时的体验,当前项目也把 Node.js 定为 Native Host 的主实现;编译型路线保留为后续单文件分发、签名安装器和更强系统 API 封装时的备选。
浏览器提供的 Native Messaging 通道
Native Messaging 先解决扩展和本机程序之间的基础通信。Chrome 的 Native Messaging 文档和 Microsoft Edge 的 Native Messaging 文档描述的是同一套模型:扩展声明 nativeMessaging 权限,本机提供 host manifest,浏览器按 manifest 找到并启动 host,再通过 stdin / stdout 交换带长度前缀的 JSON 消息。
浏览器负责启动和通信,不负责安装、升级、授权和能力抽象。Native Messaging 打通了入口,但不会自动告诉 host 哪条请求安全、哪个本地命令能执行、文件应该缓存多久、失败时扩展 UI 该提示什么。

用 native.ping 跑通 demo
这个 demo 做的事很小:点击扩展 popup 里的按钮,扩展页面向 fun.shengsheng.native_host_demo 发送 native.ping,浏览器按本机 host manifest 启动 Native Host,Native Host 从 stdin 读取请求,再从 stdout 写回 pong。它验证的是扩展权限、host manifest 注册、浏览器启动本机程序、Native Messaging 帧格式和一问一答响应结构。
插件侧有两个动作:声明 nativeMessaging 权限,发起 sendNativeMessage() 调用。为了让 demo 能直观看到结果,action.default_popup 再挂一个 popup 页面。popup 属于扩展页面,可以直接调用 chrome.runtime.sendNativeMessage();如果请求来自 content script,则要先转给 service worker 或扩展页面。
{
"manifest_version": 3,
"name": "Native Host Demo",
"version": "0.1.0",
// 允许扩展页面调用 chrome.runtime.sendNativeMessage() / connectNative()。
"permissions": ["nativeMessaging"],
// 工具栏按钮打开 popup,便于手动发起 native.ping 并查看响应。
"action": {
"default_title": "Native Host Demo",
"default_popup": "popup.html"
}
}真实的
manifest.json只支持 JSON,不能保留注释。这里用json5代码块,是为了把字段解释贴在字段旁边;复制到真实项目时要删除注释,或者直接使用后面源码包里的 JSON 文件。
popup 里发请求的逻辑也很直接:
const hostName = 'fun.shengsheng.native_host_demo';
document.querySelector('#ping').addEventListener('click', () => {
chrome.runtime.sendNativeMessage(
hostName,
{
id: `ping-${Date.now()}`,
method: 'native.ping',
params: {
from: 'popup'
}
},
(response) => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError.message);
return;
}
console.log(response);
},
);
});Native Host 侧对应两件事:提供 host manifest,让浏览器知道 fun.shengsheng.native_host_demo 对应哪个本机入口;入口程序按 Native Messaging 协议读写消息。
host manifest 是浏览器寻找本机程序的索引文件,用来声明 Native Host 的名称、入口路径、通信方式和允许调用它的扩展来源。Windows 上可以把这个 JSON 放在应用自己的安装目录里,再让注册表指向它;macOS / Linux 则要把它放进浏览器约定的 NativeMessagingHosts/ 目录。README 里可以保留一段静态样例帮助读者理解字段,真正安装时再写入本机实际路径和允许调用的扩展来源。
{
// 扩展调用 runtime.sendNativeMessage() / connectNative() 时传入的 host 名称。
// 名称只能使用小写字母、数字、下划线和点。
"name": "fun.shengsheng.native_host_demo",
"description": "Native Host Demo",
// 浏览器真正要启动的本机入口。Windows 可以指向 .cmd 或 exe wrapper,
// macOS / Linux 通常指向有可执行权限的 shell wrapper 或二进制。
"path": "C:\\Users\\me\\AppData\\Local\\ShengNativeHostDemo\\native-host.cmd",
// Native Messaging 目前就是通过 stdin / stdout 通信。
"type": "stdio",
// 允许调用这个 host 的扩展来源,格式必须带 chrome-extension:// 和末尾斜杠。
"allowed_origins": ["chrome-extension://dhomoolooollmopglnjkeciabhibjjfp/"]
}真实的 host manifest 同样只支持 JSON,不能保留注释。开发阶段可以把带注释的版本当说明文档,安装脚本实际写入的是去掉注释后的 JSON。
下面这个 native-host.mjs 不依赖 npm 包,只处理 native.ping。正文里只贴关键结构,完整文件放在后面的 Code Lab 里。
先看文件头、运行时信息和响应组装。Native Messaging 只规定浏览器和 host 怎样交换字节流,id、ok、result、error 这些字段是 demo 自己定义的业务协议:
#!/usr/bin/env node
import os from 'node:os';
const host = {
name: 'fun.shengsheng.native_host_demo',
version: '0.1.0'
};
const isLittleEndian = os.endianness() === 'LE';
// Chrome / Edge 会把扩展 origin 作为参数传给 host。
// Windows 还可能追加 --parent-window,所以这里找第一个 chrome-extension:// 参数。
const origin = process.argv.find((arg) => arg.startsWith('chrome-extension://')) ?? null;
function createPingResponse(request) {
return {
id: request.id ?? null,
ok: true,
host,
result: {
message: 'pong',
origin,
platform: {
os: process.platform,
arch: process.arch,
node: process.version
},
time: new Date().toISOString()
}
};
}
function createErrorResponse(request, code, message) {
return {
id: request?.id ?? null,
ok: false,
host,
error: {
code,
message
}
};
}
function handleRequest(body) {
try {
const request = JSON.parse(body.toString('utf8'));
if (request.method === 'native.ping') {
return createPingResponse(request);
}
return createErrorResponse(
request,
'unsupported_method',
`Unsupported method: ${request.method ?? '<empty>'}`,
);
} catch (error) {
return createErrorResponse(
null,
'invalid_json',
error instanceof Error ? error.message : String(error),
);
}
}协议读写接在后面。每条消息前 4 字节是正文长度,后面才是 UTF-8 JSON。调试日志不要写到 stdout,否则浏览器会把日志当成协议内容。
长度按 UTF-8 字节数计算,前缀使用本机字节序,不能用 JS 字符串的 length 代替。正文 demo 只展示最小读写流程;正式协议层还需限制消息与累积缓冲大小、处理非法 JSON 和半帧 EOF,并检查浏览器各方向的消息上限。大文件应走受限分片或文件缓存,不要直接塞进一条 JSON。
// data 事件可能只给半条消息,也可能一次给多条消息。
// input 用来累积还没有处理完的二进制数据。
let input = Buffer.alloc(0);
function writeResponse(response) {
const output = Buffer.from(JSON.stringify(response), 'utf8');
const header = Buffer.alloc(4);
if (isLittleEndian) {
header.writeUInt32LE(output.length, 0);
} else {
header.writeUInt32BE(output.length, 0);
}
process.stdout.write(header);
process.stdout.write(output);
}
process.stdin.on('data', (chunk) => {
input = Buffer.concat([input, chunk]);
while (input.length >= 4) {
const bodyLength = isLittleEndian ? input.readUInt32LE(0) : input.readUInt32BE(0);
// 正文还没有收完整时先等下一次 data。
if (input.length < 4 + bodyLength) {
return;
}
const body = input.subarray(4, 4 + bodyLength);
input = input.subarray(4 + bodyLength);
writeResponse(handleRequest(body));
}
});
process.stdin.on('end', () => {
process.exit(0);
});Windows 和 macOS 都还需要一个很薄的入口包装。浏览器启动的是 host manifest 里的 path,不能在 path 里直接写 node native-host.mjs 这种带参数的命令;所以 demo 里给 Windows 准备了 .cmd 和 ps1,给 macOS 准备了 .sh。
Windows 的 launcher 是独立兼容层。真实项目排查时,manifest 里的 path 一度指向 .exe wrapper,注册表和 host manifest 看起来都存在,但 Chrome / Edge 仍然报 Specified native messaging host not found.。后来把开发态默认入口改成 .cmd,再由 .cmd 启动 Node.js 脚本,浏览器侧才稳定连上。因此,解释型 Native Host 在 Windows 上要把 launcher 当成可替换层:开发阶段优先选可读、可改、容易打日志的入口,正式分发阶段再评估签名 .exe shim、安装器或单二进制。
源码包里把这两类文件分开了:native-host/src/ 放 Native Host 运行时代码和浏览器实际启动的 wrapper;native-host/install/ 放安装器和平台注册逻辑。读源码时,可以先看 src/native-host.mjs 理解协议,再看 install/index.mts 和 install/utils/ 理解它如何落到 Windows 注册表和 macOS manifest 目录。
完整源码我放成了一个可下载 demo 包:
native-host-demo/
README.md
extension/
manifest.json
popup.html
popup.css
popup.js
native-host/
src/
native-host.mjs
native-host.cmd
native-host.ps1
native-host.sh
install/
index.mts
install-windows.ps1
install-macos.sh
utils/
args.mts
constants.mts
manifest.mts
shared.mts
windows.mts
unix.mts源码可以直接在下面浏览。这个 Code Lab 是只读源码工作区,不会在页面里运行 Native Messaging;真正验证还需要按 README 加载扩展并安装本机 host。
真正运行前,需要先把 extension/ 加载进浏览器,再执行安装脚本。demo 的 extension/manifest.json 已经写入固定 public key,Edge / Chrome 加载这个 unpacked extension 时会得到同一个 Extension ID:dhomoolooollmopglnjkeciabhibjjfp。安装脚本也默认把这个 ID 写进 allowed_origins,读者不用再从扩展管理页复制 ID。
Extension ID 是浏览器给扩展生成的 ID。没有固定 key 时,开发模式加载 unpacked extension 得到的 ID 不适合直接当成跨机器、跨浏览器、跨发布渠道的固定值。Chrome 文档提供了
"key"字段,让开发期扩展 ID 从同一把公钥派生出来;Chromium 源码里的 ID 生成逻辑 会取公钥 SHA-256 的前 16 字节,再映射成a-p字母。Edge 文档也提醒,扩展发布到 Add-ons 后,正式 ID 可能和 sideload 阶段不同,因此商店分发时仍要以商店里的正式 ID 更新allowed_origins。
Windows:
.\native-host\install\install-windows.ps1macOS:
./native-host/install/install-macos.sh两个安装入口都会调用同一份 native-host/install/index.mts。入口负责参数解析、生成 host manifest 和选择平台分支;Windows 注册表逻辑在 install/utils/windows.mts,macOS / Linux 的 manifest 目录和符号链接逻辑在 install/utils/unix.mts。安装器会根据当前 native-host/ 目录动态生成真实 JSON 格式的 host manifest,并让 manifest 里的 path 指向 native-host/src/ 下面的运行时入口:Windows 指向 native-host/src/native-host.cmd,macOS 指向 native-host/src/native-host.sh。Windows 把这份 JSON 的路径写进 Chrome、Edge、Chromium 的用户级注册表位置;macOS 在 Chrome、Edge、Chromium 的 manifest 目录下创建同名符号链接,指向这份生成出来的 JSON。脚本也会扫描当前用户环境里已经存在的 NativeMessagingHosts 位置。
README 里保留了一段静态 host manifest 样例,但它只是说明,不参与安装流程。真实给浏览器读取的 manifest 会在安装时生成到 native-host/install/fun.shengsheng.native_host_demo.json,里面包含当前机器上的绝对路径和当前扩展 ID。
对于 360、QQ、Dia 这类 Chromium 变体,脚本会先看本机是否存在对应安装痕迹;检测到以后,再写入一组候选位置。回到扩展管理页点 Reload,再打开 popup 点 Ping Native Host,就能看到 pong、扩展 origin、系统平台和 Node.js 版本。
macOS 这里用符号链接只是 demo 的安装方式,不是 Native Messaging 的新机制。浏览器要求的是固定目录里能读到
<HOST_NAME>.json;正式安装器也可以选择复制 JSON 文件。Windows 直接执行.ps1时可能被本机 Execution Policy 拦截;遇到这种情况,可以临时用powershell -ExecutionPolicy Bypass -File .\native-host\install\install-windows.ps1跑 demo。macOS 从 zip 解压后如果丢了可执行权限,先执行chmod +x ./native-host/install/install-macos.sh。
这段代码只适合解释 sendNativeMessage() 的一问一答流程;真实项目应该把读写帧封装成单独模块。如果用 connectNative() 保持长连接,host 需要在循环里持续读帧,直到 stdin 关闭或出现不可恢复错误。一问一答适合复制一次文件、查询一次版本、跑一次 doctor;需要保持会话、连续收发多条消息时,再用 connectNative() 建立长连接。
安装逻辑
Native Messaging 的调用入口只有 host 名称。扩展声明 nativeMessaging 权限后,可以调用 runtime.sendNativeMessage('com.example.native_host', message),但不能在调用时临时传入本机可执行文件路径。浏览器会用这个 host 名称去系统里查找同名 host manifest,再由 host manifest 指向真正要启动的本机程序。
因此安装 Native Host 的核心工作,是把本机程序和浏览器能读取的索引文件对应起来。demo 里的安装脚本会用固定 Extension ID 生成 allowed_origins,把真实入口路径写进 host manifest,再按平台差异把浏览器指向这份 manifest。
Windows 用户级安装
Windows 上,浏览器通过注册表找到 host manifest。这个 JSON 文件可以放在任意位置,注册表默认值指向它即可。demo 里直接把生成出来的 fun.shengsheng.native_host_demo.json 放在 native-host/install/ 目录下;正式分发时,我倾向于把 manifest 和 host 二进制放在同一个应用目录,例如:
%LOCALAPPDATA%\ExampleHost\com.example.native_host.json
%LOCALAPPDATA%\ExampleHost\native-host.exe这样安装、升级和卸载都只围绕一个目录处理。即使 Windows 允许 path 相对 manifest 文件所在目录,实际分发时也建议写成绝对路径,减少安装器工作目录、快捷方式启动和调试脚本带来的差异。
Chrome 的用户级注册表位置是:
HKCU\Software\Google\Chrome\NativeMessagingHosts\<HOST_NAME>Edge 有自己的注册表位置:
HKCU\Software\Microsoft\Edge\NativeMessagingHosts\<HOST_NAME>注册表默认值写的是 host manifest 的完整路径,不是 host 二进制路径。Edge 找 manifest 时还有一条容易忽略的顺序:它会先查自己的注册表位置,再继续查 Chromium 和 Chrome 的位置;只要前面的某个位置命中,就不会继续往后查。一个同时支持 Chrome Web Store 和 Microsoft Edge Add-ons 的工具,最好明确给 Chrome 和 Edge 都写注册表,并把两个商店对应的扩展 ID 都放进 allowed_origins。
360、QQ 这类 Chromium 浏览器的情况要分开看:如果它复用 Chrome / Chromium 的 Native Messaging 注册表位置,默认安装脚本已经覆盖;如果本机已经存在对应的 NativeMessagingHosts 注册表根,脚本会扫描并写入;如果检测到常见的 360、QQ、Dia 安装痕迹,脚本也会写入一组候选注册表根。仍然失败时,通常说明这个浏览器使用了脚本不知道的私有路径,需要把真实根路径传给 -RegistryRoot。
开发阶段可以用一条用户级命令先跑通 Chrome:
REG ADD "HKCU\Software\Google\Chrome\NativeMessagingHosts\com.example.native_host" /ve /t REG_SZ /d "C:\Users\me\AppData\Local\ExampleHost\com.example.native_host.json" /f这条命令写的是当前用户的注册表,不需要把 host 装到系统目录;Edge 只是换成 HKCU\Software\Microsoft\Edge\NativeMessagingHosts\<HOST_NAME>。
写完注册表以后通常不需要重启电脑。浏览器在下一次启动 native host 时会按注册表和 manifest 查找;开发阶段重新触发一次 sendNativeMessage(),或者在扩展管理页点 Reload 后再试,一般就能看到新配置。已经建立的 connectNative() 长连接不会因为 manifest 改了自动重连;这种情况下先断开连接、重载扩展,必要时重启浏览器进程。排查时优先重载扩展或重启浏览器,不把重启系统当成默认步骤。
很多 Windows 教程写完注册表后会建议重启,是因为 Explorer、系统服务、文件关联、驱动或策略缓存都可能长时间持有旧配置。Native Messaging 这条链路的读取点更窄:浏览器启动 host 时按 host 名称查注册表,再读取 manifest。配置写错时,重载扩展、断开已有 native port 或重启浏览器进程通常已经足够;重启电脑只是排查缓存和残留进程的兜底动作。
macOS 用户级安装
macOS 不用注册表。用户级安装就是让浏览器用户数据目录下的 NativeMessagingHosts/ 子目录里出现 <HOST_NAME>.json,并确保 manifest 里的 path 指向可执行的 host 入口。例如 Chrome stable 是:
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/<HOST_NAME>.jsonEdge stable 是:
~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/<HOST_NAME>.jsonDia 和其他 Chromium 浏览器也是同一个问题:如果它们使用自己的 Native Messaging manifest 目录,安装脚本需要知道那个目录。demo 脚本会扫描 ~/Library/Application Support 下已经存在的 NativeMessagingHosts 目录;检测到 Dia、QQ、360 这类常见变体时,也会写入对应候选目录。需要强制指定时,可以执行 ./native-host/install/install-macos.sh --browser dia、./native-host/install/install-macos.sh --browser qq 或 ./native-host/install/install-macos.sh --browser 360;其他浏览器可以用 --manifest-dir 指到自己的目录。默认模式会在这些目录里创建指向生成 JSON 的符号链接;如果想复制真实文件,可以加 --copy-manifest。
从使用成本看,Windows 的「安装感」会更明显,因为需要写 HKCU 或 HKLM 注册表;macOS 是让 manifest 出现在浏览器固定目录,可以复制文件,也可以用符号链接指向同一份生成 JSON。Linux 也是固定目录思路,如果后续要支持,需要再补对应发行包和路径检查。HKCU 是当前用户级注册表,通常不需要管理员权限,比 HKLM 轻一些,但仍然是一次注册动作。
商店分发也要落到本机安装
Edge 文档给了两个事实:扩展本身可以像普通扩展一样安装;native apps 不由 Edge 安装或管理。它列出的两种分发模型都依赖本机安装动作:一种是用安装包同时安装扩展和 host;另一种是扩展从 Microsoft Edge Add-ons 安装后,再提示用户安装 host。
Chrome 文档里也有同一个前提:浏览器启动 host 前,必须先能在本机找到 host manifest。其他 Chromium 浏览器如果支持 Native Messaging,也要满足这个前提;差异在于具体商店是否允许在扩展安装后提示用户安装配套程序、安装包能不能同时部署扩展和 host。不能把 Edge 文档里的分发模型理解成所有 Chromium 浏览器都会自动处理本机 host。
开发模式加载文件夹也没有额外的自动安装机制。用户打开 edge://extensions 或 chrome://extensions,加载 unpacked extension,只会让浏览器读取扩展目录;浏览器不会因此复制 host manifest、写注册表或安装本机二进制。开发阶段能做的是把这些动作脚本化:扩展用 manifest.key 固定 ID,安装脚本用这个固定 ID 生成 allowed_origins,最后写 HKCU 注册表,或把 manifest 复制、链接到对应目录。
二进制分发也要覆盖安装层,不能只给一个 exe 或可执行文件。真正可用的包至少要包含:
- host 二进制。
- host manifest 生成逻辑或静态样例。
- Windows 注册表安装逻辑。
- macOS manifest 复制或符号链接逻辑。
- 写入
allowed_origins的扩展 ID。 - 一个
doctor命令或能力,用来检查当前安装状态。
如果没有 doctor,用户只能看到扩展报「host not found」或「access forbidden」,很难知道是浏览器没找到 manifest、manifest 里 ID 不对、二进制没执行权限,还是 host 自己启动后崩了。
通用 Native Host 的能力分发
浏览器通道打通以后,真正影响长期维护的是 host 怎么承接多个本地能力。我们的方案把 host 做成能力分发层:扩展仍然只连接一个 native messaging host,host 内部再按 method 找到具体能力,并统一处理参数、错误、诊断和权限边界。
多个扩展复用同一个 Host
最小 demo 只需要扩展发一条 JSON,host 做一件事,再返回结果。这个 host 可以是 Python 脚本、Node 程序、Swift 程序,也可以是 Rust 二进制。
但我们的场景不是只做一个一次性 demo。右键复制 GIF 需要写系统剪贴板;后面还可能接本地 CLI、网络诊断、缓存清理、安装 doctor、二进制版本检查。把每个功能都写成一个独立 host,很快会遇到三类问题:
- 安装重复:每个 host 都要处理 manifest、浏览器差异、扩展 ID 和更新。
- 协议重复:每个功能都要重新定义请求、返回值、错误和日志。
- 风险分散:剪贴板、文件系统、命令行工具的权限边界散在不同程序里,很难统一审计。
我们的目标是让多个扩展复用同一个本地能力 Host。浏览器仍然只看到一个 native messaging host;host 内部再用能力注册表分发不同 method。
本文采用的是「扩展 + JSON 请求 + Native Host + 能力注册表」这条实现路线。当前仓库可以同时保留解释型和编译型两个 host 落点,用同一套协议验证安装、剪贴板和 doctor 能力。它是 Native Messaging 的一种工程化封装,不是浏览器规范本身。

图里的 Native Host、能力注册表和 native.doctor / clipboard.copyFile 这类能力,是本文方案为长期维护加上的工程层。Native Messaging 规范本身只要求浏览器能按 manifest 启动本机程序,并用 stdio 交换带长度前缀的 JSON 消息。
在这条方案里,扩展侧负责浏览器里的上下文:用户点了哪个右键菜单、当前页面 URL 是什么、要处理哪个资源、用户是否主动触发了动作。Host 负责浏览器碰不到的那层系统能力:本地文件、剪贴板、命令行工具、系统目录、注册表、诊断信息。
中间的业务协议仍然很薄。扩展发出去的是 JSON 请求,shell 字符串不应该透传给 host。host 回来的内容也要按 Native Messaging 的帧格式写入,普通日志不能混进 stdout。
Host 不能做成 raw shell
Native Messaging 最大的诱惑,是把本机端做成一个命令转发器:
{
"method": "shell.run",
"params": {
"command": "some-cli --flag value"
}
}扩展 ID 白名单只限制入口来源,不能校验消息内容。内容脚本会接触网页,扩展构建链路可能受供应链影响,配置或消息转发也可能把不该传的字段带到 host。host 如果看见 command 字符串就执行,网页输入或扩展缺陷就可能变成本机命令执行。
allowed_origins 只能说明「哪个扩展可以连接这个 host」,不能证明每一条请求都安全。它是入口白名单,不是命令授权系统。
更稳的做法是让 host 暴露一组明确能力。扩展发的是 method + params,host 根据 method 找到具体 handler。每个 handler 自己校验参数、判断权限、记录审计信息,再决定能不能执行。
{
"id": "copy-1",
"method": "clipboard.copyFile",
"params": {
"path": "<CACHE_FILE_PATH>",
"mime": "image/gif",
"sourceUrl": "https://example.com/a.gif"
}
}按 method 分发后,每个能力都可以单独设置安全边界。
clipboard.copyFile 只应该写受控缓存目录里的文件,不应该允许任意本机路径。network.getInterfaces 可以读取网卡信息,但应该返回脱敏后的摘要。command.request 如果真的存在,也应该先经过策略引擎,而不是直接拼 shell。
能力注册表让 Host 可以持续扩展
host 一旦要同时服务剪贴板、CLI、诊断和缓存,就需要把三层代码分开:
- 协议层:负责读写 Native Messaging 帧,处理
id、method、params、result、error。 - 能力注册表:负责把 method 映射到 handler,并统一做 schema 校验、错误包装、日志和版本兼容。
- 能力实现:真正调用剪贴板、文件系统、命令行工具、系统 API 或网络接口。

native.doctor 这类只读诊断请求可以这样写:
{
"id": "doctor-1",
"method": "native.doctor",
"params": {
"browser": "edge",
"extensionId": "<EXTENSION_ID>"
}
}返回值也保持统一:
{
"id": "doctor-1",
"ok": true,
"result": {
"hostVersion": "0.2.0",
"manifestFound": true,
"originAllowed": true
}
}失败时不要只回一段字符串。错误也应该结构化,至少包含稳定错误码、面向用户的短提示、面向调试的细节。
{
"id": "copy-1",
"ok": false,
"error": {
"code": "clipboard.file_not_found",
"message": "要写入剪贴板的文件不存在",
"details": {
"path": "<CACHE_FILE_PATH>"
}
}
}这样扩展 UI 才能把错误分成「需要安装 host」「host 版本太低」「扩展 ID 不匹配」「文件已被清理」「系统剪贴板写入失败」这些不同状态,而不是给用户一个模糊的失败弹窗。
动态能力要停在参数层
如果以后可能调用很多本地工具,host 能不能完全不改,只让扩展动态传?
可以做一部分,但动态边界要停在参数、策略和组合方式上,不能变成任意代码执行。
安全一点的动态请求仍然是结构化的:
{
"id": "run-1",
"method": "command.request",
"params": {
"tool": "local-worker",
"args": ["--print"],
"cwd": "<PROJECT_PATH>",
"stdin": "请分析这次构建失败原因",
"intent": "读取项目并给出分析",
"riskHints": ["readOnly", "networkMayBeUsed"]
}
}这个请求里没有 raw shell 字符串。tool 必须命中 host 已知的工具表,args 必须按数组传递,cwd 必须落在允许目录里,riskHints 只能作为提示,真正的风险判断仍由 host 的 policy 决定。
host 可以内置几类稳定 primitive:
- 启动已登记的本地 CLI。
- 给 CLI 传参数数组和 stdin。
- 限制工作目录。
- 设置超时、输出大小和环境变量白名单。
- 要求用户确认高风险动作。
- 记录调用日志和退出码。
扩展可以动态组合这些 primitive,但不能传一段新的 JS、PowerShell、bash 或任意 shell 字符串让 host 执行。这样 host 确实需要不断增加基础能力,但不需要为了每个 UI 按钮都写一份新通信逻辑。
这也是「host 解耦」比较现实的含义:host 的协议层和分发层尽量稳定,能力层按风险逐步增加。host 仍然会演进,但每次业务功能不必都改动通信骨架。
剪贴板能力只是一个例子
GIF 复制问题正好卡在这条边界上。
纯扩展可以拿到图片 URL,也可以写 text/html、text/plain 或部分浏览器支持的自定义 Web 剪贴板格式。但它不能像桌面程序那样稳定写 Windows CF_HDROP 文件列表,也不能直接操作 macOS Pasteboard 里的 file URL 或 GIF 类型。
从这个角度看,clipboard.copyFile 比 copyGif 更值得沉淀。GIF 只是第一种文件,后面也可能是 WebP、视频片段、下载后的附件、截图文件或其他目标应用更愿意按文件处理的资源。
clipboard.copyFile 的职责覆盖完整生命周期:
- 扩展确认这是用户主动触发的复制动作。
- host 下载或接收文件,并校验大小、MIME、magic bytes 和扩展名。
- 文件进入受控缓存目录,而不是一次调用结束就删除。
- Windows 写文件列表,macOS 写 file URL 或合适的 pasteboard 类型。
- host 返回写入结果、缓存路径、过期时间和可诊断状态。
- 清理任务只删除自己管理的缓存文件。
这样设计以后,GIF 复制只是能力内核上的一个 handler。后面要加「复制图片文件」「复制本地日志」「把 CLI 输出保存成文件再复制」这类功能,也能沿用同一套协议和缓存策略。
Native Host 技术选型和跨平台边界
实现层主要落在语言选择、分发方式和能力边界上。Native Messaging 对 host 的要求很朴素:浏览器能启动它,它能按协议读写 stdin / stdout。真正麻烦的是读者怎么跟着跑通、普通用户怎么安装、后续怎么升级,以及 Windows / macOS 上哪些能力需要走系统 API。
编译型和解释型的边界
Node.js、Python、shell、PowerShell、Lua、Rust、Go、C / C++、Swift 都能写 Native Messaging host。只要浏览器能启动对应入口,并且这个入口严格按 Native Messaging 帧格式读写消息,它就可以成为 host。语言选择真正影响的是运行时依赖、安装脚本、调试方式、升级策略、系统 API 调用和跨平台构建成本。
解释型路线包括 Node.js、Python、Lua、shell 和 PowerShell。它的优势是学习和调试成本低,源码可以直接分发,改完就能重跑;如果文章面对前端读者,Node.js 通常也是默认更容易跟做的一条路。Python 和 Lua 也有源码分发优势,但普通 Windows 用户未必已经有稳定运行时;shell 和 PowerShell 很适合做包装器、安装脚本和极小 demo,但复杂协议、缓存生命周期和跨平台剪贴板逻辑继续堆在脚本里,维护成本会很快上来。
解释型路线也有自己的边界。host manifest 的 path 只能指向浏览器要启动的入口,不能把 node xxx.mjs、python xxx.py 这种带参数的命令直接塞进去;Windows 往往还需要 .cmd、小型 exe 或安装器生成的 wrapper,macOS / Linux 则要处理可执行权限、shebang 和隔离属性。源码分发还会把运行时版本、依赖完整性、更新来源和用户机器上的环境差异交给安装逻辑处理。它降低了学习门槛,但安装和信任边界仍然要明确写进 installer、doctor 和 README。
这次 m-chrome-extensions 里最值得留下的一条排障经验,也发生在这个边界上。浏览器扩展只把 host name 交给 Chromium,Chromium 再按注册表和 manifest 去找 path;一旦 launcher 写错、文件不存在、策略不允许,或者包装层启动 Node 的方式不符合当前浏览器预期,扩展侧看到的仍可能只是 host not found。所以 doctor 不能只检查注册表键和值,还要打印当前 manifest 来源、manifest 里的 path、launcher 是否存在、手动 Native Messaging ping 是否能收发帧,以及切换 .cmd / .exe shim 的命令。这样下次看到同一个浏览器原始错误时,排查不会停在「注册表已经写了,为什么还不行」。
编译型路线包括 Rust、Go、C / C++、Swift 和 C# 这类能产出本机程序的方案。它的优势是运行时依赖更少,容易放进安装包,签名、升级和版本诊断也更像传统桌面工具;涉及剪贴板、注册表、系统目录、文件权限和本地 CLI 时,调用系统 API 的边界也更明确。不同语言的安全模型和库生态并不一样,不能把「编译型」直接等同于安全或稳定,但单独分发一个 host 二进制,确实更容易控制用户机器上的前置条件。
编译型路线的成本集中在构建矩阵和发布流程。Windows、macOS Apple Silicon、macOS Intel、Linux 往往都要有对应产物;有些语言可以比较方便地交叉编译,有些目标仍然需要对应平台的 SDK、linker 或构建机。macOS 还会遇到签名、公证、隔离属性和 Apple SDK;Windows 则要处理架构、杀软误报、签名证书和安装器体验。
从 Windows 直接构建 macOS M1 / Intel 产物通常不划算,实际分发更适合用 macOS CI 或 macOS 构建机分别产出。Linux 也需要对应目标链路、glibc / musl 口径和发行包策略,不能只靠本机一次构建解决所有平台。
这次先把 Node.js 定为主实现
前面排查 host not found、manifest 路径、注册表、浏览器策略、剪贴板写入和 GIF / WebP 资源回退时,Node.js 的调试体验很直接:改完源码就能重新触发扩展,不需要等跨平台编译;日志、doctor、安装脚本和 Native Messaging 帧读写也能放在同一套语言里迭代。对这类还在验证协议、安装链路和能力边界的工具来说,调试速度比单文件纯净度更重要。
所以当前项目先把 Node.js 定为 Native Host 主实现。Rust、Go、Swift 这类编译型路线仍然有价值,但更适合放到后续分发阶段:当目标变成给大量普通用户安装、需要签名安装器、需要更强的系统 API 封装,或者希望完全摆脱用户机器上的 Node 运行时,再把同一套协议迁移到编译型实现。
Node.js 也不意味着只能写裸 JS。Node.js 的 TypeScript 文档里已经提供了内置 TypeScript 支持,但这条能力主要是类型擦除:它不做类型检查,也不会完整读取 tsconfig.json;遇到 enum、带运行时代码的 namespace、参数属性、路径别名这类需要转换的语法时,仍然要交给第三方工具。开发期如果只写可擦除的 TypeScript,较新的 Node 可以直接跑 .mts;如果希望获得完整 TypeScript 开发体验,当前更适合用 tsx 作为源码运行层。
这样做的好处是不用在每次改完 host 后先跑一遍构建。源码分发时,Windows 的 .cmd 和 macOS 的 .sh 仍然只是很薄的 wrapper,真正入口可以落到 native-node 包里的 TypeScript 文件;安装逻辑先确认 Node、pnpm 和依赖状态,再生成 host manifest、写注册表或创建 manifest 链接。等到面向普通用户分发时,再把同一套源码 bundle 成 JS,或者连 Node 运行时一起放进安装包里。
正文的 native.ping demo 保持零运行时依赖,用来减少通信排查变量;项目里的 TypeScript runner、安装工具和完整能力实现是另一层依赖。后续剪贴板、MIME、缓存或 schema 校验需要第三方包时,可以正常加入相应包的 dependencies,不用为了「零依赖」自己重写系统能力。
源码分发可以在安装阶段执行 pnpm install --frozen-lockfile,或者在发现依赖缺失时给出明确命令;打包分发则把依赖和入口一起 bundle,用户不需要理解 workspace 和包管理器。需要单独评估的是带原生 addon、postinstall 下载二进制或依赖额外系统库的包,它们会把问题重新带回平台矩阵和安装诊断里。
Node 后续也有几种分发形态。最轻的是源码包加安装脚本,适合开发者和教程读者;再往前一步,是 bundle 后的 JS 加一份随包携带的 Node 运行时,使用体验接近 Electron 这类跨平台工具;更进一步可以研究 Node.js Single executable applications,把程序注入到 Node 可执行文件里。SEA 还处在 active development,当前约束也更多,适合等协议和能力边界稳定以后再评估。
Monorepo 影响组织和分发,不影响协议
m-chrome-extensions 现在是 pnpm workspace。扩展放在 apps/*,通用包放在 packages/*,Native Host 分成 native-node 和 native-rust 两条实现线。这个组织方式对文章有影响,但影响的是工程落点,不是 Native Messaging 的浏览器协议:浏览器仍然只认 host name、host manifest、allowed_origins 和 manifest 里的 path。
monorepo 的好处是扩展、host、共享协议和 demo 能一起演进。后面如果把 { id, method, params, result, error }、错误码、能力 schema、Extension ID 常量抽到 packages/*,扩展侧和 host 侧可以复用同一套类型定义;native-node 可以专注 host、installer、doctor 和剪贴板能力;native-rust 可以保留为后续编译型验证,不干扰当前 Node 主线。
它也会改变安装脚本要承担的责任。开发期的 manifest 可以指向 workspace 里的 wrapper,但安装脚本不能依赖用户当前命令行所在目录,应该从 import.meta.url 或包目录推导真实路径。源码分发时,安装入口可以在仓库根目录执行 pnpm install --frozen-lockfile,再跑 pnpm --filter @m-chrome-extensions/native-node install:host;如果直接分发给普通用户,最终包就应该输出一个自包含目录,让 manifest 指向包内 wrapper,而不是要求用户保留完整 monorepo。
因此,文章里的 demo 可以继续用一个轻量源码包讲清楚 Native Messaging;真正项目实现则可以明确写成 monorepo 形态:apps 里是浏览器扩展,native-node 是当前主实现,packages 放协议和共享工具,native-rust 保留为后续分发验证。读者理解 Native Messaging 时不需要先懂 monorepo,但读者要跟着跑我们的项目,就需要知道依赖安装、workspace 路径和最终打包产物之间的差别。
协议仍然保持语言无关
Native Messaging 的协议层不绑定具体语言。扩展侧发出的仍然是结构化 JSON 请求,例如 { method, params, id };Host 侧返回的仍然是结构化结果,例如 { ok, result, error }。只要这层协议稳定,上层扩展、安装诊断和能力文档就不必因为实现语言变化而重写。
Host 内部也可以保持同样的分层。协议层只处理 Native Messaging 帧格式和 JSON 解析;能力层只接收已经校验过的请求,不直接读 stdin;安装层只负责 manifest、注册表、浏览器目录和执行权限,不和业务 handler 混在一起。当前用 Node.js 写,是为了让这几层更快被调试和验证;后续如果迁移到 Rust、Go 或 Swift,应该迁移的是实现,不是扩展和 host 之间的业务协议。

Windows 兼容还要额外注意几个点:
stdin/stdout要按二进制模式处理,避免换行转换破坏 4 字节长度前缀。- 写注册表时同时考虑 Chrome 和 Edge 的 Native Messaging host 路径。
- 写剪贴板文件列表时使用 Unicode 路径,并确保文件在目标应用读取前仍然存在。
- 编译型路线要明确目标架构和签名策略;解释型路线要明确运行时路径、wrapper 和依赖检查。
- 安装脚本默认写用户级目录,避免一开始就要求管理员权限。
macOS 则重点在 manifest 目录、执行权限、签名 / 隔离属性和 Pasteboard 写入方式。第一版可以先做用户级安装,把 host 放到 ~/Library/Application Support/<APP_NAME>/,manifest 放到对应浏览器目录。后续如果要给不熟悉命令行的用户用,再考虑 .pkg、.app 或带双击入口的安装包。
第一版先收紧能力边界
Native Messaging 让扩展可以调用本机能力,也把权限判断留给了 host。新能力进 host 前,我会先问这几个问题:
- 纯扩展或 Web API 是否已经能安全完成?能完成就别进 host。
- 这个能力是否必须访问操作系统、本地文件、系统剪贴板或本机 CLI?
- 请求能不能写成结构化 method,而不是 raw shell?
- 参数 schema 能不能清楚描述,错误能不能稳定返回?
- 是否需要用户确认、目录限制、大小限制、超时、脱敏或审计?
- Windows 和 macOS 是否都能给出明确安装和诊断路径?
- 失败时扩展 UI 能不能告诉用户下一步做什么?
这些问题答完,Native Host 才会像一个本地能力层,而不是一个隐蔽的远程控制入口。
第一版不急着开放本地 CLI,也不急着做动态命令平台。先把 clipboard.copyFile、native.doctor、安装诊断和缓存生命周期跑稳。等这条链路能稳定工作,再让本地 CLI 调用进入策略层。后续接入剪贴板、网络诊断或文件缓存时,可以复用通信和诊断层,分别补上各自的参数校验与权限限制。