插件后台实例
插件的 View 和 action 都有明确的结束时机:View 在取消挂载后销毁, action 在执行结束后销毁。它们适合界面交互和一次性操作,无法承载需要在这些入口结束后继续运行的逻辑。
Background 补上了这项能力:它为插件提供独立的后台运行环境, 启动后可以持续运行,不随 View 卸载或 action 执行结束而销毁,直到被停止。
因此,插件可以把需要长期维护的状态、WebSocket 或蓝牙连接放在 background 中, 由页面、文件视图和命令通过消息访问。关闭视图后连接仍可保留, 再次打开视图时也可以读取后台的当前状态。
例如,一个蓝牙电量插件可以由 Cmd+K 选择设备,让 background 负责连接和读取电量, 再由页面显示结果。页面负责交互,background 负责维护设备状态,两者各有自己的生命周期。 插件也可以只提供命令,不提供页面。
一个插件共享一个实例
Section titled “一个插件共享一个实例”在同一窗口、同一 Space 内,一个插件最多有一个运行中的 background。 该插件的所有调用者都与这个实例通信,不会因打开另一个文件或执行另一条命令而创建副本。 不同窗口、不同 Space 或不同插件的实例相互独立。
页面 View ─────┐文件 View A ──┤ 插件的 background文件 View B ──┼─ 消息 → 内存状态、WebSocket、蓝牙连接Cmd+K 命令 ───┘ 独立于各个调用者存活实例的内存状态在停止或崩溃后丢失。需要跨运行保存的数据由插件主动写入私有存储; 宿主不自动保存内存状态,也不提供自动恢复或闹钟唤醒。
示例:增强最近打开历史
Section titled “示例:增强最近打开历史”一个实际用途是记录文件打开次数和最近打开时间。页面只负责查看和搜索, background 在页面关闭后继续接收打开事件,每 10 秒保存有变化的数据。
需要 Lite 插件 API 3.6.0,声明:
{ "requires": { "pluginApi": "3.6.0" }, "background": { "entry": "./background.ts", "fileHistory": true, "autoStart": true }, "storage": { "maxBytes": 16777216 }}在 background 模块中,宿主注入 globalThis.ctx,类型为 SDK 的 BackgroundHost。
声明 fileHistory: true 后,可通过 ctx.events.on("file.opened", listener) 接收当前窗口、
当前 Space 成功打开文件的 { id, path, timestamp }。path 相对 Space,timestamp
为 Unix 毫秒。事件不包含文件内容;读取文件内容、预览资源和 View 重渲染不产生事件,
打开失败也不产生事件。监听没有初始快照,不回放启动之前的历史。
import type { BackgroundHost } from "@eidos.space/plugin-sdk"declare const ctx: BackgroundHost
const subscription = ctx.events.on("file.opened", (event) => { console.log(event.path, event.timestamp)})// 不再需要时取消;停止 background 也会释放监听。// subscription.dispose()事件字段、权限、交付和清理规则见宿主事件 API。
声明 storage.maxBytes 后,ctx.storage 提供与 View/action 相同的
list/read/write/delete,读写 Uint8Array。存储属于插件、本机所有 Space 共享;
用 ctx.spaceId 区分 Space,用 ctx.instanceId 区分本次运行。
多个窗口需要自行避免同键覆盖,不能把一次读改写当作事务。
在 Lite 插件市场中安装 Recent History(最近打开),在 Space 中启用, 并授权自动启动及访问文件打开元数据。插件需要 Eidos Lite 0.28.0 或更高版本(插件 API 3.6.0)。 完整示例和开发说明维护在独立的源码仓库中。 启用后,通过 Cmd+K 使用:
- 最近打开:打开面板:搜索路径、查看次数并重新打开文件。
打开面板会自动连接后台,尚未运行时自动启动。记录默认自动进行,没有开始或停止按钮;禁用插件可停止记录。
关闭面板后打开其他文件,再打开面板,实例 ID 保持不变,计数继续增加。
保存失败会显示错误并在下一周期重试。异常退出或关闭 Space 可能丢失未保存的记录;
授权后,每次打开这个 Space 都会自动记录。background.isRunning() 可检查运行状态,不会触发启动或授权。
声明后台入口
Section titled “声明后台入口”最低插件 API 版本:3.4.0。
在 plugin.json 中添加 background 字段,指向独立模块。下面是 manifest 片段:
{ "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts" }}background.ts 保存共享状态并处理消息。页面与文件视图仍通过 views 声明,
命令仍在 extension 中注册,它们通过 ctx.capabilities.background 访问这个实例。
插件 API 3.6 支持声明 background.autoStart: true,在启用插件或打开 Space 时启动。
Lite 会请求授权,并为当前 Space 和这一版本的插件包记住授权。更新需要重新授权,
禁用插件会撤销授权。仅安装、尚未启用的插件不会启动。
自动启动在打开 Space 时执行一次。手动停止或崩溃后不会循环重启;可通过命令再次 启动,或等下次打开 Space 时自动启动。Lite 关闭期间不会运行,不恢复内存状态, 也不会自动授权蓝牙设备:设备仍需用户主动选择。
默认情况下,安装或启用插件不会自动启动实例。View 或命令首次调用 start()、request()、
postMessage() 或 onMessage() 时,宿主请求授权并按需启动;
实例已运行时,这些调用复用它。requestDevice() 使用独立的设备授权流程,并替换原运行。
如果希望由用户决定何时连接设备或服务,应把启动调用放在按钮或命令处理函数中。
onMessage() 也可能启动后台,不能把它当作不会产生副作用的状态查询。
| 事件 | 结果 |
|---|---|
| 关闭页面或 action 完成 | 释放调用者的请求/订阅,保留后台 |
| 取消尚未完成的设备选择 | 不授予新权限,保留原运行 |
| 确认设备 | 替换运行和设备授权 |
stop()、禁用、卸载或更新 |
停止后台并释放连接 |
| 切换/关闭 Space 或关闭其窗口 | 停止该范围内的后台 |
| 退出 Lite | 停止所有后台 |
开发源码重载也会撤销旧运行。重新打开页面或执行命令以请求新运行。 重启或崩溃后不会自动恢复内存状态。 计时器只在进程存活时执行,不是能够唤醒 Lite 的持久定时任务或闹钟。
View 和 action 的上下文都提供 ctx.capabilities.background。
通过 ExtensionContext.capabilities.actions 注册命令;
在处理函数收到的 action context 上访问 background。
const result = await ctx.capabilities.background!.request({ command: "increment",})request(data) 发送 { requestId, data }。
后台用同一个 requestId 回复,并将结果放在 data 中:
globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data postMessage({ requestId, data: { received: data } })}export {}这层请求/回复格式是 Eidos 宿主约定。
原始消息使用 postMessage(data) 和 await onMessage(listener)。
消息必须是 JSON,二进制数据需要显式编码。
业务错误可以作为结果返回,例如 { ok: false, error: "Device unavailable" },
调用方需要检查结果。宿主错误、取消、退出或超时则会使请求 Promise 拒绝。
Action 应等待回复后再返回,其未完成请求和订阅会随 action 结束而释放。 View 的订阅随页面关闭而释放,后台继续运行。 订阅属于某一次运行:停止、替换设备或更新后,需要重新订阅。
示例:页面和 Cmd+K 共用计数器
Section titled “示例:页面和 Cmd+K 共用计数器”创建一个目录,放入以下四个文件。这里的代码直接来自仓库中可运行的
packages/plugin-sandbox/examples/lite-background 示例。
plugin.json
{ "apiVersion": 1, "id": "example.background-counter", "name": "Background Counter", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts" }, "extension": "./extension.ts", "views": [ { "id": "counter", "title": "Background Counter", "kind": "page", "entry": "./page.ts" } ], "actions": [ { "id": "increment", "title": "Increment background counter", "context": "workspace" }, { "id": "stop", "title": "Stop background counter", "context": "workspace" } ], "placements": [ { "location": "navigation", "view": "counter" }, { "location": "command-palette", "action": "increment" }, { "location": "command-palette", "action": "stop" } ]}background.ts
// Runs in QuickJS. No DOM, Node, Electron or Eidos filesystem access.let count = 0globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data if (data?.command === "increment") count++ postMessage({ requestId, data: { count } }) postMessage({ type: "count", count })}export {}page.ts
import type { Mount } from "@eidos.space/plugin-sdk"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! const output = document.createElement("pre") const increment = document.createElement("button") const stop = document.createElement("button") increment.textContent = "Increment" stop.textContent = "Stop background" const showError = (error: unknown) => { output.textContent = String(error) } increment.onclick = () => { void background .request({ command: "increment" }) .then((value) => { output.textContent = JSON.stringify(value) }) .catch(showError) } stop.onclick = () => { void background.stop().catch(showError) } root.append(increment, stop, output) // Complete mounting before waiting for the user's native permission dialog. void (async () => { try { await background.start() ctx.subscriptions.add( await background.onMessage((value) => { output.textContent = JSON.stringify(value) }) ) output.textContent = JSON.stringify( await background.request({ command: "read" }) ) } catch (error) { showError(error) } })()}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"
const activate: Activate = (ctx) => { ctx.capabilities.actions.register("increment", async ({ capabilities }) => { const result = await capabilities.background!.request({ command: "increment", }) await capabilities.ui.notify(JSON.stringify(result)) }) ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("Background stopped") })}export default activate在支持 API 3.4.0 的 Lite 中:
- 打开测试 Space,进入 Plugin Manager → Load development source…。
- 选择该目录的
plugin.json,并启用插件。 - 从导航打开 Background Counter,确认启动。
- 点击 Increment,然后关闭页面。
- 在 Cmd+K 中执行 Increment background counter。
- 重新打开页面,计数包含命令刚才的累加。
- 执行 Stop background counter。下次启动会重新确认权限, 并从新的内存状态开始。
从 Eidos 仓库运行时,在仓库根目录执行 pnpm dev:eidos-lite,
它也会准备原生 sandbox 可执行文件。
如果已安装的工具尚未支持这项能力,应使用当前源码构建的插件工具和 SDK。
授权网络连接
Section titled “授权网络连接”在 background 对象中声明 network: true,经用户授权后访问公网 HTTPS/WSS 服务:
{ "entry": "./background.ts", "network": true }需要限制目标时,可添加 networkOrigins 白名单。只声明白名单也有效:
{ "entry": "./background.ts", "networkOrigins": ["https://api.example.com", "wss://events.example.com"]}授权对话框会说明允许访问公网服务,或列出白名单中的 origins。
同时声明两者时以白名单为准;空白名单不允许访问任何地址,未声明网络能力默认禁止访问。
浏览器的 browser.networkOrigins 不会赋予后台网络权限。
普通运行不会授权私网地址;下文的本地开发示例需要单独的精确回环授权。重定向仍受 sandbox 网络策略约束。
请将示例地址替换成你的实际服务。
在后台模块中:
const socket = new WebSocket("wss://events.example.com")socket.onmessage = (event) => { postMessage({ type: "server-message", data: String(event.data) })}socket.onclose = () => postMessage({ type: "connection-closed" })socket.onerror = () => postMessage({ type: "connection-error" })HTTP 请求可使用 fetch("https://api.example.com/status")。
要求 Lite API 3.5 的 View 和普通命令使用相同的 Worker 风格 fetch 接口,
支持表单、响应流和取消,各环境独立声明 origin。
网络授权不会提供已认证的 Eidos connection 或账号凭据,
宿主也不会自动重连断开的 socket。
示例:选择并连接 BLE 电量设备
Section titled “示例:选择并连接 BLE 电量设备”准备一个提供 Battery Service(0x180F)和 Battery Level(0x2A19)
的 BLE 外设。这个示例读取电量,并在设备支持时订阅电量通知。
BLE GATT 不负责将音频输出到蓝牙音箱。
创建另一个插件目录,放入以下五个文件。它们来自可运行的
packages/plugin-sandbox/examples/lite-bluetooth 示例。
本地类型文件描述后台使用的 Web Bluetooth 风格 API 子集。
plugin.json
{ "apiVersion": 1, "id": "example.bluetooth-battery", "name": "Bluetooth Battery", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts", "bluetooth": true }, "extension": "./extension.ts", "views": [ { "id": "battery", "title": "Bluetooth Battery", "kind": "page", "entry": "./page.ts" } ], "actions": [ { "id": "open-panel", "title": "蓝牙电量:打开面板", "context": "workspace" }, { "id": "connect", "title": "蓝牙电量:选择并连接设备", "context": "workspace" }, { "id": "status", "title": "蓝牙电量:查看连接与电量", "context": "workspace" }, { "id": "stop", "title": "蓝牙电量:断开并停止后台", "context": "workspace" } ], "placements": [ { "location": "navigation", "view": "battery" }, { "location": "command-palette", "action": "open-panel" }, { "location": "command-palette", "action": "connect" }, { "location": "command-palette", "action": "status" }, { "location": "command-palette", "action": "stop" } ]}background.ts
// QuickJS worker: selection and grants belong to Lite; connecting requires a message.import type { Device, Characteristic } from "./bluetooth-types"type Command = { command: string; id?: string }let device: Device | null = nulllet battery: Characteristic | null = nulllet percent: number | null = nulllet phase = "idle"let failure: string | null = nulllet failedAt: string | null = nullconst progress = (next: string) => { phase = next postMessage({ type: "status", ...state() })}let queue = Promise.resolve()const state = () => ({ id: device?.id ?? null, name: device?.name ?? null, connected: device?.gatt.connected ?? false, percent, phase, error: failure, failedAt,})async function disconnect() { if (!device?.gatt.connected) return const done = new Promise<void>((resolve) => device!.addEventListener("gattserverdisconnected", () => resolve(), { once: true, }) ) device.gatt.disconnect() await done}async function handle(data: Command) { switch (data.command) { case "connect": { if (device?.gatt.connected && battery && phase === "connected") return state() failure = null failedAt = null percent = null battery = null device = (await navigator.bluetooth.getDevices()).find( (d) => d.id === data.id ) ?? null if (!device) throw new Error("The host has not granted this device") device.ongattserverdisconnected = () => { battery = null percent = null if (phase !== "failed") phase = "disconnected" postMessage({ type: "disconnected", ...state() }) } try { progress("connecting") const server = await device.gatt.connect() progress("services") const service = await server.getPrimaryService("battery_service") battery = await service.getCharacteristic("battery_level") battery.addEventListener("characteristicvaluechanged", (event) => { const value = (event.target as Characteristic).value if (value?.byteLength) percent = value.getUint8(0) postMessage({ type: "battery", ...state() }) }) progress("reading") const value = await battery.readValue() if (!value.byteLength) throw new Error("Battery Level returned an empty value") if (value.byteLength) percent = value.getUint8(0) if (battery.properties.notify || battery.properties.indicate) await battery.startNotifications() progress("connected") } catch (error) { failure = String(error) failedAt = phase phase = "failed" postMessage({ type: "status", ...state() }) await disconnect() throw error } return state() } case "read": { if (!battery) throw new Error("Choose and connect a battery device first") const value = await battery.readValue() if (value.byteLength) percent = value.getUint8(0) return state() } case "status": return state() case "disconnect": await disconnect() return state() default: throw new Error("Unknown command") }}globalThis.onmessage = ({ data: { requestId, data },}: MessageEvent<{ requestId: string; data: Command }>) => { queue = queue.then(async () => { try { postMessage({ requestId, data: { ok: true, ...(await handle(data)) } }) } catch (error) { postMessage({ requestId, data: { ...state(), ok: false, error: String(error) }, }) } })}bluetooth-types.ts
// The Web Bluetooth-style subset used by this example. No runtime code.export interface Characteristic extends EventTarget { readonly value?: DataView readonly properties: { notify: boolean; indicate: boolean } readValue(): Promise<DataView> startNotifications(): Promise<Characteristic>}interface Service { getCharacteristic(uuid: string): Promise<Characteristic>}interface Server { readonly connected: boolean connect(): Promise<Server> disconnect(): void getPrimaryService(uuid: string): Promise<Service>}export interface Device extends EventTarget { readonly id: string readonly name?: string readonly gatt: Server ongattserverdisconnected: (() => void) | null}declare global { interface Navigator { readonly bluetooth: { getDevices(): Promise<Device[]> } }}page.ts
import type { Mount, Disposable } from "@eidos.space/plugin-sdk"import { labels, parseState, type BatteryState } from "./status"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! let subscription: Disposable | undefined let generation = 0 let busy = false let disposed = false let state: BatteryState = { phase: "idle", id: null, name: null, percent: null, error: null, } let failedAt = "" root.innerHTML = `<style> .battery-panel { max-width: 520px; margin: 40px auto; padding: 24px; font: 14px/1.6 system-ui; color: var(--foreground, inherit); } .battery-panel h1 { font-size: 20px; margin: 0 0 24px; } .battery-panel h2 { font-size: 17px; margin: 0; } .battery-panel p { margin: 8px 0; } .battery-panel .muted { opacity: .65; font-size: 13px; } .battery-panel .value { font-size: 48px; font-weight: 600; margin: 20px 0; } .battery-panel .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; } .battery-panel button { font: inherit; padding: 7px 14px; border: 1px solid var(--border, #8885); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .battery-panel button:disabled { opacity: .45; cursor: default; } .battery-panel .primary { background: var(--primary, #247080); color: var(--primary-foreground, white); } .battery-panel details { margin-top: 16px; overflow-wrap: anywhere; } .battery-panel [hidden] { display: none; } </style><section class="battery-panel"> <h1>蓝牙电量</h1> <h2 data-device>选择设备</h2> <p data-status role="status" aria-live="polite"></p> <p class="value" data-value hidden></p> <p data-help class="muted"></p> <details data-details hidden><summary>错误详情</summary><p data-error></p></details> <div class="actions"> <button class="primary" data-choose>选择设备</button> <button data-retry hidden>重试连接</button> <button data-read hidden>刷新电量</button> <button data-stop hidden>断开连接</button> <button data-resume>恢复后台状态</button> </div> <p class="muted">支持提供标准电量服务的低功耗蓝牙设备。关闭页面后连接继续保持。</p> </section>` const element = <T extends HTMLElement>(name: string) => root.querySelector<T>("[data-" + name + "]")! const render = () => { if (disposed) return element("device").textContent = state.name || (state.id ? "未命名设备" : "选择设备") element("status").textContent = labels[state.phase] element("value").hidden = state.phase !== "connected" || state.percent === null element("value").textContent = state.percent + "%" element("details").hidden = !state.error element("error").textContent = state.error element("help").textContent = state.phase === "failed" ? failedAt === "services" ? "无法使用设备的标准电量服务。请确认设备支持 Battery Service,或选择其他设备。" : "请确认设备在附近、可连接,且未被其他应用占用,然后重试。" : state.phase === "disconnected" ? "设备连接已断开,电量不再更新。可以重试连接或选择其他设备。" : busy ? "请稍候;连接结果会显示在这里。" : state.phase === "connected" ? "电量读取成功,连接由后台保持。" : "点击选择设备开始连接。" element<HTMLButtonElement>("choose").disabled = busy element("choose").textContent = state.id ? "换个设备" : "选择设备" element("retry").hidden = !state.id || !["failed", "disconnected"].includes(state.phase) element<HTMLButtonElement>("retry").disabled = busy element("read").hidden = state.phase !== "connected" element<HTMLButtonElement>("read").disabled = busy element("stop").hidden = state.phase === "idle" || state.phase === "stopped" || state.phase === "selecting" element("stop").textContent = busy ? "取消连接" : "断开连接" element<HTMLButtonElement>("resume").disabled = busy element("resume").hidden = state.phase === "connected" || busy } const receive = (value: unknown) => { if (disposed) return if (value && typeof value === "object" && "failedAt" in value) failedAt = String(value.failedAt) state = parseState(value) render() } const subscribe = async (run: number) => { subscription?.dispose() const next = await background.onMessage((value) => { if (run === generation) receive(value) }) if (run !== generation || disposed) next.dispose() else subscription = next } const execute = async (operation: (run: number) => Promise<void>) => { if (busy) return busy = true const run = ++generation render() try { await operation(run) } catch (error) { if (run === generation && !disposed) { const message = String(error) state = { ...state, phase: message.includes("selection cancelled") ? "idle" : "failed", error: message.includes("selection cancelled") ? null : message, } } } finally { if (run === generation) { busy = false render() } } } const connect = async (run: number) => { if (run !== generation) return state = { ...state, phase: "connecting", percent: null, error: null } failedAt = "" render() await subscribe(run) if (run !== generation) return const result = await background.request({ command: "connect", id: state.id, }) if (run === generation) receive(result) } element("choose").onclick = () => void execute(async (run) => { const previous = state state = { ...state, phase: "selecting", error: null } render() let device: { id: string; name?: string } try { device = await background.requestDevice() } catch (error) { if (String(error).includes("selection cancelled")) { state = previous if ( previous.phase === "connected" || previous.phase === "disconnected" ) await subscribe(run) return } throw error } if (run !== generation) return state = { ...state, id: device.id, name: device.name ?? null } await connect(run) }) element("retry").onclick = () => void execute(connect) element("read").onclick = () => void execute(async (run) => { await subscribe(run) const result = await background.request({ command: "read" }) if (run === generation) receive(result) }) element("resume").onclick = () => void execute(async (run) => { await subscribe(run) const result = await background.request({ command: "status" }) if (run === generation) receive(result) }) element("stop").onclick = () => { element<HTMLButtonElement>("stop").disabled = true ++generation busy = true subscription?.dispose() subscription = undefined render() void background .stop() .then(() => { state = { ...state, phase: "stopped", percent: null, error: null } }) .catch((error) => { state = { ...state, phase: "failed", error: String(error) } }) .finally(() => { busy = false element<HTMLButtonElement>("stop").disabled = false render() }) } render() ctx.subscriptions.add({ dispose: () => { disposed = true ++generation subscription?.dispose() }, })}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"import { labels, parseState } from "./status"const activate: Activate = (ctx) => { ctx.capabilities.actions.register("open-panel", async ({ capabilities }) => { if (!capabilities.ui.navigate) throw new Error("当前宿主不支持打开插件面板") await capabilities.ui.navigate("battery", "/") }) ctx.capabilities.actions.register("connect", async ({ capabilities }) => { const background = capabilities.background! const device = await background.requestDevice() await capabilities.ui.notify( "正在连接 " + (device.name || "蓝牙设备") + ",并检查电量服务…" ) const result = await background.request({ command: "connect", id: device.id, }) const state = parseState(result) await capabilities.ui.notify( state.phase === "connected" ? (state.name || "设备") + " 已连接 · 电量 " + state.percent + "%" : "连接失败:" + (state.error || labels[state.phase]) ) }) ctx.capabilities.actions.register("status", async ({ capabilities }) => { const state = parseState( await capabilities.background!.request({ command: "status" }) ) await capabilities.ui.notify( (state.name || "蓝牙设备") + " · " + labels[state.phase] + (state.phase === "connected" && state.percent !== null ? " · " + state.percent + "%" : "") + (state.error ? ":" + state.error : "") ) }) ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("已断开连接,后台已停止,设备授权已撤销。") })}export default activate- 在插件管理器加载该目录的
plugin.json,并启用插件。 - 打开 Bluetooth Battery → 选择设备, 或在 Cmd+K 执行 蓝牙电量:选择并连接设备。
- Lite 设备选择弹窗打开后自动扫描;如果操作系统询问蓝牙权限,再进行系统授权。点击设备行选中,再点击“连接”授权该设备。可重新扫描或随时取消。 将外设置于广播状态。
- 点击“连接”只完成所选设备的授权,实际连接结果由页面持续展示。
- Lite 用该设备的授权启动新后台。页面或 action 发送连接消息,
后台调用
getDevices()和gatt.connect()。 - 页面依次显示连接设备、检查电量服务和读取电量,读取成功后才显示“已连接”和百分比。 失败时保留设备和错误详情,可重试或换设备;连接中可取消并停止后台。 断线后隐藏旧电量并提示重连。关闭页面。在 Cmd+K 执行 蓝牙电量:查看连接与电量, 检查仍保持的连接。重新打开页面后,点击 恢复后台状态 订阅当前运行。
- 执行 蓝牙电量:断开并停止后台,结束进程并撤销设备授权。
示例使用 "bluetooth": true,允许读取、写入和订阅用户选中设备的 GATT 特征。
代码仍只读取电量并订阅通知,设备必须实际提供 Battery Service。
插件不能在 manifest 中指定设备 ID。后台的 navigator.bluetooth.getDevices()
只返回选中的设备;后台不能自行打开选择器,
navigator.bluetooth.requestDevice() 在其中不可用。
服务白名单是可选限制。例如仅访问电量服务:
{ "bluetooth": { "services": [{ "uuid": "battery_service" }] } }省略操作字段时,允许该服务下设备支持的读取、写入和通知。
如需进一步收窄,显式声明 "read": true, "notify": true;
只要声明了任意操作字段,未声明的操作就不允许。设备自身不支持的操作仍会失败。
取消选择保留原运行。确认设备会替换运行、清空内存状态并断开旧设备, 其他页面需重新订阅。如果新连接失败,不会自动恢复旧连接。 名称和广播信息不是设备身份的密码学证明,广播也不保证某个服务实际可用。
授权只针对本次运行,不会持久化。普通的 start() 永远不授予蓝牙权限。
设备断开会触发 gattserverdisconnected,示例会报告断开,不会悄悄重连。
File View 复用 WebSocket
Section titled “File View 复用 WebSocket”WebSocket Workbench 把 .wsreq 文本文件作为保存的请求样本。
将 packages/plugin-sandbox/examples/lite-file-websocket 加载为开发插件,
把其中的 samples/*.wsreq 复制到 Space,用此 View 打开两个文件。
点击 Send sample,在宿主弹窗中授权网络访问。
在两个终端分别运行示例自带的 Python 服务和开发版 Lite:
python3 packages/plugin-sandbox/examples/lite-file-websocket/server.pyEIDOS_LITE_DEV_BACKGROUND_ORIGIN=ws://127.0.0.1:8787 pnpm dev:eidos-litePython 只用标准库,仅监听本机回环地址。打开文件不发送内容;点击发送才读取当前文档文本,不发送路径。
开发授权必须匹配字面量 127.0.0.1 的精确 origin 和端口,不能混合其他 origins,仍需宿主弹窗确认。
打包版 Lite 忽略此环境变量。正式使用时,同时修改 background 地址和 manifest origin 为公网 WSS,
私有网络地址仍被禁止。服务器需要原样回显 { id, text } JSON 消息;示例 Python 服务只支持
这里使用的未分片文本帧等基本操作,不是生产级 WebSocket 服务。
文件 A ─┐ ┌─ 响应 A ├─ background.request → 一个 WebSocket ─┤文件 B ─┘ └─ 响应 B同一窗口、Space、插件共用后台实例。并发连接共享一个 Promise,独立的网络消息 ID
把乱序回显映射到宿主请求 ID。background.request() 将响应返回对应调用者,
无需广播订阅或新的 session API。这是消息路由,不是同一插件内部 View 之间的安全隔离。
连接卡片直接显示“本次建立了新 WebSocket”或“复用了已有 WebSocket”,并展示消息序号、
连接编号和建立时间。卡片描述最近一次成功响应,不是实时连接健康监控。
两个文件的响应应显示相同的 connection 编号。关闭一个文件再发送另一个,
只要服务器保持连接,编号就不变;View 关闭时不调用 stop()。
通过 CmdK 的 WebSocket Workbench: stop shared connection 可以统一断开,
下次发送会启动新实例。服务器断开会使待处理请求失败;后续发送重新连接,不自动重发旧消息。
连接和回显分别在八秒后超时,每次限制 16,000 字符,最多 32 个待处理网络请求。
plugin.json
{ "apiVersion": 1, "id": "example.websocket-workbench", "name": "WebSocket Workbench", "version": "1.0.0", "requires": { "pluginApi": "3.4.0" }, "background": { "entry": "./background.ts", "networkOrigins": ["ws://127.0.0.1:8787"] }, "extension": "./extension.ts", "views": [ { "id": "request", "title": "WebSocket Workbench", "kind": "file", "entry": "./file.ts", "capabilities": ["document"], "access": "read" } ], "actions": [ { "id": "stop", "title": "WebSocket Workbench: stop shared connection", "context": "workspace" } ], "placements": [ { "location": "file/open", "view": "request", "extensions": [".wsreq"] }, { "location": "command-palette", "action": "stop" } ]}background.ts
const endpoint = "ws://127.0.0.1:8787/echo"let socket: WebSocket | undefinedlet connecting: Promise<WebSocket> | undefinedlet sequence = 0let connections = 0let connectedAt = 0let sentOnConnection = 0const run = `${Date.now()}-${Math.random().toString(36).slice(2)}`const pending = new Map< number, { resolve: (text: string) => void; reject: (error: Error) => void }>()
function connect(): Promise<WebSocket> { if (socket?.readyState === WebSocket.OPEN) return Promise.resolve(socket) if (connecting) return connecting const ws = new WebSocket(endpoint) socket = ws connecting = new Promise<WebSocket>((resolve, reject) => { const fail = () => { clearTimeout(timer) if (socket !== ws) return socket = undefined reject(new Error("Connection closed. Click Send to reconnect.")) for (const item of pending.values()) item.reject(new Error("Connection lost")) pending.clear() } const timer = setTimeout(() => { fail() ws.close() }, 8000) ws.onopen = () => { clearTimeout(timer) connections++ connectedAt = Date.now() sentOnConnection = 0 resolve(ws) } ws.onerror = fail ws.onclose = fail ws.onmessage = (event: MessageEvent) => { try { const message = JSON.parse(String(event.data)) as { id: number text: string } if (typeof message.text === "string") pending.get(message.id)?.resolve(message.text) } catch { /* Ignore non-protocol server messages. */ } } }).finally(() => { connecting = undefined }) return connecting}
async function send(text: string) { // Bound allocation before connecting; the host also caps concurrent RPCs. if (text.length > 16000) throw new Error("Use a sample of at most 16,000 characters") const ws = await connect() if (pending.size >= 32) throw new Error("Too many pending requests") const id = ++sequence const connection = `${run}/${connections}` const establishedAt = connectedAt const messageNumber = ++sentOnConnection const started = Date.now() const echoed = await new Promise<string>((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id) reject(new Error("Echo timed out")) }, 8000) const finish = () => { clearTimeout(timer) pending.delete(id) } pending.set(id, { resolve: (value) => { finish() resolve(value) }, reject: (error) => { finish() reject(error) }, }) try { ws.send(JSON.stringify({ id, text })) } catch (error) { finish() reject(error) } }) return { ok: true, text: echoed, connection, connectedAt: establishedAt, messageNumber, reused: messageNumber > 1, elapsedMs: Date.now() - started, }}
globalThis.onmessage = (event: MessageEvent) => { const { requestId, data } = event.data void (async () => { try { if (data?.command !== "send" || typeof data.text !== "string") throw new Error("Expected send with text") postMessage({ requestId, data: await send(data.text) }) } catch (error) { postMessage({ requestId, data: { ok: false, error: String(error) } }) } })()}export {}file.ts
import type { Mount } from "@eidos.space/plugin-sdk"
const mount: Mount = (ctx, root) => { const background = ctx.capabilities.background! const documentApi = ctx.capabilities.document! const title = document.createElement("h1") title.textContent = "WebSocket Workbench" const description = document.createElement("p") description.textContent = "Send this sample to ws://127.0.0.1:8787/echo. All open requests share one background connection." const preview = document.createElement("pre") const output = document.createElement("pre") const connectionCard = document.createElement("section") connectionCard.setAttribute("aria-live", "polite") connectionCard.style.cssText = "padding:20px;border:1px solid currentColor;border-radius:12px;margin:20px 0" const status = document.createElement("strong") status.style.cssText = "display:block;font-size:22px" status.textContent = "等待发送" const metrics = document.createElement("p") metrics.style.cssText = "font-size:28px;font-weight:600;margin:12px 0" metrics.textContent = "文件 A / 文件 B → 同一个后台 → 一个 WebSocket" const identity = document.createElement("div") identity.style.cssText = "font:12px/1.8 monospace;overflow-wrap:anywhere" identity.textContent = "发送后显示共享连接编号和本连接累计消息序号。" const hint = document.createElement("p") hint.textContent = "验证方法:发送此文件 → 关闭文件 → 打开另一个 .wsreq 并发送。编号不变、序号继续增加,就复用了连接。" connectionCard.append(status, metrics, identity, hint) const button = document.createElement("button") button.textContent = "Send sample" output.textContent = "Ready · no data sent" root.style.cssText = "padding:24px;max-width:880px;margin:auto;font:14px/1.6 system-ui" for (const element of [preview, output]) element.style.cssText = "white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border:1px solid currentColor;border-radius:8px" button.style.cssText = "padding:8px 16px;font:inherit;cursor:pointer" root.append(title, description, connectionCard, preview, button, output) void documentApi .read() .then((value) => { if (!ctx.signal.aborted) preview.textContent = value.text }) .catch((error: unknown) => { output.textContent = String(error) }) button.onclick = () => { button.disabled = true output.textContent = "Connecting / waiting for echo…" status.textContent = "正在发送…" void (async () => { try { const { text } = await documentApi.read() if (ctx.signal.aborted) return preview.textContent = text const response = await background.request({ command: "send", text }) if (!ctx.signal.aborted) { output.textContent = JSON.stringify(response, null, 2) if ( response && typeof response === "object" && !Array.isArray(response) && response.ok === true ) { status.textContent = response.reused ? "✓ 复用了已有 WebSocket" : "● 本次建立了新 WebSocket" metrics.textContent = `1 条连接 · 第 ${String(response.messageNumber)} 条消息` identity.textContent = `连接编号:${String(response.connection)}\n建立时间:${new Date(Number(response.connectedAt)).toLocaleTimeString()} · 本次耗时:${String(response.elapsedMs)} ms` identity.style.whiteSpace = "pre-wrap" hint.textContent = response.reused ? "本次发送没有新建连接。后台在文件关闭或切换后继续持有这条连接。" : "现在关闭此文件,打开另一份样本并发送:应显示“复用了已有 WebSocket”,消息序号变为 2。" } else { status.textContent = "发送失败" hint.textContent = "检查 Python 服务是否运行;再次点击发送可重试。下方保留错误详情。" } } } catch (error) { if (!ctx.signal.aborted) { status.textContent = "发送失败" output.textContent = String(error) } } finally { if (!ctx.signal.aborted) button.disabled = false } })() } // Disposing a view must not stop the shared background.}export default mountextension.ts
import type { Activate } from "@eidos.space/plugin-sdk"const activate: Activate = (ctx) => { ctx.capabilities.actions.register("stop", async ({ capabilities }) => { await capabilities.background!.stop() await capabilities.ui.notify("Shared WebSocket stopped") })}export default activateserver.py
"""Loopback-only demo echo server; Python standard library, no dependencies.
Supports the unfragmented text frames used by this plugin, plus ping/close.Not a general-purpose or production WebSocket server."""import base64import hashlibimport structfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Echo(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1"
def do_GET(self): if self.path != "/echo" or self.headers.get("Upgrade", "").lower() != "websocket": self.send_error(400, "Use WebSocket /echo") return key = self.headers.get("Sec-WebSocket-Key", "") accept = base64.b64encode(hashlib.sha1( (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() ).digest()).decode() self.send_response(101) self.send_header("Upgrade", "websocket") self.send_header("Connection", "Upgrade") self.send_header("Sec-WebSocket-Accept", accept) self.end_headers() self.close_connection = True print("CONNECTED", self.client_address, flush=True) self.connection.settimeout(600) try: while True: header = self.rfile.read(2) if len(header) != 2: break opcode, size = header[0] & 15, header[1] & 127 if not header[0] & 128 or not header[1] & 128 or opcode not in (1, 8, 9): break if size == 126: size = struct.unpack("!H", self.rfile.read(2))[0] elif size == 127: size = struct.unpack("!Q", self.rfile.read(8))[0] if size > 1024 * 1024: break mask = self.rfile.read(4) data = self.rfile.read(size) if len(mask) != 4 or len(data) != size: break data = bytes(value ^ mask[index % 4] for index, value in enumerate(data)) reply_opcode = 10 if opcode == 9 else opcode prefix = bytes([128 | reply_opcode, size]) if size < 126 else bytes([128 | reply_opcode, 126]) + struct.pack("!H", size) if size < 65536 else bytes([128 | reply_opcode, 127]) + struct.pack("!Q", size) self.wfile.write(prefix + data) self.wfile.flush() print("ECHO", self.client_address, size, "bytes", flush=True) if opcode == 8: break except (OSError, ValueError, struct.error): pass finally: print("DISCONNECTED", self.client_address, flush=True)
def server(port=8787): return ThreadingHTTPServer(("127.0.0.1", port), Echo)
if __name__ == "__main__": with server() as instance: print("Listening on ws://127.0.0.1:8787/echo", flush=True) try: instance.serve_forever() except KeyboardInterrupt: pass运行环境与限制
Section titled “运行环境与限制”模块运行在 Rust + QuickJS 进程中,提供 Worker 风格的消息、计时器、
fetch 和 WebSocket。它没有 DOM、Node 模块加载器、Electron、
文件系统 API 或 Eidos 数据能力。网络与设备访问需要明确的宿主授权。
这是能力隔离,不是操作系统级沙盒。
宿主每次请求都会检查 Space 授权,并以 500 ms 的间隔轮询。 每次运行的 JavaScript 堆上限为 64 MiB,每轮执行预算为 100 ms。 消息请求 30 秒超时,每个进程最多 64 个待回复请求, 整个应用最多 16 个 background。设备选择和首次启动授权最多等待五分钟; 授权等待不计入命令的普通执行预算。停止、撤销授权或关闭发起调用的页面/命令会取消 尚未完成的启动,之后确认也不会启动已取消的实例。启动成功后,关闭调用方不会停止后台。
消息与日志没有生命周期累计字节上限。单条输出最多 1 MiB;每个一秒窗口内消息最多 16 MiB,超过消息速率会终止运行。日志独立限制为每窗口 1 MiB,超额日志被丢弃, 不会因此终止运行。Node SDK 另对每条输出管道限制为每秒 32 MiB,并限制单条帧大小。 耗时操作应拆成有界步骤,并在步骤之间让出执行。
检查页面关闭重开、没有打开页面时执行命令、取消、拒绝授权、 停止重启、更新和切换 Space。 BLE 还应检查同名设备、不支持的服务、设备断开,以及替换设备。 确认未授权的设备 ID 和未声明的服务始终不可访问。
原生扫描需要可用的 BLE 适配器和操作系统权限; Linux 还需要 BlueZ 与 D-Bus。 自动化测试使用真实 QuickJS 进程和显式 mock BLE 后端, 不能作为真实设备或签名安装包权限验收的依据。 请在每个分发平台上单独验收。