一个文件就能给 Hermes 桌面加个面板:Desktop Plugin SDK 上手


你每天盯着 Hermes 桌面端干活,慢慢会冒出一个很朴素的念头:要是有个小面板显示今天几个 cron 任务的运行状态就好了,或者状态栏加个按钮,点一下把日志尾巴捞出来。以前这种想法要么忍了,要么去啃整个 App 的源码——那是一份几十万行、对第三方不友好的工程。

现在它有了一条完全不同的路:写一个文件,丢进目录,几秒后生效。Hermes 桌面端窗口里的每一个界面元素——布局面板、侧边导航、状态栏、标题栏工具、⌘K 命令面板、快捷键、主题——都是往同一个注册表里注册出来的,而核心代码注册的方式和插件一模一样。插件不是后补的口子,它就是这个 App 本来的组织方式。

三分钟:写出第一个插件

先看最短的完整例子。建一个文件:

// $HERMES_HOME/desktop-plugins/hello/plugin.js   (默认就是 ~/.hermes/desktop-plugins/hello/plugin.js)
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'

function HelloPane() {
  const gateway = useValue(host.state.gateway)

  return jsxs('div', {
    className: 'flex h-full flex-col gap-2 p-3 text-sm',
    children: [
      jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
      jsx('div', {
        className: 'text-(--ui-text-tertiary)',
        children: `gateway: ${gateway}`
      })
    ]
  })
}

export default {
  id: 'hello', // 必须和文件夹名一致
  name: 'Hello',
  register(ctx) {
    // 右侧挂一个 260px 宽的面板
    ctx.register({
      id: 'pane',
      area: 'panes',
      title: 'hello',
      data: { placement: 'right', width: '260px' },
      render: () => jsx(HelloPane, {})
    })
    // 状态栏右下角放一个小按钮
    ctx.register({
      id: 'chip',
      area: 'statusBar.right',
      order: 130,
      render: () =>
        jsx('button', {
          type: 'button',
          className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
          onClick: () => {
            haptic('tap')
            host.notify({ kind: 'info', message: 'Hello from my plugin!' })
          },
          children: 'hello'
        })
    })
  }
}

保存。App 会盯着 desktop-plugins/ 这个目录,几秒内把它加载进来,之后每次保存都原地热重载。没出现的话,⌘K 搜 Reload desktop plugins。加载失败会在界面上弹一条带着错误信息的提示,改完再存就行——坏插件只会弹错,不会把 App 搞崩。

插件契约:两个接口就够了

一个插件就是 default export 的一个对象:

interface HermesPlugin {
  id: string            // 稳定标识,同时作为 `plugin:<id>` 来源标记和 id 命名空间
  name?: string         // 设置界面里显示的名字,默认等于 id
  defaultEnabled?: boolean  // 默认 true;设为 false 就是"清单里能看到,但要用户手动开"
  register: (ctx: PluginContext) => void
}

register 拿到的是一个被限定过范围的上下文。它不直接碰注册表——上下文会自动打上来源标记(source: 'plugin:<id>')并给每个贡献项的 id 加命名空间前缀(<id>:<本地id>),所以两个插件永远不会撞 id。

interface PluginContext {
  readonly source: string                                   // 例如 'plugin:hello'
  register: (c: PluginContribution) => () => void           // 注册一项,返回注销函数
  registerMany: (cs: PluginContribution[]) => () => void     // 批量注册
  rest: <T>(path, opts?) => Promise<T>                      // 打到自己后端的 REST(/api/plugins/<id>)
  socket: (path, onMessage) => () => void                   // 打到同一命名空间的 WebSocket
  os: PluginOs                                               // 系统通知、打开外链、在文件管理器里定位、剪贴板
  storage: PluginStorage                                     // 插件私有持久化(键存在 hermes.plugin.<id>. 下)
}

而贡献项是所有界面共用的那一个原语:

interface Contribution {
  id: string            // 本地 id,宿主会加命名空间
  area: string          // 放到哪里(见下面的区域表)
  title?: string
  order?: number         // 同一区域内的排序,越小越靠前
  when?: () => boolean   // 动态显隐,由区域重新求值
  enabled?: boolean
  render?: () => ReactNode  // 要挂载的组件
  data?: unknown            // 区域专属的载荷
}

给 render、给 data,或者两个都给,取决于目标区域。

往哪儿放:区域速查

界面位置 area 你要提供什么
布局面板(可停靠/拖拽) PANES_AREA('panes') title + render + data(placement、dock、width、height)
整页路由 ROUTES_AREA data(path)+ render
侧边导航项 SIDEBAR_NAV_AREA data(path、label、codicon)
状态栏 STATUSBAR_AREAS.left / .right render,或 data 形式的 StatusbarItem
标题栏 TITLEBAR_AREAS.left / .center / .right data 形式的 TitlebarTool,或作用域内的 <Contribute>
⌘K 命令面板 PALETTE_AREA data 形式的 PaletteContribution
快捷键 KEYBINDS_AREA data 形式的 KeybindContribution
主题 THEMES_AREA data 形式的一个 DesktopTheme
输入区 COMPOSER_AREAS.* 渲染插槽,或中间件 / 附件提供者

面板的 placement 是语义位置(main、left、right、top、bottom):它会和同位置的已有面板叠成标签页,之后用户可以随便拖。想直接落在某个边缘而不是叠进去,就再加一个 dock 手势——和用鼠标把面板拖到某个投放区是同一件事。

三个必须记住的坑

第一,别写 JSX 语法。 磁盘插件是不编译直接加载的,所以 <div> 这种东西进不去解析器。UI 用 react/jsx-runtime 里的 jsx() / jsxs() 调用写(或者 React.createElement),就像上面那个例子里那样。看着有点啰嗦,但它换来的就是“不需要构建步骤”。

第二,只有三个模块能 import。 @hermes/plugin-sdk、react、react/jsx-runtime——其他说明符一律解析失败,而且是故意的。SDK 外面有一道 lint 围栏拦这个:插件想用 App 内部模块(@/…)会直接报错,正确做法是把需要的能力加进 SDK。

第三,桌面插件是 App 级的,不按 profile 隔离。 它只有一份根目录,对窗口连的每个 profile、每个网关、每台远程机器都生效。想按 profile 区分行为,就在插件内部读当前 profile 自己处理。

界面语言用 App 自己的

SDK 里的 ui.* 直接给你 App 的真实组件、主题变量、图标和格式化函数。这一点比听起来重要:手搓一套自己的样式,结果就是插件界面和 App 长得不一样,用户一眼看出是外来的。用 ui 里的东西,配色和间距跟着主题走——比如上面例子里的 text-(--ui-text-tertiary),就是主题变量,切主题时自动跟着变。

想要后端?加一个名字空间

插件的 REST 和 socket 都打在自己独占的命名空间 /api/plugins/<id> 上。你只要在插件包里放一个 plugin_api.py,这个命名空间就活了(桌面端和 Web dashboard 的 SDK 共用的只有这一层后端名字空间,前端 API 是两套,别搞混)。这样你的插件可以有自己的进程内状态、读写自己的文件、对着 gateway 做长连接,而不必把逻辑塞进 render。React Query 也一并给你了,所以拉数据、缓存、失效这套不用自己搭。

三种投放方式

方式 放哪儿 谁用 要构建吗
磁盘(推荐) $HERMES_HOME/desktop-plugins/<id>/plugin.js 用户、Agent 不用,纯 ESM 直接加载
统一包 $HERMES_HOME/plugins/<id>/desktop/plugin.js 同时还有 Agent 侧代码的插件 不用,同一条磁盘管线
内置 apps/desktop/src/plugins/<id>/plugin.tsx 随 App 一起发布的仓库内插件 要,走 App 自己的 Vite 构建

三种走的是同一个 HermesPlugin 契约,都会出现在 Capabilities → Plugins 里,都能随时开关。想找现成参考,官方有一个配套的 hermes-example-plugins 仓库放示例实现;App 自带的 Radio(电台播放 + 状态栏播放控件 + 音频可视化波形)本身就是一个 SDK 插件,默认关着,在 Capabilities → Plugins 里打开——它是个不错的“插件能写到什么程度”的样本。

安全边界:这不是沙箱

这一点文档里说得非常直白,值得原样转达:加载进来的插件是在渲染进程里按 ESM 求值的,拥有 App 的全部权限——React 单例、整套 SDK(host.request 的 gateway JSON-RPC、ctx.rest、storage、navigate)。这里的隔离只是错误隔离(错误边界 + 独立监听器),它能保证一个坏插件不会把 App 弄崩,但拦不住它做 App 能做的任何事。

对本地来源来说这可以接受——磁盘上的文件本来就能运行代码。但正因如此,远程来源(https + 白名单)不能直接复用这条管线,它需要真正的边界(iframe/worker + CSP + 能力门禁)才能上线;{ integrity } 选项是传输层的接口,不是信任层的接口。

所以:只装你愿意让它跑在你机器上的插件。如果你想先了解官方在插件来源和审核上做了什么,插件目录那篇值得一并读——目录解决的是“从哪来、审核没审、装的是不是原版”。

从哪儿开始

最后一句实话:SDK 的时间线很短——Radio 作为内置 SDK 插件随桌面端上线是最近的事,插件页面的整理也在最近才完成。这意味着接口还可能动,也是个机会:现在能跑通的小插件,很可能是别人还没想到要写的那个。