小工具接入(开发者指南)
本文面向插件开发者:教你如何为「沙特协作」编写一个小工具,并提交到工具市场,让用户一键安装。
目标:让任何前端开发者用熟悉的方式开发,打个包就能集成进来,对前端极其友好。
这是什么?
「沙特协作」的小工具是一种市场插件(Market Plugin):
- 它是一个普通的纯前端项目(Vue / React / Vite / webpack 等都行),
build 后产出 dist/,入口是 index.html;
- 插件通过
<iframe> 沙箱(allow-scripts allow-same-origin)加载运行,按真实 URL 静态托管,因此 dist/ 里拆分的 ./assets/*.js、*.css 相对路径能正确解析——无需内联成单文件;
- 插件没有直接碰任意文件系统 / 进程的能力,只能通过宿主桥
window.shater 调用宿主能力;
- 宿主(Rust 桌面端)代为执行受控的底层操作——文件读写、网络、剪贴板、命令执行等。
这种设计类似于 VS Code 的插件模型:插件不需要、也不应该改动宿主核心代码。你要做的只是:正常写前端 + 在 manifest.json 声明所需权限,宿主端零改动。
开发体验(核心)
-
用你习惯的脚手架新建项目(Vue / React / Vite …),正常开发、正常打包:
npm create vite@latest my-tool -- --template vue
cd my-tool && npm i && npm run build # 产出 dist/,入口 dist/index.html + dist/assets/*
-
在插件的 index.html 里,用 <script> 引入宿主桥即可接入全部能力:
<!-- 放在 <head> 或 <body> 顶部都行;/shater.js 由宿主在运行时提供,无需你下载 -->
<script src="/shater.js"></script>
宿主桥就是一份普通 JS(plugin-bridge/shater.js,你也可以把它拷进自己项目里引用)。它会定义全局 window.shater。
如果你用的是 srcdoc 注入模式(内置 / 未安装预览),宿主会自动注入桥,无需手动加标签。
-
在插件代码里直接用 window.shater.* 调能力(见下文 API)。
-
打包发布:把 dist/ 根目录(index.html + assets/)打成 zip,curl 上传即可。
✅ 不需要单文件:vite-plugin-singlefile 之类内联插件不是必须的。多文件 JS/CSS 只要以真实 URL 托管就能正常加载。
插件结构
一个最小插件就是打包后的 dist/ 目录,zip 后发布。zip 根目录必须直接是 index.html:
my-tool.zip
├── index.html # 入口(必须,zip 根目录直接是它)
├── assets/ # 拆分出的 js / css / 图片(正常打包即可,相对路径自动解析)
└── manifest.json # 插件元信息(id / 名称 / 版本 / 权限 / 图标)
注意:manifest.json 也要放在 zip 根目录(与 index.html 同级)。
manifest.json 字段
权限模型
插件必须显式声明要用到的能力,未声明的调用会被宿主拒绝:
🔐 两层安全校验:shell:<cmd> 既要在 manifest 声明,也会被宿主端白名单二次校验。宿主只允许执行登记过的少数二进制(如 unzip / bsdtar / unrar / zip / mkdir),杜绝插件任意执行命令。
宿主桥 API 总览
插件脚本里直接调用 window.shater.*,全部返回 Promise。
// 判断桥是否就绪
if (window.shater) {
await window.shater.ui.alert('hello');
}
fs —— 文件与目录
所需权限:fs
fs.readText
读取插件专属沙箱目录里的文本文件。
window.shater.fs.readText(path: string): Promise<string>
返回值:文件内容字符串;文件不存在时返回空字符串 ''。
示例:
const cfg = await window.shater.fs.readText('config.json');
const config = cfg ? JSON.parse(cfg) : {};
说明:桌面端文件实际位于 ~/.shater-collab/plugins/<id>/<path>,不会与其它插件或系统文件混淆;Web 预览模式退化为 localStorage。
fs.writeText
向插件专属沙箱目录写入文本文件。
window.shater.fs.writeText(path: string, content: string): Promise<true>
返回值:true。
示例:
await window.shater.fs.writeText(
'config.json',
JSON.stringify({ theme: 'dark' }, null, 2)
);
fs.pickDirectory
弹出系统目录选择框,让用户选择一个目录。
window.shater.fs.pickDirectory(): Promise<string | null>
返回值:选中目录的绝对路径字符串;用户取消则返回 null。
示例:
const dir = await window.shater.fs.pickDirectory();
if (dir) {
console.log('用户选择了', dir);
}
注意:桌面端可用;Web 预览模式会抛错,因此建议 try/catch 或先用 env.isTauri() 判断。
fs.listFiles
递归或平铺列出某个目录下的文件。
window.shater.fs.listFiles(baseDir: string, recursive: boolean): Promise<Array<{ path: string; is_dir: boolean }>>
返回值:{ path, is_dir }[]。path 是相对 baseDir 的相对路径。
示例:
// 递归找出目录下所有压缩包
const dir = await window.shater.fs.pickDirectory();
const all = await window.shater.fs.listFiles(dir, true);
const zips = all
.filter((it) => !it.is_dir && /\.(zip|rar|7z|tar)$/i.test(it.path));
http —— 网络请求
所需权限:http
http.get
发起 GET 请求。
window.shater.http.get(url: string, opt?: { headers?: Record<string,string> }): Promise<{ status: number; ok: boolean; text: string }>
返回值:
{
status: number; // HTTP 状态码
ok: boolean; // status 是否在 200-299
text: string; // 响应体文本
}
示例:
const { status, ok, text } = await window.shater.http.get('https://api.example.com/user');
if (ok) {
const user = JSON.parse(text);
}
http.post
发起 POST 请求。
window.shater.http.post(
url: string,
body: object | string,
opt?: { headers?: Record<string,string> }
): Promise<{ status: number; ok: boolean; text: string }>
返回值:同 http.get。
示例:
const { ok, text } = await window.shater.http.post(
'https://api.example.com/analyze',
{ url: 'https://example.com' }
);
store —— 插件私有存储
所需权限:store
每个插件拥有独立的 key 空间,彼此隔离。桌面端和 Web 预览都可用。
store.get
读取一个值。
window.shater.store.get(key: string): Promise<string | null>
返回值:字符串值;不存在返回 null。存对象时建议自行 JSON.parse。
示例:
const raw = await window.shater.store.get('history');
const history = raw ? JSON.parse(raw) : [];
store.set
写入一个值。
window.shater.store.set(key: string, value: string | object): Promise<true>
返回值:true。
示例:
await window.shater.store.set('token', 'abc123');
await window.shater.store.set('prefs', { theme: 'dark', fontSize: 14 });
ui —— 弹窗、二维码、主题
所需权限:ui
ui.alert
宿主统一弹窗提示。
window.shater.ui.alert(message: string): Promise<true>
示例:
await window.shater.ui.alert('保存成功');
ui.confirm
宿主统一确认对话框。
window.shater.ui.confirm(message: string): Promise<boolean>
返回值:用户点击「确定」返回 true,取消返回 false。
示例:
const yes = await window.shater.ui.confirm('确定要清空列表吗?');
if (yes) {
// 执行清空
}
ui.qrcode
把文本/URL 生成二维码图片的 Base64 DataURL。
window.shater.ui.qrcode(text: string, opt?: QRCodeOptions): Promise<string>
返回值:data:image/png;base64,... 字符串,可直接塞给 <img src>。
示例:
const dataUrl = await window.shater.ui.qrcode('https://shater.online');
// <img :src="dataUrl" alt="二维码" />
ui.theme
获取宿主当前主题。
window.shater.ui.theme(): Promise<'dark' | 'light'>
返回值:'dark' 或 'light'。
示例:
const theme = await window.shater.ui.theme();
document.documentElement.classList.toggle('dark', theme === 'dark');
更推荐的首屏做法:宿主打开插件时会在 URL 追加 ?theme=dark|light,插件可在加载脚本里先读该参数避免闪烁;运行时再用 ui.theme() 或监听 __shaterTheme postMessage 跟随切换。详见下文「主题跟随」。
clipboard —— 剪贴板
所需权限:clipboard
clipboard.writeText
写入系统剪贴板。
window.shater.clipboard.writeText(text: string): Promise<true>
示例:
await window.shater.clipboard.writeText('复制这段文本');
在 iframe 沙箱中,桥还会自动修复 macOS WKWebView 下 Cmd+C/V/A 失效的问题。
clipboard.readText
读取系统剪贴板。
window.shater.clipboard.readText(): Promise<string>
示例:
try {
const text = await window.shater.clipboard.readText();
console.log('剪贴板内容', text);
} catch (e) {
// 某些沙箱环境不允许主动读取,可提示用户 Ctrl/Cmd+V 手动粘贴
}
env —— 运行环境
env.isTauri
判断当前是否运行在桌面端(Tauri 环境)。
window.shater.env.isTauri(): Promise<boolean>
示例:
const isDesktop = await window.shater.env.isTauri();
if (isDesktop) {
const dir = await window.shater.fs.pickDirectory();
} else {
// Web 模式使用 <input type="file"> 等兜底方案
}
process —— 端口与进程
所需权限:process
process.scanPorts
扫描端口占用情况。
window.shater.process.scanPorts(spec: string): Promise<ScanResult[]>
返回值:端口扫描结果数组,结构取决于宿主 Rust 实现(一般为 { pid, port, name } 之类)。
示例:
const rows = await window.shater.process.scanPorts('3000,8000-8010');
console.table(rows);
process.killPort
结束指定进程。
window.shater.process.killPort(pid: number): Promise<any>
示例:
await window.shater.process.killPort(row.pid);
shell —— 通用命令原语(重点)
所需权限:shell:<cmd>,例如用 unzip 需声明 "shell:unzip";或声明 "shell:*" 允许全部白名单命令(谨慎)。
这是推荐的「重操作」通道:解压、压缩、转码、git 等都通过它完成,宿主端无需为你的工具新增任何 Rust 代码。
shell.run
执行一个受白名单约束的系统命令。
window.shater.shell.run(
cmd: string,
args: string[],
cwd?: string | null
): Promise<{ stdout: string; stderr: string; code: number }>
返回值:
{
stdout: string; // 标准输出
stderr: string; // 标准错误
code: number; // 进程退出码,0 通常表示成功
}
示例:解压 zip
const r = await window.shater.shell.run('unzip', ['-o', '/abs/a.zip', '-d', '/abs/out']);
if (r.code === 0) {
console.log('解压成功', r.stdout);
} else {
console.error('失败', r.stderr);
}
示例:创建目录
await window.shater.shell.run('mkdir', ['-p', '/abs/out/sub']);
当前白名单:unzip bsdtar unrar zip tar 7z 7za ffmpeg ffprobe git mkdir cp mv。
⚠️ 调用时若报错「插件未申请 shell:xxx 权限」,请检查 manifest.json 是否声明了对应的 shell:<cmd>。
主题跟随
宿主使用 html.dark 类切换深浅主题。插件可通过两种方式跟随:
方式一:首屏 URL 参数(推荐,避免闪烁)
宿主打开已安装插件时,URL 会带 ?theme=dark|light:
const params = new URLSearchParams(location.search);
const dark = params.get('theme') === 'dark';
document.documentElement.classList.toggle('dark', dark);
方式二:运行时消息
插件加载完成后,宿主会通过 postMessage 发送当前主题;后续切换也会实时推送:
window.addEventListener('message', (e) => {
if (e.data && typeof e.data.__shaterTheme === 'boolean') {
document.documentElement.classList.toggle('dark', e.data.__shaterTheme);
}
});
建议首屏用方式一,运行时两种方式结合:先用 URL 参数落色,再监听消息响应用户切换。
一个最小示例(Vite 脚手架)
index.html(Vite 默认入口,自行加上桥引入):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- 接入宿主桥:/shater.js 由宿主运行时提供 -->
<script src="/shater.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
src/main.js:
import { createApp, h } from 'vue';
createApp({
data: () => ({ text: '', saved: '' }),
methods: {
async save() {
await window.shater.store.set('echo', this.text);
await window.shater.ui.alert('已保存');
},
async copy() {
await window.shater.clipboard.writeText(this.text);
},
},
render() {
return h('div', { style: 'padding:16px' }, [
h('textarea', {
value: this.text,
onInput: (e) => (this.text = e.target.value),
style: 'width:100%;height:120px',
}),
h('button', { onClick: this.save }, '保存到本地'),
h('button', { onClick: this.copy, style: 'margin-left:8px' }, '复制'),
]);
},
}).mount('#app');
不需要引入任何 npm 包来接桥——window.shater 在脚本执行前已由 <script src="/shater.js"> 就绪。
对应的 manifest.json:
{
"id": "echo-tool",
"name": "回声工具",
"version": "1.0.0",
"author": "you",
"desc": "演示宿主桥能力的最小示例",
"icon": "🔊",
"permissions": ["ui", "clipboard", "store"]
}
用通用命令原语做「重操作」
以「解压专家」为例,它完全没有专属后端代码,全部走 shell.run:
// 选择一个目录并列出压缩文件
const dir = await window.shater.fs.pickDirectory();
const files = await window.shater.fs.listFiles(dir, true); // 递归找深层压缩包
// 解压某个 zip(已在 manifest 声明 shell:unzip / shell:bsdtar / shell:unrar / shell:mkdir)
const abs = dir + '/' + file.path;
const out = dir + '/' + file.path.replace(/\.[^.]+$/, '');
await window.shater.shell.run('mkdir', ['-p', out]);
const r = await window.shater.shell.run('unzip', ['-o', abs, '-d', out]);
这种模式的好处:以后你写任何需要调用系统命令的小工具,都不用动宿主一行 Rust 代码,只改前端并声明 shell:<cmd> 即可。
打包与发布
-
build 后,把 dist/ 打成 zip(确保 zip 根直接是 index.html + assets/ + manifest.json):
cd my-tool
npm run build
cd dist
zip -r ../my-tool.zip . # 仓库自带示例见 plugin-samples/*/package.sh
-
把 zip 发布到市场后端(manifest 已内置在 zip 内,单独再传一份以便后端快速读取元信息):
curl -F "file=@my-tool.zip" \
-F "manifest=<manifest.json" \
https://shater.online/market/publish
⚠️ 版本号必须递增:桌面端按 version 比对,版本不变不会提示用户更新。每次改动后务必 bump 版本。
-
发布成功后,用户在「小工具 → 市场」里即可看到并一键安装。
调试建议
- 开发期可直接把插件
dist/ 放到 src/market/plugins.json 的 entryHtml(内联)或本地路径预览,桌面端无需发版即可看效果;
- 插件运行在沙箱 iframe 里,打开系统日志即可看到
console 输出;
- 若调用宿主能力报「未申请权限」,先检查
manifest.permissions 是否声明了对应权限(命令类需 shell:<cmd>);
- 若
window.shater 为 undefined:确认 index.html 里有 <script src="/shater.js"></script>,且插件是通过「已安装 → 真实 URL」方式加载(宿主静态托管 /shater.js)。旧插件缺该标签时宿主会自动补注入,但建议显式加上。
小结
记住一句话:小工具 = 前端 + 权限声明。宿主端的通用原语(shell.run 等)已经就位,你不必、也不应该去改宿主核心。