本手册说明如何为 Desktop Manager 编写、导入并验证一个符合主界面规范的插件。 插件以独立卡片的形态占位在主界面上,每个插件目录自包含、互不影响,便于持续运维与独立扩展。
- 打开浮动悬浮窗,点击顶栏的 「+插件」 按钮。
- 在弹出的「导入插件」面板中,会列出
plugins/目录下所有通过校验的插件。 - 点击某个插件,即会以卡片形式添加到当前页面的空闲网格位。
界面导入列表由主进程
plugins:list扫描生成,只展示存在合法manifest.json的目录。
- 打开「导入插件」面板,底部有 「从 GitHub 导入」 输入框。
- 粘贴仓库地址(如
https://github.com/owner/repo)后点击「导入」。 - 主进程会下载仓库 zip(默认分支)→ 解压 → 自动定位
manifest.json(浅层目录优先)→ 校验id/entry→ 安装到plugins/{id}。 - 安装结果以右上角 Toast 反馈,成功后插件立即出现在上方列表中。
要求:仓库(或其子目录)内含合法的
manifest.json与入口文件;id仅允许字母 / 数字 / 下划线 / 连字符;同名插件已存在时会拒绝安装。
把插件目录复制到项目的 plugins/ 下,目录结构保持:
plugins/
├─ my-plugin/ ← 你的插件目录(目录名与 manifest.id 应一致)
│ ├─ manifest.json ← 必填:插件声明
│ ├─ index.html ← 必填:渲染入口
│ ├─ main.js ← 可选:脚本
│ └─ style.css ← 可选:样式
├─ _template/ ← 内置模板,可复制后改造
└─ plugin.manifest.schema.json ← manifest 的 JSON Schema
放入后,重启应用即可在「+插件」列表中看到。
目录名以
_或.开头的会被扫描器忽略(用于存放非插件资源)。
每个插件根目录必须有一个 manifest.json,完整字段见 plugins/plugin.manifest.schema.json。
必填:id、name、version、entry、type。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
string | 是 | 插件唯一标识,仅小写字母 / 数字 / 连字符 - |
name |
string | 是 | 显示名称 |
version |
string | 是 | 版本号,如 "1.0.0" |
entry |
string | 是 | 入口 HTML,相对插件目录,如 "index.html" |
type |
string | 是 | "content"(内容插件)、"container"(容器插件)或 "skin"(皮肤插件,不占网格位,协议另见 skin-development.md) |
desc |
string | 否 | 一句话简介,展示在导入面板 |
defaultSize |
object | 否 | { "width": number, "height": number },期望初始尺寸 |
styles |
array | 否 | 预设样式列表,见 第 4 节;空数组表示无样式切换 |
defaultStyle |
string | 否 | 默认样式 id(须存在于 styles),可省略 |
draggable |
boolean | 否 | 是否允许拖动(true / false) |
closable |
boolean | 否 | 是否可通过右键「移除卡片」关闭 |
transparent |
boolean | 否 | 内容区是否透明(建议 true) |
permissions |
array | 否 | 能力声明,见 第 6 节 |
styles[] 中的每个预设可声明 sizeGrid,切换到该样式时卡片自动调整占格(样式优先,被压住的其它窗口自动让位):
"styles": [
{ "id": "digital", "name": "数字电子", "sizeGrid": { "cols": 3, "rows": 1 } },
{ "id": "analog", "name": "圆盘仿真", "sizeGrid": { "cols": 3, "rows": 3 } }
]- 新添加插件卡片时,初始占格取
defaultStyle的sizeGrid(无则回退 3×3)。 - 插件内容应做好等比缩放(推荐容器查询单位
cqw/cqh或 SVGviewBox),保证占满卡片。
{
"id": "clock",
"name": "时钟",
"version": "1.1.0",
"entry": "index.html",
"type": "content",
"desc": "24 小时圆盘仿真 / 纯数字电子时钟 + 专注倒计时",
"styles": [
{ "id": "digital", "name": "数字电子", "sizeGrid": { "cols": 3, "rows": 1 } },
{ "id": "analog", "name": "圆盘仿真", "sizeGrid": { "cols": 3, "rows": 3 } }
],
"defaultStyle": "digital",
"permissions": []
}- 插件在宿主的
<iframe>中加载,entry指向的index.html即为根页面。 - 内容区透明:
html, body应设为background: transparent,卡片外观由插件自身绘制(建议不写实底背景,交由宿主卡片承载)。 - 插件卡片没有标题栏,内容占满整卡:插件区域内按住鼠标左键(约 200ms 或移动 ≥6px)即可拖动整卡,松开左键放下(见 4.6 拖动转发协议);设置入口是「插件区域内右键」,插件须按第 4.5 节的右键转发协议把右键事件转交给宿主。
- 建议引入
style.css与main.js组织代码(参考_template/)。 - 插件是独立的:每个插件目录互不引用、不互相依赖,便于单独替换 / 升级 / 删除。
宿主支持对插件进行3 套(或自定义数量)预设样式切换。
用户右键插件卡片 → 「样式预设」中切换;主界面据此下发 style 消息。
- 在
manifest.json中通过styles声明预设,通过defaultStyle指定默认值。 - 插件监听消息并切换样式:
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'style') {
document.body.dataset.style = e.data.style || '默认样式id';
}
});- 用 CSS 皮肤规则驱动外观:
body[data-style="digital"] .time { color: var(--accent); } /* 主题高亮色:随宿主皮肤 */
body[data-style="analog"] .time { color: #ffd166; } /* 预设身份色:不随皮肤 */时钟插件同时提供「数字电子(3×1 横条)」与「24 小时圆盘仿真(3×3)」两种形态,并内置专注倒计时;其余插件(新闻 / 播放器等)各提供 3 套预设样式,均可参照复刻。
宿主设置面板有皮肤切换(科幻 / 鎏金 / 樱野 / 苔原)。插件 iframe 与宿主是两个文档, CSS 变量不跨文档,因此约定如下协议:
- 插件在自己的
:root声明默认高亮色(独立打开插件时生效):
:root { --accent-rgb: 110, 231, 255; --accent: rgb(var(--accent-rgb)); }
/* 实色用 var(--accent);带透明度用 rgba(var(--accent-rgb), 0.x) */- 嵌入宿主后,
plugin-loader.js的applySkinVars会把当前皮肤解析出的--accent-rgb/--ink以内联样式写到插件documentElement上, 内联优先级高于插件:root,高亮色即随皮肤实时联动(换肤不重建 iframe)。 - SVG
<stop>用 CSS 类(.s1 { stop-color: ... })写色可解析var(); 但stop-color="..."属性形式不支持,注意别混用。 - 身份色(如音乐插件的霓虹粉
#ff4fd8)建议不随皮肤变,保持插件辨识度。
插件卡片没有标题栏,卡片的设置菜单(移动到 / 锁定 / 样式预设 / 移除卡片)由插件区域内右键唤起。
由于 iframe 内的右键事件不会冒泡到宿主文档,插件必须监听 contextmenu 并通过 postMessage 转发给宿主:
// 插件区域内右键 → 转发给宿主弹出卡片设置菜单(必须实现,否则卡片无法调出设置)。
window.addEventListener('contextmenu', (e) => {
e.preventDefault();
parent.postMessage({ type: 'ctxmenu', x: e.clientX, y: e.clientY }, '*');
});- 消息格式:
{ type: 'ctxmenu', x: <iframe 内 clientX>, y: <iframe 内 clientY> }。 - 宿主收到后按 iframe 位置换算成页面坐标,就地弹出卡片设置菜单。
- 菜单打开期间宿主会垫一层全屏盾层,点击任意位置(含插件区域)即关闭菜单;插件无需处理。
主界面支持右键按住左右滑动切换页面(累计约两格距离切换一次)。右键拖动若发生在插件 iframe 内,宿主同样收不到指针事件,插件按需转发 swipe 消息补位:
// 右键按住拖动 → 逐帧转发宿主切换页面(宿主收不到 iframe 内的右键移动事件)。
let rSwipe = false;
window.addEventListener('pointerdown', (e) => { if (e.button === 2) rSwipe = true; });
window.addEventListener('pointermove', (e) => {
if (rSwipe && (e.buttons & 2)) {
parent.postMessage({ type: 'swipe', x: e.clientX, y: e.clientY }, '*');
}
});
function rSwipeEnd() { rSwipe = false; }
window.addEventListener('pointerup', (e) => { if (e.button === 2) rSwipeEnd(); });
window.addEventListener('pointercancel', rSwipeEnd);
window.addEventListener('blur', rSwipeEnd);- 消息格式:
{ type: 'swipe', x, y }(iframe 视口坐标,逐帧转发即可,宿主自行累计位移并判定切换)。
插件区域内按住鼠标左键约 200ms,或移动 ≥6px 即可拖动整张卡片;拖动期间须持续按住左键,松开即放下(快速按下即松开仍是纯点击,不影响按钮等交互)。
iframe 会吞掉宿主的鼠标事件:左键按下发生在 iframe 内时,后续 pointermove / pointerup 由 iframe 持续捕获(即使指针移出 iframe 范围),宿主 window 监听收不到。因此拖动由插件全程驱动:判定抓起后逐帧转发 dragmove,松手转发 dragend;宿主另以全屏盾层(shield)+ window 监听作为兜底通道(若事件意外到达宿主也能正常跟随 / 放下,结束操作幂等):
// ---- 整卡拖动:左键按下即抓起——按住约 200ms 或移动 ≥6px 转发宿主接管拖动,
// 松开左键放下;快速点击(按下即松开,如点歌单行)仍是纯点击,不受影响。
// 按下发生在 iframe 内时鼠标事件被 iframe 捕获,宿主收不到——拖动期间须逐帧转发
// dragmove,松手转发 dragend,宿主才能跟随指针并在松开时放下。
// 若插件内有按钮 / 输入框等交互控件,可在 pointerdown 中 closest('button, input') 排除,避免误触带动卡片。 ----
let armed = false;
let dragging = false;
let holdTimer = null;
let startPt = { x: 0, y: 0 };
let lastPt = { x: 0, y: 0 };
function grab(x, y) {
if (!armed) return;
armed = false;
dragging = true;
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
parent.postMessage({ type: 'dragstart', x, y }, '*');
}
window.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
armed = true;
startPt = lastPt = { x: e.clientX, y: e.clientY };
holdTimer = setTimeout(() => grab(lastPt.x, lastPt.y), 200);
});
window.addEventListener('pointermove', (e) => {
lastPt = { x: e.clientX, y: e.clientY };
if (dragging) {
parent.postMessage({ type: 'dragmove', x: e.clientX, y: e.clientY }, '*');
return;
}
if (!armed) return;
if (Math.hypot(e.clientX - startPt.x, e.clientY - startPt.y) >= 6) {
grab(e.clientX, e.clientY);
}
});
function disarm() {
armed = false;
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (dragging) {
dragging = false;
parent.postMessage({ type: 'dragend' }, '*');
}
}
window.addEventListener('pointerup', disarm);
window.addEventListener('pointercancel', disarm);
window.addEventListener('blur', disarm);- 三条消息:
{ type: 'dragstart', x, y }—— 抓起(转发时指针当前位置,宿主以该点为锚,卡片不跳动);{ type: 'dragmove', x, y }—— 拖动期间逐帧转发指针位置,宿主驱动卡片丝滑跟随;{ type: 'dragend' }—— 松开左键,宿主磁吸落点并提交(其它窗口让位 + 落点光边框补间均由宿主完成)。x/y均为 iframe 视口坐标(e.clientX / e.clientY),宿主自行叠加 iframe 位置换算。
- 拖动过程中其它窗口会以「水流挤压」式动画让位,落点淡亮光边框平滑补间——均由宿主完成,插件无需处理。
- 锁定的卡片不可拖动(宿主拦截
dragstart)。
插件可请求宿主在桌面右上角弹窗提醒(持续 3 秒后自动关闭),适合倒计时结束、任务完成等提示:
parent.postMessage({ type: 'notify', title: '专注倒计时', body: '专注时间已结束,休息一下吧' }, '*');时钟插件的专注倒计时即用此协议:结束瞬间弹 Toast + 卡片内容闪烁提示。
资讯类插件可向宿主请求当前配置的数据源内容(默认媒体热榜,用户可在卡片右键 → 「数据源…」配置):
// 插件侧:请求数据(挂载时;点击刷新时带 force 绕过缓存强制拉取)
parent.postMessage({ type: 'news:load' }, '*');
parent.postMessage({ type: 'news:load', force: true }, '*');
// 宿主侧回发:
// { type: 'news:data', ok, items: [{ title, source, url, time }], error, hint?,
// sourceType, dsMeta: { type, label, name }, tags: [{ text, active }] }
window.addEventListener('message', (e) => {
const msg = e.data;
if (msg && msg.type === 'news:data') render(msg.ok, msg.items, msg.error);
});
// 插件侧:提交标签变更(关键词固化为持久过滤条件,随面板存 layout.json)
parent.postMessage({ type: 'news:tags', tags: [{ text: 'AI', active: true }] }, '*');
// 宿主回执:{ type: 'news:cfg', tags: [...] }- 数据抓取在宿主渲染层完成(
window.NewsSource),网络请求统一走主进程news:fetch,插件自身不碰网络。 - 媒体热榜(自建聚合,
renderer/hotlist.js)三种模式:yesterday昨日榜单(昨日热度 / 名次最靠前的最多 50 条,需应用运行期间采集)/current当前榜单(实时榜单,每 15 分钟自动刷新,最多 50 条)/incremental增量监控(最近 6 小时新条目,每 15 分钟刷新);platform可过滤 11 个平台(微博 / 百度热搜 / 知乎 / 哔哩哔哩 / 抖音 / 今日头条 / 贴吧 / 华尔街见闻 / 澎湃新闻 / 凤凰网 / 财联社)。 - RSS / API 条目在「数据源」弹窗中集中管理(各最多 20 条,名称 ≤20 字,可编辑 / 删除);API 的 path 无需写「?」,最多 5 组 key-value 有效时自动拼接;API 支持 GET/POST(POST 可填 body)、测试请求预览、定时抓取(每日定点 / 每小时 / 配额轮询——按月配额自动折算请求间隔,超量暂停自动请求并记录错误日志)。
dsMeta用于头部展示(如「当前榜单·知乎」/ RSS 名称);hint为内联提示文案(如昨日数据不足);tags为宿主下发的持久化标签列表。- 条目点击跳转外链可用桥接
window.parent.desktopManager.app.openURL(url)(系统默认浏览器打开)。
宿主在挂载 / 卸载插件 iframe 时会发送生命周期事件,插件可用于初始化与清理:
window.addEventListener('message', (e) => {
const msg = e.data;
if (!msg || msg.type !== 'lifecycle') return;
switch (msg.event) {
case 'mount': /* 初始化、读持久化 */ break;
case 'unmount': /* 写回持久化、释放资源 */ break;
}
});插件需要的能力通过 permissions 数组声明(当前为约定,供安全审计与后续做权限门控):
| 权限 | 用途 |
|---|---|
app:resolveShortcut |
解析 .lnk 快捷方式 |
app:launch |
启动应用 / 打开文件 |
app:openURL |
用系统默认浏览器打开外链 |
app:pickAudio |
弹出音频文件选择 |
news:fetch |
主进程网络抓取(RSS / 文本,规避渲染层 CORS) |
storage:* |
指定命名空间下的持久化读写 |
插件 iframe 与宿主同源,可通过 window.parent.desktopManager 访问宿主安全桥接(由 preload 通过 contextBridge 暴露,不开放 Node / Electron 原始能力):
// 持久化(JSON 可序列化)
await window.parent.desktopManager.storage.read('my-plugin');
await window.parent.desktopManager.storage.write('my-plugin', { foo: 1 });
// 应用与快捷方式
await window.parent.desktopManager.app.resolveShortcut('/path/to/x.lnk');
await window.parent.desktopManager.app.launch('C:\\path\\to\\app.exe');
await window.parent.desktopManager.app.pickAudio();
await window.parent.desktopManager.app.openURL('https://example.com'); // 系统默认浏览器打开
// 网络抓取
await window.parent.desktopManager.news.fetch('https://example.com/feed.xml');
// 插件清单(主界面导入面板使用同款数据)
await window.parent.desktopManager.plugins.list();注意:桥接通过
window.parent访问;若你的 iframe 环境因沙箱策略无法跨帧访问,同样会在 iframe 自身暴露window.desktopManager(preload 运行于子帧时),可做兜底判断。
- 支持:静态内容卡片、本地音频播放、
storage:*持久化、.lnk解析与启动、外链跳转、网络抓取(经主进程)。 - 暂不支持:直接调用 Node API、访问文件系统任意路径、任意跨域网络(需经
news:fetch/ 主进程白名单)。 - 卡片尺寸由主界面网格决定(基于
cols × rows网格单元):初始与切换样式时取styles[].sizeGrid,用户可自由缩放,defaultSize仅作为期望提示。
- 复制
plugins/_template/为plugins/my-plugin/。 - 修改
manifest.json的id、name、desc、styles等字段。 - 在
index.html编写卡片内容,style.css编写皮肤,main.js处理生命周期、样式切换与右键转发。 - 重启应用 → 「+插件」→ 点击你的插件即可添加。
- 校验 manifest:
manifest.json必须符合plugins/plugin.manifest.schema.json,字段拼写错误或id含非法字符会导致扫描器跳过该目录。 - 看不到插件:确认目录名不以
_/.开头,且与/plugins路径正确;确认manifest.json是合法 JSON。 - 样式不生效:确认已监听
{ type: 'style' }消息,styles中的id与 CSS 里body[data-style="..."]一致。 - 卡片内右键没反应:确认已实现第 4.5 节的右键转发(监听
contextmenu并postMessage),这是卡片调出设置菜单的唯一入口。 - 卡片拖不动:确认已实现第 4.6 节的拖动转发(
pointerdown后按住约 200ms 或移动 ≥6px 触发postMessage {type:'dragstart'});另检查卡片是否被锁定。 - GitHub 导入失败:确认仓库地址可访问、默认分支含
manifest.json与入口文件;网络抓取与下载均在主进程完成(GitHub API 需要 UA,主进程已附带)。 - 无法持久化:确认
permissions声明了相应storage:*,且数据可被 JSON 序列化。