权限写在 manifest.json 的 permissions 数组中,并展示给用户。声明应与插件实际行为一致;未声明的能力不能通过绕过 api 或动态脚本加载来获得。
这两个概念名字很像、取值也重叠,但管的是两条独立的轴,别混:
permissions |
requires / optional / minApiLevel |
|
|---|---|---|
| 管什么 | 安全:插件获准做什么 | 兼容:宿主会不会做 |
| 谁来判 | 服务端审核 + 用户知情同意 + 运行期权限门 | 客户端在加载插件之前判定 |
| 不满足时 | 调用被拦截(返回空值或抛错) | 缺 requires 拒绝打开;缺 optional 只提示降级 |
| 缺失的原因 | 插件没声明(改 manifest 重发即可) | 客户端版本旧(要升级客户端) |
同一个名字(如 screen:capture)会同时出现在两边,但回答的问题不同:
两者都要满足才能真正调用。 只声明 permissions 而不声明 requires 时,插件在旧客户端上会被加载起来、然后在调用处失败;声明了 requires 则会在加载前就被拦下并给出可读提示。详见版本与兼容性。
| 权限 | 允许的行为 | 推荐接口 | 风险提示 |
|---|---|---|---|
clipboard:read |
读取剪贴板内容或声明剪贴板识别规则 | api.readClipboardText()、api.readClipboardImage()、api.readClipboardFiles()、initialText / enterAction |
只处理插件必要的数据,不要长期保存 |
clipboard:write |
将结果写入系统剪贴板或清空剪贴板 | api.copyText(text)、api.copyClipboardImage(dataUrl)、api.clearClipboard() |
不要覆盖用户剪贴板而不提示 |
clipboard:write-files |
把文件路径写入剪贴板(用户粘到别的应用里会真的产生副本) | api.copyFile(paths) |
与 clipboard:write 是两项权限:那个是写文本,这个是写文件。粘出去会真的落盘,不要在用户没察觉时替换他的剪贴板 |
network:fetch |
由宿主主进程代发网络请求 | api.network.fetch() |
不受同源策略(CORS)限制,可访问任意 http(s) 服务,且不带宿主自身 cookie;应披露数据去向 |
file:dialog |
打开系统文件选择或保存对话框 | api.showOpenDialog()、api.showSaveDialog() |
只读取用户主动选择的路径;宿主不替插件读写文件 |
file:read |
读取用户交给插件的文件信息(名称、大小、时间) | api.file.scan()、api.file.exists()、api.file.reveal() |
只扫描必要的路径,不要遍历用户整个磁盘。它不返回文件内容 |
file:read-content |
按路径与偏移读取文件内容 | api.file.read() |
能拿到文件字节本身。只在用户明确选择/拖入的文件上读取,不要拿它遍历磁盘 |
file:write |
在用户本次授权范围内重命名文件、写入新文件 | api.file.grant()、api.file.rename()、api.file.write() |
会改动用户磁盘;先 dryRun 并把结果展示给用户。写入只受理用户交出来的路径 |
system:shell |
用系统默认程序打开文件/文件夹、把文件移入回收站、读取文件图标 | api.shellOpenPath()、api.shellTrashItem()、api.getFileIcon() |
shellTrashItem 会把文件移进回收站,是用户可见的破坏性操作;shellOpenPath 会拉起关联程序。只在用户明确点选时执行,并让用户看清动的是哪个路径 |
lan:discover |
在同网段播报自己的存在、搜索同网段的其它 ToolZen | api.lan.advertise()、api.lan.discover()、api.lan.stop() |
会向本地网络广播一个含 payload 的小报文(同网段可见);不要把敏感信息放进 payload |
lan:scan |
枚举本机可扫的网段、扫描网段上在线的设备 | api.lan.getInterfaces()、api.lan.scan()、api.lan.cancelScan() |
会向网段内每一台机器发起 TCP 连接(对方装没装 ToolZen 都无所谓);只在用户明确发起扫描时使用,不要后台常开 |
screen:capture |
枚举屏幕与窗口、采集画面与系统声音、区域选区、置顶悬浮窗 | api.desktopCapturer.getSources()、api.capture.getStream()、api.overlay.selectRegion()、api.floatWindow.* |
能拿到用户屏幕上的全部内容,包括其它应用的窗口;只在用户明确发起录制后采集,不要后台常开,并说明录制内容存在哪里 |
schedule:request |
申请定时任务,到点由宿主触发(插件没运行时也会被拉起) | api.requestSchedule()、api.getSchedules()、api.removeSchedule()、api.onScheduleTrigger() |
能让插件在用户没打开它的时候被自动唤起。只在用户明确需要「定期自动做某事」时才申请,任务名要说清干什么;不要用它做后台轮询 |
feature:dynamic |
运行期增删自己的搜索入口 | api.setFeature()、api.getFeatures()、api.removeFeature() |
会改主界面上用户能看到的入口。入口名要如实描述它打开什么,不要伪装成别的插件或系统功能 |
feature:mainpush |
在主搜索框里推送内容(用户每打一个字都会被调用一次) | api.onMainPush() |
输入是同步热路径,回调要快、要稳;推送项要真的与用户输入相关,不要塞广告或常驻项 |
ai:chat |
调用平台 AI 模型 | api.ai()、api.allAiModels() |
消耗平台的付费额度。插件额度与用户的内置对话额度分开计算(默认每人每天 50 次,所有插件合计),插件烧光自己那档不影响用户对话;但请勿循环调用或在后台空转,并在界面上如实显示剩余次数 |
ai:tools |
把插件能力注册成 AI 可调用的工具 | api.registerTool() |
工具的 description 是模型判断「什么时候该用」的唯一依据,写清副作用;工具有副作用的(写文件、发请求)要谨慎,模型可能在你没预期的时候调用它 |
ports:read |
枚举本机监听端口与端口上的进程(地址、端口、协议、PID、进程名、进程路径) | api.ports.list() |
会把本机正在监听的服务与对应进程暴露给插件;只在用户要排查端口占用时读取,不要长期缓存或上报 |
ports:kill |
结束占用端口的进程(破坏性) | api.ports.kill() |
会杀掉用户机器上正在运行的进程,可能中断他的工作。宿主不弹确认框,插件必须自己先向用户确认,并显示进程名与 PID;只按 PID 定位,不要按端口猜 |
权限与能力同名,但两者是两条独立的轴:permissions 管「插件获准做什么」,requires / optional 管「宿主会不会做」。详见版本与兼容性。
有几项能力不需要权限,但仍登记在能力清单里(便于老客户端被 requires 拦下):api.renderQrCode()(纯计算)、api.getLocalAddresses()(只读本机网卡)、api.holdSessionReset() / api.hideMainWindowKeepAlive()(只是「别把我卸载掉」)、api.getNativeId() / api.getPluginLocalDataPath() / api.getPluginTempPath()(设备标识与插件自己的目录)、api.onPluginReady()(宿主通知插件「你已就绪」)。
全局快捷键(api.registerShortcut)不需要声明权限:它只在插件运行期间生效,退出即自动注销,且组合键被宿主或其他应用占用时宿主会直接拒绝。它抢的是系统级键位,插件仍要挑得克制,并在注册失败时如实提示用户。
| 层级 | 入口 | 第三方插件是否依赖 |
|---|---|---|
| 稳定插件 API | 组件 api prop |
是,推荐 |
| 浏览器标准能力 | DOM、fetch、localStorage、window.Vue |
可用,遵守权限和兼容性;源码由 Vite 编译后运行。浏览器 fetch 受同源策略约束,跨域请求改用 api.network.fetch() |
| Electron preload bridge | window.toolzen |
仅使用本文明确标为公开的能力;默认不依赖 |
| 主进程 IPC | ipcRenderer.invoke/send 对应通道 |
禁止 |
| 文件层 | api.file.*(受 file:read / file:write 约束) |
是,但只在用户交出来的路径范围内 |
| 屏幕采集层 | api.desktopCapturer.* / api.capture.* / api.overlay.*(受 screen:capture 约束) |
是,但只在用户发起录制之后 |
文件层单独成层,并且分成三项权限,别把它们当成一件事:
| 权限 | 覆盖 | 给的是什么 |
|---|---|---|
file:read |
file.scan / file.exists / file.reveal |
只有元信息(名称、大小、时间、是否目录) |
file:read-content |
file.read |
文件字节本身 |
file:write |
file.grant / file.rename / file.write |
写入与改名 |
clipboard:read |
readClipboardFiles |
剪贴板里的文件路径 |
file:read 与 file:read-content 刻意分开:前者自 1 级起就发给插件了,用途只有元信息;如果把「读字节」并进同一个权限名,已发布的插件会静默获得读任意文件内容的能力——用户当初同意的是「读文件信息」,不是「读文件内容」。分开之后,商店与用户都能看到这次授权升级。
读取内容的护栏比扫描更严:file.scan 只要求声明权限,而 file.read 只肯读本会话已授权集合里的路径,没 grant 过的一律回 not-granted。拖进插件自身拖放区的文件要先 api.getPathForFile() + api.file.grant()。
局域网层也单独成层,而且那是两件事、两项权限,不要把它们当成一件事:
| 权限 | 覆盖 | 它真正做的事 |
|---|---|---|
lan:discover |
lan.advertise / lan.discover / lan.stop |
在组播地址上收发小报文,让同网段装了 ToolZen 的设备互相看见 |
lan:scan |
lan.getInterfaces / lan.scan / lan.cancelScan |
朝网段里每一台设备发起 TCP 连接,把在线的列出来(对方是什么都无所谓) |
两项刻意分开,理由与 file:read / file:read-content 那一对完全一样:lan:discover 自 3 级起就发给插件了,报文只在自己人之间往来;若把「扫全网段」并进同一个权限名,已发布的插件会静默获得扫整个网段的能力——用户当初同意的是「参与局域网发现」,不是「用我的电脑去探测网段里每一台机器」。分开之后,商店与用户都能看到这次授权升级。反过来说,只声明 lan:scan 的插件也拿不到 advertise / discover。
两项都必须由宿主代劳:渲染层不能监听 UDP 端口,也发不出原始 TCP 连接(connect 只到页面所在的主机与端口)。未声明时一律在渲染层拦住、一个包都不发:lan:discover 缺了则 advertise / discover 回 { ok: false, code: 'invalid' }、stop 回 false;lan:scan 缺了则 getInterfaces 回空数组、scan 回 { ok: false, code: 'invalid', devices: [] }、cancelScan 回 false。详见系统。
屏幕采集层同样单独成层,原因和文件层一样:它有一部分必须在主进程执行。采集源无法在渲染层枚举(W3C 规范禁止 enumerateDevices 暴露它们,desktopCapturer 也只在主进程可用),getDisplayMedia 在没有宿主安装 request handler 时必抛 NotSupportedError,系统回环音频也只能由主进程给出。未声明 screen:capture 时:枚举回空数组、getMediaAccessStatus 回 'unknown'、selectRegion 回 null,而 capture.getStream 直接抛错(静默给一条空流会让插件以为录上了)。详见屏幕。
不需要权限的四项:
| 接口 | 为什么不需要 |
|---|---|
api.holdSessionReset() / api.hideMainWindowKeepAlive() |
只表达「别把本页卸载掉」,不读也不改用户数据 |
api.renderQrCode() |
纯计算:把一段文本画成图片,不碰用户任何数据 |
api.getLocalAddresses() |
只读本机网卡地址,不含用户数据;且这些地址在同网段本来就是公开的 |
它们仍登记在能力清单里(qrcode:render / lan:discover / lan:scan),这样老客户端能被 requires 拦下,而不是加载起来再报一个看不懂的错。注意 api.getLocalAddresses() 与 api.lan.getInterfaces() 虽然都不需要权限,但登记的能力不同——前者挂在 lan:discover 下(3 级起可用),后者挂在 lan:scan 下(4 级起可用)。
前面三项解决的是「主窗口收起 30 秒后插件页被会话重置卸载、后台任务中断」这个问题。
network:fetch 的执行方式与文件能力不同:它对应 api.network.fetch(),请求由宿主主进程发出,不受渲染层同源策略约束,因此目标服务不需要返回 CORS 头。未声明 network:fetch 时 api.network.fetch 直接抛 TypeError(消息点明缺少哪个权限),不会发出任何请求。插件仍可继续使用全局 fetch,但它跑在渲染层、只能访问同源地址;老宿主没有 api.network.fetch 时应退回全局 fetch。详见网络。
需要说清楚实际执行方式:渲染层按声明拦截调用,未声明时 file.scan 返回 { ok: false, code: 'NOT_SUPPORTED', entries: [] }、file.exists 返回 { ok: false, code: 'NOT_SUPPORTED', exists: [...与输入等长的 false] }、file.reveal 返回 false,rename 逐项返回 NOT_SUPPORTED 并在 items[].reason 里给 permission-denied,grant 返回 { ok: false, granted: [] } 并在 rejected 里逐项报 permission-denied,而不是抛异常。但写入的真正护栏不是权限字符串本身,而是宿主维护的已授权路径集合——rename 只受理本会话已登记进该集合的路径。宿主自动登记剪贴板文件、文件对话框选中的路径和粘贴进搜索框的文件;用户拖入插件自身拖放区的文件需要插件自己调用 api.file.grant()。这个集合不落盘、重启即清空。