小工具接入(开发者指南)

本文面向插件开发者:教你如何为「沙特协作」编写一个小工具,并提交到工具市场,让用户一键安装。 目标:让任何前端开发者用熟悉的方式开发,打个包就能集成进来,对前端极其友好。

这是什么?

「沙特协作」的小工具是一种市场插件(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 声明所需权限,宿主端零改动。

开发体验(核心)

  1. 用你习惯的脚手架新建项目(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/*
  2. 在插件的 index.html 里,<script> 引入宿主桥即可接入全部能力:

    <!-- 放在 <head> 或 <body> 顶部都行;/shater.js 由宿主在运行时提供,无需你下载 -->
    <script src="/shater.js"></script>

    宿主桥就是一份普通 JS(plugin-bridge/shater.js,你也可以把它拷进自己项目里引用)。它会定义全局 window.shater。 如果你用的是 srcdoc 注入模式(内置 / 未安装预览),宿主会自动注入桥,无需手动加标签。

  3. 在插件代码里直接用 window.shater.* 调能力(见下文 API)。

  4. 打包发布:把 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 字段

字段类型说明
idstring全局唯一标识,如 unzip-tool。一经发布不要更改
namestring展示名称,如「解压专家」
versionstring语义化版本号。每次更新必须递增,否则桌面端不会提示更新
authorstring作者
descstring一句话描述(市场卡片显示)
iconstringSVG 字符串(或 emoji / 文本),用于工具列表图标
sizenumber可选。安装包大小(字节)。旧 Koa 需要手填;新 Koa 会自动从 zip 读取
permissionsstring[]声明插件需要的宿主能力,见下表

权限模型

插件必须显式声明要用到的能力,未声明的调用会被宿主拒绝:

权限对应桥能力说明
uiui.alert / ui.confirm / ui.qrcode / ui.theme宿主弹窗、二维码、主题
clipboardclipboard.readText / clipboard.writeText剪贴板读写
fsfs.readText / fs.writeText / fs.pickDirectory / fs.listFiles插件专属沙箱目录读写 + 用户选目录遍历
storestore.get / store.set插件私有本地键值存储
httphttp.get / http.post发起网络请求
processprocess.scanPorts / process.killPort端口扫描 / 停止进程
shell:<cmd>shell.run('cmd', args, cwd)执行指定系统命令(见下文 shell.run

🔐 两层安全校验shell:<cmd> 既要在 manifest 声明,也会被宿主端白名单二次校验。宿主只允许执行登记过的少数二进制(如 unzip / bsdtar / unrar / zip / mkdir),杜绝插件任意执行命令。

宿主桥 API 总览

插件脚本里直接调用 window.shater.*,全部返回 Promise

// 判断桥是否就绪
if (window.shater) {
  await window.shater.ui.alert('hello');
}
命名空间能力
shater.fs沙箱文件读写、目录选择、文件列表
shater.httpGET / POST 网络请求
shater.store插件私有键值存储
shater.ui弹窗、确认框、二维码、主题查询
shater.clipboard读写系统剪贴板
shater.env运行环境判断
shater.process端口扫描、结束进程
shater.shell受权限约束的通用命令执行

fs —— 文件与目录

所需权限:fs

fs.readText

读取插件专属沙箱目录里的文本文件。

window.shater.fs.readText(path: string): Promise<string>
参数类型说明
pathstring相对插件沙箱目录的路径,如 'config.json''data/notes.txt'

返回值:文件内容字符串;文件不存在时返回空字符串 ''

示例

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>
参数类型说明
pathstring相对插件沙箱目录的路径
contentstring文本内容

返回值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 }>>
参数类型说明
baseDirstring要列出的目录绝对路径(通常来自 fs.pickDirectory
recursivebooleantrue 递归列出所有深层文件;false 只列直接子项(含文件夹)

返回值{ 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 }>
参数类型说明
urlstring请求地址
optobject可选。headers 为自定义请求头

返回值

{
  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 }>
参数类型说明
urlstring请求地址
bodyobject / string请求体。对象会被 JSON.stringify;字符串原样发送
optobject可选。headers 为自定义请求头

返回值:同 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>
参数类型说明
keystring键名
valuestring / object字符串原样保存;对象会 JSON.stringify

返回值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>
参数类型说明
textstring要编码的内容
optobject可选。默认 { margin: 1, width: 320, errorCorrectionLevel: 'M' }

返回值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[]>
参数类型说明
specstring端口范围,如 '3000,8080-8090';传空字符串 '' 扫描全部

返回值:端口扫描结果数组,结构取决于宿主 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>
参数类型说明
pidnumber进程 ID

示例

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 }>
参数类型说明
cmdstring裸二进制名,必须是白名单中的命令,如 'unzip'
argsstring[]参数数组,逐项传入,天然避免 shell 注入
cwdstring / null可选,工作目录

返回值

{
  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> 即可。


打包与发布

  1. 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
  2. 把 zip 发布到市场后端(manifest 已内置在 zip 内,单独再传一份以便后端快速读取元信息):

    curl -F "file=@my-tool.zip" \
         -F "manifest=<manifest.json" \
         https://shater.online/market/publish

    ⚠️ 版本号必须递增:桌面端按 version 比对,版本不变不会提示用户更新。每次改动后务必 bump 版本。

  3. 发布成功后,用户在「小工具 → 市场」里即可看到并一键安装。


调试建议

  • 开发期可直接把插件 dist/ 放到 src/market/plugins.jsonentryHtml(内联)或本地路径预览,桌面端无需发版即可看效果;
  • 插件运行在沙箱 iframe 里,打开系统日志即可看到 console 输出;
  • 若调用宿主能力报「未申请权限」,先检查 manifest.permissions 是否声明了对应权限(命令类需 shell:<cmd>);
  • window.shaterundefined:确认 index.html 里有 <script src="/shater.js"></script>,且插件是通过「已安装 → 真实 URL」方式加载(宿主静态托管 /shater.js)。旧插件缺该标签时宿主会自动补注入,但建议显式加上。

小结

你想做的事怎么做
正常用 Vue/React 开发并打包任意脚手架,builddist/,zip 发布即可
接入宿主能力index.html<script src="/shater.js"></script>,用 window.shater
读写配置 / 记住内容store + fs
访问网络接口http
弹窗 / 二维码 / 复制ui + clipboard
扫描端口 / 停进程process
解压 / 压缩 / 转码 / git 等系统命令shell.run + shell:<cmd> 权限

记住一句话:小工具 = 前端 + 权限声明。宿主端的通用原语(shell.run 等)已经就位,你不必、也不应该去改宿主核心。