Minibar 插件契约(Plugin API)
版本 API v1(对应宿主 0.3.x)· 状态 ✅ 已确认 · 英文版开发者指南见 plugin-guide.en.md。
本文是插件与宿主之间的全部接口:manifest 格式、生命周期、ctx API、UI DSL、错误模型、兼容规则。实现以本文为准;本文未写的能力不存在。
1. 包结构
com.example.myplugin/
├── manifest.json 必需
├── main.js 必需(可通过 manifest.entry 改名)
├── package.json 发布到 npm 时必需(只做包元数据,不执行任何东西)
├── README.md 可选,市场详情页展示
└── screenshots/ 可选,市场详情页展示(PNG,≤ 1 MB/张,≤ 5 张)
- 包内只允许
.json/.js/.md/.png/.jpg/.txt文件,总大小 ≤ 20 MB(解压后),文件数 ≤ 200。 - 只有
entry一个 JS 文件会被执行。没有模块系统、没有require/import;需要多文件请用构建工具打成一个文件。 - 分发格式:npm 包。 市场里的插件就是 npm 上的包(
npm pack产出的 tgz,package/前缀),基座从 npm 源或就近的镜像下载,并按索引里锁定的 sha256 校验。package.json要求:name合法、version等于 manifest 的version、keywords含"minibar-plugin"、files包含manifest.json与入口文件;不得有scripts(没有任何东西会在安装时执行),dependencies不会被安装。minibar-cli init会生成一份。 - 本地安装仍接受文件夹、zip(扩展名
.zip或.minibar)和 tgz:根目录即上述结构,或只含一个一级子目录(npm 的package/)。
2. manifest.json
{
"id": "com.minibar.weather",
"name": "Weather",
"version": "1.0.0",
"description": "Current conditions and the next 6 hours.",
"author": "Minibar",
"homepage": "https://example.com/weather",
"icon": "cloud.sun",
"category": "life",
"entry": "main.js",
"refreshInterval": 900,
"minHostVersion": "0.1.0",
"permissions": ["network", "location"],
"allowedHosts": ["open-meteo.com"],
"settings": [
{ "key": "city", "type": "text", "label": "City", "placeholder": "Leave empty to use your location" },
{ "key": "unit", "type": "select", "label": "Unit", "default": "celsius",
"options": [{ "value": "celsius", "label": "°C" }, { "value": "fahrenheit", "label": "°F" }] }
]
}
2.1 字段
| 字段 | 必填 | 类型 | 默认 | 约束 |
|---|---|---|---|---|
id | ✅ | string | — | 反向域名,^[a-z0-9]+(\.[a-z0-9-]+)+$,≤ 64 字符,全局唯一 |
name | ✅ | string | — | 1–32 字符 |
version | ✅ | string | — | SemVer MAJOR.MINOR.PATCH,可带 -pre |
description | ❌ | string | "" | ≤ 200 字符 |
author | ❌ | string | "" | ≤ 64 字符 |
homepage | ❌ | string | — | https:// URL |
icon | ❌ | string | "square.grid.2x2" | SF Symbol 名;无效时回退默认 |
category | ❌ | string | "other" | productivity / finance / developer / system / life / other |
entry | ❌ | string | "main.js" | 相对路径,不含 ..、不以 / 开头,必须存在 |
refreshInterval | ❌ | integer | 300 | 秒,5 ≤ n ≤ 86400 |
minHostVersion | ❌ | string | "0.1.0" | SemVer;宿主低于此版本不装载,卡片提示升级 |
permissions | ❌ | string[] | [] | 见 §6 权限枚举;重复项报错 |
allowedHosts | ❌ | string[] | [] | 域名(不含协议/端口/路径)或 "*";声明 network 时必须非空 |
allowedSchemes | ❌ | string[] | [] | ctx.openURL 可打开的其他 App 的 URL scheme(如 things、obsidian),≤ 20 项;需要 apps 权限;file/javascript/data 永远不允许 |
settings | ❌ | Setting[] | [] | ≤ 20 项,key 唯一 |
localizations | ❌ | object | {} | 按语言标签(zh-Hans、ja、pt-BR)翻译 name、description 与设置项文案,见 §2.4;≤ 20 种语言 |
未知字段忽略(向前兼容)。
2.2 Setting 对象
| 字段 | 必填 | 说明 |
|---|---|---|
key | ✅ | ^[a-zA-Z][a-zA-Z0-9_]{0,31}$ |
type | ✅ | text / secret / number / toggle / select |
label | ✅ | 表单标签,≤ 40 字符 |
description | ❌ | 表单下方说明,≤ 120 字符 |
default | ❌ | 与 type 匹配:text/secret → string,number → number,toggle → boolean,select → options 之一的 value |
placeholder | ❌ | text / secret / number |
required | ❌ | 默认 false;为 true 且值为空时,卡片显示"需要设置 [去设置]"而不调用 render |
min / max / step | ❌ | number |
options | select 必填 | [{ "value": string, "label": string }] 或 string[],2–50 项 |
secret 在表单中以密码框显示,存储时与其他字段分开(M3 进 Keychain),永远不会出现在日志或错误信息中。
2.4 本地化
"localizations": {
"zh-Hans": {
"name": "天气",
"description": "当前天气与未来几小时。",
"settings": {
"city": { "label": "城市", "description": "…", "placeholder": "留空则使用你的位置" },
"unit": { "label": "单位", "options": { "celsius": "摄氏", "fahrenheit": "华氏" } }
}
}
}
- 宿主按用户的语言偏好顺序匹配:
zh-Hans-CN→zh-Hans→zh,找不到再看下一种偏好语言,最后回退到 manifest 的基础(英文)字段;每个字段独立回退。 - 翻译内容只影响宿主界面(卡片标题、设置表单、市场、引导);插件自己渲染的文字请在
main.js里按ctx.locale处理。 - 未知的设置 key 或选项值会被警告并忽略;非法语言标签是错误。
2.3 校验
安装、本地加载、minibar-cli validate 使用同一套 ManifestValidator。错误是硬失败(拒绝装载),警告显示但放行:
| 级别 | 情况 |
|---|---|
| 错误 | 缺必填字段;格式不符;network 无 allowedHosts;entry 不存在;settings.key 重复;select 无 options;default 类型不符 |
| 警告 | allowedHosts 含 "*";refreshInterval < 30;icon 不是已知 SF Symbol;未声明 description |
3. 生命周期
装载 宿主创建 VM → 注入 bootstrap → 执行 entry → 必须恰好调用一次 defineMiniApp,且 id 与 manifest 一致
首帧 装载成功后立即 render
定时 每 refreshInterval × 用户倍率 × (0.9…1.1) 秒 render 一次;上一次返回了 refreshIn 则下一次改用该值(一次性)
动作 用户点击 button → 执行对应 action → 立即 render
变更 用户改了设置 / 授予或撤销权限 / 点了刷新 → 立即 render
卸载 停止定时器 → 存储落盘 → 销毁 VM
- 插件没有后台执行:没有
setTimeout/setInterval/ Promise 回调会被调度。一切都发生在 render 或 action 的同步调用栈里。 - render 和 action 串行执行,绝不并发。
- 单次 render / action 的 JavaScript 时间最长 5 秒,超时整个插件被终止并自动禁用(见 §8)。等待宿主的时间(
ctx.http、日历、提醒、定位、通知、剪贴板)不计入这 5 秒,但有自己的上限:单次 http 请求 ≤ 15 秒,一次 render / action 累计等待宿主 ≤ 30 秒,超出后后续宿主调用立即以timeout失败(http 返回ok:false,其余抛出)。
4. defineMiniApp(config)
defineMiniApp({
// 可选;若提供必须与 manifest.id 一致(用于防止拷错文件)
id: 'com.minibar.weather',
// 必需:返回 RenderResult(§5)
render(ctx) { /* ... */ },
// 可选:name → function(ctx, payload);payload 来自 button.payload,默认 null
actions: {
refresh(ctx) { ctx.storage.remove('cache'); },
open(ctx, payload) { ctx.openURL(payload.url); }
}
});
render必须是函数;actions若存在必须是对象且值全为函数。render/action中抛出的任何异常都被宿主捕获,显示为错误卡片,不会影响其他插件或宿主。- 全局作用域可以保存状态(模块级变量),但 VM 可能随时被重建(更新、崩溃恢复、宿主重启);需要持久的东西放
ctx.storage。
5. RenderResult
type RenderResult = {
title: string | HeadlineTitle | null; // 必需(可为 null)
body: UINode[]; // 必需,可为空数组
menu?: MenuItem[]; // 卡片 ⋯ 菜单里的次要动作,≤ 6 项(v0.7)
urgency?: number; // 0–1,默认 0;≥ 0.8 触发抢占
urgencyTTL?: number; // 秒,默认 60,范围 5–3600
refreshIn?: number; // 秒,≥ 1;一次性覆盖下次刷新间隔
};
type MenuItem = {
label: string; // ≤ 200 字符
action?: string; // 与 button 相同:actions 里的名字,或 $openSettings / $refresh 等宿主动作
url?: string; // 与 button 相同:http(s) / mailto
payload?: unknown; // 原样传给 action
icon?: string; // SF Symbol,可选
};
type HeadlineTitle = {
text: string; // ≤ 40 字符;宿主再按用户设置截断
icon?: string; // SF Symbol;缺省用 manifest.icon
color?: ColorToken; // 仅 success / warning / danger 会着色,其他 token 按默认色
};
title为字符串时等价于{ text }。title: null表示"本轮不参选头条"(例如日历今天没有日程)。fixed锁定到该插件时状态栏只显示其图标。- 卡片空间留给信息。 刷新按钮宿主已经放在卡片标题栏,
$openSettings一类的"编辑 / 更换 / 设置"动作放进menu,宿主把它们显示在卡片的 ⋯ 菜单最上方(宿主自己的「插件设置…」「上移 / 下移」「停用」在下面)。正文里的button只用于内容本身的动作(开始专注、完成提醒、释放内存、打开某条 PR)或空状态 / 错误态的引导。menu为空或缺失时菜单只有宿主项;非法项被丢弃并记警告。 body超过 500 个节点或嵌套超过 32 层会被截断并记录警告。- 返回值不是对象、缺
body、body不是数组 → 视为 render 失败。
6. ctx API
所有 API 同步。按返回方式分两类:
- 数据类(
ctx.http.*):永不抛出,返回{ ok, ... }结果对象,网络失败是常态。 - 其他:返回普通值;误用时抛出
MinibarError(§8)——权限未授予、在 render 中调openURL、参数非法等。render 中抛出permission_denied时,卡片会显示"需要 X 权限 [授予]"而不是错误。
6.1 基础
| API | 签名 | 说明 |
|---|---|---|
ctx.now() | () => Date | 当前时间 |
ctx.locale | string | 用户首选语言的 BCP 47 标签,如 "en-US"、"zh-Hans-CN";用于正文翻译与格式化(Intl 可用),不受 XPC 服务自身支持的界面语言限制 |
ctx.i18n(tables) | ({ [tag]: T }) => T | 按 ctx.locale 选一张表:先精确匹配(zh-Hans-CN),再逐级缩短(zh-Hans、zh),再 en,最后第一张表。表里放字符串或函数都可以;内置插件的正文翻译都这样写:const t = ctx.i18n({ en: {...}, zh: {...} })。Minibar 0.5.0+ |
ctx.host | { version: string, api: 1 } | 宿主版本与 API 版本 |
ctx.log(...args) | (...any) => void | 写入宿主日志(插件设置页可查看);对象会 JSON 序列化;每条 ≤ 2 KB |
ctx.permissions.granted(name) | (string) => boolean | 用户是否已授予某项权限,用于优雅降级(没授权时换一个分支渲染,而不是抛错) |
ctx.permissions.list() | () => string[] | 当前全部已授予的权限 |
6.2 存储
| API | 签名 | 说明 |
|---|---|---|
ctx.storage.get(key, fallback?) | (string, any?) => any | 不存在返回 fallback(默认 undefined) |
ctx.storage.set(key, value) | (string, any) => void | value 必须可 JSON 序列化;undefined 等价于 remove |
ctx.storage.remove(key) | (string) => void | |
ctx.storage.clear() | () => void | |
ctx.storage.keys() | () => string[] |
- 单插件总量 ≤ 1 MB,超出
set抛storage_quota_exceeded。 - 卸载插件时清空;更新插件时保留。
6.3 设置
| API | 签名 | 说明 |
|---|---|---|
ctx.settings.get(key, fallback?) | (string, any?) => any | 用户填写的值;未填返回 manifest default,再无则 fallback |
ctx.settings.all() | () => object | 全部设置的拷贝 |
设置只读;要让用户改设置,用 button 的 $openSettings 宿主动作。
6.4 网络(权限 network)
ctx.http.get(url: string, headers?: Record<string,string>): HttpResponse
ctx.http.getJSON(url: string, headers?: Record<string,string>): HttpJSONResponse
ctx.http.request(url: string, options: {
method?: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'HEAD';
headers?: Record<string,string>;
body?: string; // 字符串;要发 JSON 自己 stringify 并设 Content-Type
timeout?: number; // 毫秒,默认 15000,上限 15000
cache?: boolean; // 仅 GET,默认 true:30 秒内存缓存
}): HttpResponse
type HttpResponse = {
ok: boolean; // 2xx 且无传输错误
status: number; // 0 表示未得到响应
body: string;
headers: Record<string,string>; // 键小写
error?: 'timeout' | 'too_large' | 'network' | 'http_error';
cached?: boolean;
};
type HttpJSONResponse = Omit<HttpResponse, 'body'> & { json: any; error?: HttpResponse['error'] | 'invalid_json' };
- 抛出(误用):
permission_denied、host_not_allowed(URL 域名不在allowedHosts)、scheme_not_allowed(非 https;localhost 例外)、invalid_argument。 - 返回
ok: false(常态失败):超时、响应 > 5 MB、DNS/连接错误、非 2xx 状态。 - 重定向会跟随,目标域名同样校验;跨域重定向到白名单外 →
host_not_allowed(以ok:false, error:'network'返回而非抛出,因为发生在请求中途)。 - 宿主自动加
User-Agent: Minibar/<version>;可被 headers 覆盖。 - 不支持二进制响应(body 按 UTF-8 解码,失败则
error: 'network')。
6.5 系统指标
ctx.system(): {
timestamp: number; // ms
osVersion: string; // "14.5"
processorCount: number;
hardware: { model: string | null; chip: string | null; memoryTotal: number; // "Mac14,9", "Apple M2 Pro"
cores: { performance: number; efficiency: number } | null }; // Apple 芯片有值,Intel 为 null
cpu: { usage: number; user: number; system: number }; // 0–1
load: { '1m': number; '5m': number; '15m': number };
memory: { used: number; free: number; total: number; percent: number; // 字节
pressure: 'normal' | 'warning' | 'critical'; swapUsed: number }; // 内核内存压力;已用交换空间
disk: { used: number; free: number; total: number; percent: number }; // 字节,启动卷
network: { rxBytesPerSec: number; txBytesPerSec: number };
battery: { percent: number; charging: boolean; timeToEmptyMinutes: number | null } | null; // 台式机为 null
}
无需权限。采样共享,最多每秒更新一次。不包含主机名、用户名、序列号、IP、MAC。
6.6 日历(权限 calendar)
ctx.calendar.upcoming(options?: { hours?: number; limit?: number; includeAllDay?: boolean }): CalendarEvent[]
// hours 默认 24(1–168),limit 默认 10(1–50),includeAllDay 默认 false
type CalendarEvent = {
id: string; title: string;
start: number; end: number; // ms
allDay: boolean;
location: string | null;
calendar: { title: string; color: string | null }; // color: "#RRGGBB"
url: string | null;
};
抛出:permission_denied(插件级未授权)、os_access_denied(系统日历权限被拒,卡片显示"去系统设置")。结果按 start 升序,已结束的事件不返回。
新建日程(仅 action):
ctx.calendar.add(options: {
title: string; start: number; end?: number; // ms;end 默认 start + 1 小时,须晚于 start 且不超过 1 年
allDay?: boolean; calendar?: string; // calendar:日历名称,缺省系统默认日历
location?: string; notes?: string; url?: string; alert?: number; // alert:提前多少分钟提醒
}): { id: string; title: string; start: number; end: number; allDay: boolean; calendar: string }
只能写入允许修改的日历;指定的名称不存在抛 invalid_argument。标题 ≤ 200 字,备注 ≤ 2000 字。
6.7 提醒事项(权限 reminders)
ctx.reminders.incomplete(options?: { list?: string; limit?: number }): Reminder[]
// list:清单名,缺省全部;limit 默认 50(1–200)
type Reminder = { id: string; title: string; due: number | null; priority: number; list: string };
抛出同 §6.6。按 due 升序,无截止日期的排最后。
新建与完成(仅 action):
ctx.reminders.add(options: { title: string; due?: number; allDay?: boolean; list?: string; notes?: string; priority?: number; url?: string }):
{ id: string; title: string; due: number | null; list: string }
ctx.reminders.complete(id: string, completed?: boolean): { id: string; title: string; completed: boolean } // completed 默认 true
add:list缺省为系统默认列表;due为毫秒时间戳,带时间的截止会同时创建一个到期提醒(allDay: true只记日期);priority0(无)–9,同 Reminders 的高 1 / 中 5 / 低 9。complete:id来自incomplete();传false可取消完成。内置"提醒事项"插件每行一个 Done 按钮就是这么做的。- 不提供删除、移动或批量修改。
6.8 定位(权限 location,M2)
ctx.location(): { latitude: number; longitude: number; accuracy: number; city: string | null; updatedAt: number }
宿主缓存位置 15 分钟;city 来自系统反向地理编码,可能为 null。抛出 permission_denied / os_access_denied / unavailable。
6.9 通知(权限 notifications)
ctx.notify(title: string, body?: string, options?: { url?: string }): boolean
返回 false 表示被限流(每插件每 60 秒 1 条)。url 点击通知时打开。抛出 permission_denied、invalid_argument。
6.10 剪贴板(权限 clipboard)
ctx.clipboard(): string // 当前剪贴板文本;无文本返回 ""(等价于 ctx.clipboard.read())
ctx.clipboard.read(): string
ctx.clipboard.write(text: string): { written: true } // 仅 action;≤ 100 KB
抛出 permission_denied。每次 App 会话中的首次读取需要用户在卡片上再确认一次("Confirm Clipboard access for this session [Allow]"),确认前同样抛出 permission_denied。写入不需要会话确认,但只能在 action 中(render 中抛 not_in_action)。
6.11 打开链接
ctx.openURL(url: string): void
只能在 action 中调用;在 render 中调用抛 not_in_action。http / https / mailto 无需权限;manifest allowedSchemes 里声明的其他 scheme(如 things:///add?title=…)需要 apps 权限;其余一律 invalid_argument。
6.12 UI 构造器
ctx.ui 是纯粹的便捷函数,只返回普通对象;也可以手写对象字面量。
ctx.ui.text(value, opts?) // { style, color, maxLines }
ctx.ui.icon(systemName, opts?) // { size, color }
ctx.ui.row(children, opts?) // { spacing, align: 'top'|'center'|'bottom' }
ctx.ui.column(children, opts?) // { spacing, align: 'leading'|'center'|'trailing' }
ctx.ui.spacer(size?) // 无参 = 弹性
ctx.ui.divider()
ctx.ui.progress(value, opts?) // 0–1;{ color }
ctx.ui.button(label, action, opts?) // action: string | { action?, payload?, url? };opts: { color, icon }
ctx.ui.chart(points, opts?) // number[];{ color, height, min, max }
ctx.ui.badge(text, opts?) // { color }
ctx.ui.keyValue(key, value, opts?) // { color }
ctx.ui.grid(children, opts?) // { columns, spacing }
ctx.ui.horizontalScroll(children, opts?) // { itemWidth, spacing };Minibar 0.5.0+
6.13 系统操作(权限 system)
对这台 Mac 做点什么。只能在 action 中调用(在 render 中调用抛 not_in_action),用户必须先授予 system 权限。
ctx.freeMemory(): { freeBefore: number; freeAfter: number; freedBytes: number; allocatedBytes: number; seconds: number } // 字节
ctx.runShortcut(name: string, input?: string): { started: true }
ctx.keepAwake(enabled?: boolean): { enabled: boolean } // 默认 true
freeMemory:宿主短暂申请并触碰一大块内存,迫使内核回收非活跃与缓存页,然后释放(上限为物理内存一半、3 秒,空闲页过低即停)。返回前后的空闲字节数。效果是"空闲内存变多",不是让 Mac 变快;配合ctx.system().memory.pressure在warning/critical时提供按钮最有意义。runShortcut:通过shortcuts://run-shortcut运行用户自己创建的快捷指令,input作为文本输入。快捷指令里能做什么由用户决定,这是插件触达系统其他部分(打开 App、控制设备、调用任意服务)的通用通道。名称 ≤ 100 字符。keepAwake:阻止系统空闲休眠(IOPMAssertion,显示器仍可休眠)。每插件一个断言;插件被停用、撤销system权限、卸载或 App 退出时自动释放,所以插件应在自己的 UI 里提供关闭按钮并在适当时候调用keepAwake(false)。- 抛出:
permission_denied、not_in_action、invalid_argument、unavailable。
6.14 其他 App(权限 apps)
ctx.apps.running(): { name: string; bundleID: string; active: boolean; hidden: boolean }[] // 任意阶段
ctx.apps.open(bundleID: string): { opened: true } // 仅 action
running()只含常规(Dock 可见)App,不含窗口标题、路径、进程参数。open()打开或激活已安装的 App;未安装抛unavailable。- 配合
allowedSchemes,ctx.openURL('obsidian://open?vault=…')可以调用其他 App 的 URL scheme(§6.11)。
6.15 加密与编码(无需权限)
ctx.crypto.sha256(text: string): string // 小写 hex
ctx.crypto.hmacSHA256(key: string, message: string): string
ctx.crypto.base64Encode(text: string): string
ctx.crypto.base64Decode(text: string): string // 非法输入抛 invalid_argument
ctx.crypto.randomUUID(): string
JavaScriptCore 没有 crypto、atob、btoa;需要签名 API 请求(交易所、云服务)时用这些。输入按 UTF-8 处理。
6.16 文件(权限 files)
插件读不到任何它没被给的路径:用户在系统文件对话框里亲手选了什么,插件就能读什么(只读)。
ctx.files.pick(options?: { folders?: boolean; multiple?: boolean; types?: string[]; message?: string }): { opened: true } // 仅 action
ctx.files.list(): { id: string; name: string; path: string; kind: 'file' | 'folder'; available: boolean; addedAt: number }[]
ctx.files.entries(id: string, options?: { hidden?: boolean; limit?: number }):
{ id: string; name: string; kind: 'file' | 'folder' | 'symlink' | 'other'; size: number | null; modified: number | null }[]
ctx.files.read(id: string, options?: { encoding?: 'utf8' | 'base64'; maxBytes?: number }):
{ id: string; name: string; size: number; content: string; encoding: string; truncated: boolean; modified: number | null }
ctx.files.forget(id: string): { forgotten: boolean } // 仅 action
pick()立即返回,对话框由 Minibar 弹出并注明是哪个插件在要;用户选完后 Minibar 自动重新渲染,插件在render里用list()看到新项。folders: true选文件夹(否则选文件),types是扩展名列表(如['md', 'txt']),message≤ 120 字符显示在对话框顶部。同一插件同时只能有一个对话框。entries()只列直接子项(不递归),默认跳过隐藏文件,最多 500 项,按名称排序;子项的id形如"f_1a2b3c4d/notes/today.md",可以再传给entries()/read()。..、绝对路径、穿出所选文件夹的符号链接一律invalid_argument。read()最多 1 MB(maxBytes可更小),超出部分不读并标记truncated: true;utf8(默认)按 UTF-8 解码(非法字节替换为 �),二进制用base64。- 被移动、删除或在已断开磁盘上的项:
list()里available: false,read()/entries()抛unavailable;请在卡片里给"重新选择"按钮。 - 用户可在"设置 › 插件 › 此插件可读取的文件"逐项或全部移除;关闭
files权限即清空全部。每插件最多 50 项。 - 抛出:
permission_denied、not_in_action、invalid_argument、unavailable。 - 示例:
docs/examples/notes-folder/(选一个笔记文件夹,头条显示今天改动的篇数)。本地可用node tools/jsharness/run.js docs/examples/notes-folder --file ~/Notes模拟已选中的文件夹。
7. UI DSL
每个节点是 { "type": string, ...fields }。未知 type 静默忽略;已知 type 但必需字段缺失或类型错误 → 该节点忽略并记录警告(不让整个 render 失败)。
| type | 必需 | 可选(默认) | 渲染 |
|---|---|---|---|
text | value: string | style (body), color (primary), maxLines (0 = 不限) | 单段文本,自动换行 |
icon | systemName: string | size (14), color (primary) | SF Symbol,使用 size × size 的对齐区域 |
row | children: UINode[] | spacing (6), align (center) | 水平排列 |
column | children: UINode[] | spacing (6), align (leading) | 垂直排列 |
spacer | — | size | 无 size 为弹性,撑满父容器主轴 |
divider | — | — | 细分隔线 |
progress | value: number | color (accent) | 0–1,超出裁剪 |
button | label: string + (action 或 url) | payload (null), color, icon | 小号带边框按钮 |
chart | points: number[](≥ 2) | color (accent), height (32), min, max(缺省自动) | 折线 sparkline,≤ 500 点 |
badge | text: string | color (accent) | 胶囊徽章 |
keyValue | key: string, value: string | color(作用于 value) | 左 key 灰色、右 value |
grid | children: UINode[] | columns (2,1–4), spacing (6) | 等宽网格 |
horizontalScroll | children: UINode[] | itemWidth (56,32–200), spacing (6,0–64) | 单行横向滚动,每项等宽、顶部对齐;高度随内容收缩,宽度限于卡片;Minibar 0.5.0+ |
web | src: string | height (200,60–600), data | 插件包内的 HTML 页面内嵌渲染,见 §7.1 |
horizontalScroll 保留所有子项,不挤压成多行;使用系统横向滚动手势与滚动条,内容底部保留滚动条间距,纵向滚动仍由仪表盘处理。其子树沿用全局 500 节点 / 32 层限制。依赖此节点的插件应声明 minHostVersion: "0.5.0"。
style:title(17pt 半粗)/ headline(13pt 半粗)/ body(13pt)/ caption(11pt 次要色)/ mono(12pt 等宽,数字等宽)
ColorToken:primary / secondary / accent / success / warning / danger / muted
button.action:
- 普通字符串 → 调用
actions[name](ctx, payload),然后 render。 $openSettings→ 宿主打开该插件的设置表单。$refresh→ 宿主立即 render(不调用任何 action)。$allow:<permission>→ 用户点击即授予 manifest 中声明的某项权限(例如$allow:location),随后立即 render。未声明的权限被忽略。- 同时给了
url和action→ 先执行 action,再打开 url。 - 未知的
$动作 → 忽略并记录警告。
7.1 Web 卡片(web 节点,微前端)
{ "type": "web", "src": "ui/index.html", "height": 200, "data": { "count": 3 } }
| 字段 | 必需 | 说明 |
|---|---|---|
src | ✅ | 插件包内的 .html 文件(相对路径,不能逃出包) |
height | ❌ | 60–600 pt,默认 200;页面可用 minibar.resize(h) 调整 |
data | ❌ | 任意 JSON(≤ 64 KB),每次 render 都会推给页面 |
页面里可用的 window.minibar(宿主在文档开始前注入):
minibar.data // 当前 data
minibar.onData((data) => {}) // 立即调用一次,之后每次 render 后再调用;返回取消函数
minibar.action(name, payload?) // 触发插件 actions[name](也可用 $openSettings / $refresh / $allow:<permission>)
minibar.resize(height) // 请求卡片高度(60–600)
minibar.log(...args) // 写入插件日志
minibar.locale, minibar.theme.dark
规则:资源只能来自插件包(<script src="app.js"> 相对引用即可,React / Vue / Svelte 打包进包里就行,Vite 请设 base: './');没有网络、iframe、表单、持久存储——数据请在 main.js 里用 ctx.http 取好再放进 data;点击 http/https/mailto 链接会在浏览器打开;主题色以 --minibar-bg / --minibar-text / --minibar-secondary / --minibar-accent / --minibar-success / --minibar-warning / --minibar-danger / --minibar-separator CSS 变量提供,prefers-color-scheme 也有效;WebView 只在卡片可见时存在,面板关闭即销毁。完整示例:docs/examples/web-card/。
8. 错误模型与兼容
8.1 MinibarError
class MinibarError extends Error { code: string; }
| code | 含义 |
|---|---|
permission_denied | 插件级权限未授予(卡片显示"需要 X 权限 [授予]") |
os_access_denied | 系统级 TCC 权限被拒(卡片显示"去系统设置") |
os_access_pending | 系统权限弹窗尚未回答(卡片显示"等待 macOS 授权";用户回答后宿主自动重新 render) |
host_not_allowed | 请求域名不在白名单 |
scheme_not_allowed | 非 https |
not_in_action | 在 render 中调用了仅限 action 的 API |
invalid_argument | 参数类型/范围错误 |
storage_quota_exceeded | 存储超过 1 MB |
unavailable | 宿主能力当前不可用(如定位服务关闭) |
8.2 失败与自动禁用
| 情况 | 宿主行为 |
|---|---|
| render / action 抛出非权限类错误 | 卡片显示错误信息与堆栈摘要;头条跳过;连续 3 次 → 自动禁用 |
| 单次执行的 JS 时间超过 5 秒 | 立即终止并自动禁用,原因"timed out"(等待宿主的时间不计) |
| 返回值结构非法 | 视为一次失败 |
permission_denied / os_access_denied / os_access_pending | 不计入失败;显示引导横幅 |
自动禁用后用户可一键重新启用。
8.3 兼容规则
ctx.host.api是整数 API 版本,当前1。宿主承诺:同一 API 版本内只增不减,不改变已有语义。- 新节点类型、新字段、新 ctx 方法可以在次版本中加入;老宿主忽略未知节点/字段,新 API 方法在老宿主上是
undefined,插件可特性检测或用minHostVersion一刀切。 - 删除或改变语义只会发生在 API v2,届时
defineMiniApp可通过api: 2选择。
9. 安全约束汇总
- 没有文件系统、进程、shell、DOM、
fetch、XMLHttpRequest、setTimeout、require。 - 网络只能访问
allowedHosts;只能 https。 - 系统指标不含可识别信息。
- 日历 / 提醒 / 定位 / 剪贴板 / 通知 / 系统操作各自需要用户在安装时逐项同意,且可随时撤销。
- 系统操作(释放内存、运行快捷指令)只响应用户点击。
openURL只响应用户点击。- 单次执行 JS 5 秒上限、累计等待宿主 30 秒上限;存储 1 MB;响应 5 MB;UI 500 节点。
- 插件源码在市场详情页与设置页可查看。
10. 完整示例:股票
// com.minibar.stocks/main.js
defineMiniApp({
id: 'com.minibar.stocks',
render(ctx) {
const symbols = String(ctx.settings.get('symbols', 'AAPL,NVDA,TSLA'))
.split(',').map(s => s.trim().toUpperCase()).filter(Boolean).slice(0, 10);
if (symbols.length === 0) {
return { title: null, body: [ctx.ui.text('Add some symbols to get started.'),
ctx.ui.button('Set symbols', '$openSettings')] };
}
const cache = ctx.storage.get('quotes', { ts: 0, data: {} });
if (Date.now() - cache.ts > 60_000) {
const data = { ...cache.data };
for (const sym of symbols) {
const res = ctx.http.getJSON(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?range=1d&interval=1d`);
const meta = res.ok && res.json?.chart?.result?.[0]?.meta;
if (meta && meta.regularMarketPrice != null) {
const prev = meta.chartPreviousClose ?? meta.previousClose ?? meta.regularMarketPrice;
data[sym] = { price: meta.regularMarketPrice, change: prev ? (meta.regularMarketPrice - prev) / prev * 100 : 0 };
}
}
ctx.storage.set('quotes', { ts: Date.now(), data });
}
const quotes = ctx.storage.get('quotes', { data: {} }).data;
const first = quotes[symbols[0]];
const rows = symbols.map(sym => {
const q = quotes[sym];
if (!q) return ctx.ui.keyValue(sym, '—');
const up = q.change >= 0;
return ctx.ui.row([
ctx.ui.text(sym, { style: 'mono' }),
ctx.ui.spacer(),
ctx.ui.text(q.price.toFixed(2), { style: 'mono' }),
ctx.ui.badge((up ? '+' : '') + q.change.toFixed(2) + '%', { color: up ? 'success' : 'danger' }),
], { spacing: 8 });
});
return {
title: first
? { text: `${symbols[0]} ${first.price.toFixed(0)} ${first.change >= 0 ? '▲' : '▼'}${Math.abs(first.change).toFixed(1)}%`,
icon: 'chart.line.uptrend.xyaxis', color: first.change >= 0 ? 'success' : 'danger' }
: { text: 'Loading…', icon: 'chart.line.uptrend.xyaxis' },
body: [
ctx.ui.text('Watchlist', { style: 'headline' }),
ctx.ui.divider(),
ctx.ui.column(rows, { spacing: 6 }),
ctx.ui.spacer(4),
ctx.ui.row([ctx.ui.button('Refresh', 'refresh'), ctx.ui.spacer(), ctx.ui.button('Edit list', '$openSettings')]),
],
};
},
actions: {
refresh(ctx) { ctx.storage.remove('quotes'); },
},
});
对应 manifest:
{
"id": "com.minibar.stocks", "name": "Stocks", "version": "1.0.0",
"description": "Your watchlist, color-coded.", "author": "Minibar",
"icon": "chart.line.uptrend.xyaxis", "category": "finance",
"refreshInterval": 60, "permissions": ["network"], "allowedHosts": ["finance.yahoo.com"],
"settings": [{ "key": "symbols", "type": "text", "label": "Symbols", "default": "AAPL,NVDA,TSLA",
"description": "Comma separated, up to 10." }]
}
11. 开发与调试
- 本地安装:设置 → 插件 → "Install from Folder…",选中插件目录即可;修改后点卡片上的刷新。
- 开发者模式:设置 → 插件 → 🔨 "Link Plugin Folder…"(状态栏右键菜单也有)。Minibar 复制一份并监视该目录:每次保存都会重新校验 manifest 并重载插件,存储、设置、权限保留;manifest 写错时卡片直接显示错误。设置页可 "Reload Now" / "Unlink"。
- 日志:设置 → 插件 → 选中插件 → "Logs";
ctx.log与错误堆栈都在这里。 - CLI(M3):
minibar-cli init <dir>生成模板;validate <dir>校验 manifest;run <dir>用真实运行时渲染一次并打印标题与 UI 树;pack <dir>打包。 - Node 烟测(仓库内
tools/jsharness):用 mock host 在任何平台跑 render,主要供 CI 与无 Mac 环境使用。
分享安装链接
网页或文档里放 minibar://market/<插件 id>(例如 minibar://market/com.example.notesfolder):点击后 Minibar 打开市场并定位到该插件,用户确认权限后才安装;链接本身不会安装任何东西。minibar://install?id=<插件 id> 等价。插件必须已发布到 registry。
11. 市场源:官方源与外部源
市场的插件来自若干源,每个源就是一份 registry.json(§8.1 格式,schemaVersion 2)加上它指向的 npm 包:
| 源 | 谁维护 | 在 App 里 |
|---|---|---|
官方源 https://minibar.084817.xyz/registry.json | Minibar | 始终存在,不能修改、不能删除;必须带 registry.sig,验签失败即拒绝;市场显示「已验证来源」 |
| 外部源(任意数量) | 运营它的人 | 用户在「设置 › 市场 › 外部源」里按地址添加或移除;可以不签名(显示「未签名」);每个插件在列表里带来源主机名标签,详情页注明「外部源:host」 |
合并规则:
- 官方源在前,外部源按添加顺序在后。同一个插件 id 只取最先出现的那份:外部源永远盖不住官方插件,两个外部源重复时先添加的赢(日志里会记一条)。
- 官方源里
revoked的 id 对所有来源生效;外部源的revoked只作用于从它安装的插件。移除一个外部源后,从它装的插件照常运行,只是不再收到更新。 - 下载量、精选位(
featured)只来自各自的源;外部源的插件没有官方下载量。
11.1 自己发布一个源
任何人都可以运营一个源,给团队内部、私有插件或镜像用:
- 插件照 §1 打成 npm 包,发布到任意 npm 兼容源(npmjs、公司内网的 Verdaccio、或者像官方一样放一份静态文件:
<源>/<包名>/-/<basename>-<版本>.tgz)。 - 生成
registry.json:每个条目至少要有id、name、version、npm: { name, version }、download(tgz 的完整地址)、sha256(整个 tgz 的哈希)、permissions、allowedHosts;可选npm.registries列出你希望 App 优先尝试的 npm 源(按地区)。仓库里的scripts/build_registry.py就是官方源的生成器,--resolve-from <你的 npm 源>会替你下载已发布的包、校验 manifest 并计算哈希。 - 用 https 托管
registry.json(任何静态托管都行;本机测试可用http://127.0.0.1)。签名可选:scripts/sign-registry.sh keygen生成密钥,sign产出registry.sig放在同一目录;App 只认官方公钥,所以外部源即使签了名也显示为"未签名/未验证",但你可以在自己的内部构建里替换RegistryTrust的公钥。 - 使用者把地址加进「设置 › 市场 › 外部源」。安装时 App 仍然按你索引里的
sha256校验每个包、在沙箱里运行,并逐项征求权限。
想进入官方源,走 §10 / 08-plugin-submission.md 的提交流程。