跳转到内容

插件后台实例

Lite

插件的 View 和 action 都有明确的结束时机:View 在取消挂载后销毁, action 在执行结束后销毁。它们适合界面交互和一次性操作,无法承载需要在这些入口结束后继续运行的逻辑。

Background 补上了这项能力:它为插件提供独立的后台运行环境, 启动后可以持续运行,不随 View 卸载或 action 执行结束而销毁,直到被停止。

因此,插件可以把需要长期维护的状态、WebSocket 或蓝牙连接放在 background 中, 由页面、文件视图和命令通过消息访问。关闭视图后连接仍可保留, 再次打开视图时也可以读取后台的当前状态。

例如,一个蓝牙电量插件可以由 Cmd+K 选择设备,让 background 负责连接和读取电量, 再由页面显示结果。页面负责交互,background 负责维护设备状态,两者各有自己的生命周期。 插件也可以只提供命令,不提供页面。

在同一窗口、同一 Space 内,一个插件最多有一个运行中的 background。 该插件的所有调用者都与这个实例通信,不会因打开另一个文件或执行另一条命令而创建副本。 不同窗口、不同 Space 或不同插件的实例相互独立。

页面 View ─────┐
文件 View A ──┤ 插件的 background
文件 View B ──┼─ 消息 → 内存状态、WebSocket、蓝牙连接
Cmd+K 命令 ───┘ 独立于各个调用者存活

实例的内存状态在停止或崩溃后丢失。需要跨运行保存的数据由插件主动写入私有存储; 宿主不自动保存内存状态,也不提供自动恢复或闹钟唤醒。

一个实际用途是记录文件打开次数和最近打开时间。页面只负责查看和搜索, 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() 可检查运行状态,不会触发启动或授权。

最低插件 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 的订阅随页面关闭而释放,后台继续运行。 订阅属于某一次运行:停止、替换设备或更新后,需要重新订阅。

创建一个目录,放入以下四个文件。这里的代码直接来自仓库中可运行的 packages/plugin-sandbox/examples/lite-background 示例。

plugin.json
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
background.ts
// Runs in QuickJS. No DOM, Node, Electron or Eidos filesystem access.
let count = 0
globalThis.onmessage = (event: MessageEvent) => {
const { requestId, data } = event.data
if (data?.command === "increment") count++
postMessage({ requestId, data: { count } })
postMessage({ type: "count", count })
}
export {}
page.ts
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 mount
extension.ts
extension.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 中:

  1. 打开测试 Space,进入 Plugin Manager → Load development source…。
  2. 选择该目录的 plugin.json,并启用插件。
  3. 从导航打开 Background Counter,确认启动。
  4. 点击 Increment,然后关闭页面。
  5. 在 Cmd+K 中执行 Increment background counter。
  6. 重新打开页面,计数包含命令刚才的累加。
  7. 执行 Stop background counter。下次启动会重新确认权限, 并从新的内存状态开始。

从 Eidos 仓库运行时,在仓库根目录执行 pnpm dev:eidos-lite, 它也会准备原生 sandbox 可执行文件。 如果已安装的工具尚未支持这项能力,应使用当前源码构建的插件工具和 SDK。

在 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。

准备一个提供 Battery Service(0x180F)和 Battery Level(0x2A19) 的 BLE 外设。这个示例读取电量,并在设备支持时订阅电量通知。 BLE GATT 不负责将音频输出到蓝牙音箱。

创建另一个插件目录,放入以下五个文件。它们来自可运行的 packages/plugin-sandbox/examples/lite-bluetooth 示例。 本地类型文件描述后台使用的 Web Bluetooth 风格 API 子集。

plugin.json
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
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 = null
let battery: Characteristic | null = null
let percent: number | null = null
let phase = "idle"
let failure: string | null = null
let failedAt: string | null = null
const 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
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
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 mount
extension.ts
extension.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
  1. 在插件管理器加载该目录的 plugin.json,并启用插件。
  2. 打开 Bluetooth Battery → 选择设备, 或在 Cmd+K 执行 蓝牙电量:选择并连接设备。
  3. Lite 设备选择弹窗打开后自动扫描;如果操作系统询问蓝牙权限,再进行系统授权。点击设备行选中,再点击“连接”授权该设备。可重新扫描或随时取消。 将外设置于广播状态。
  4. 点击“连接”只完成所选设备的授权,实际连接结果由页面持续展示。
  5. Lite 用该设备的授权启动新后台。页面或 action 发送连接消息, 后台调用 getDevices() 和 gatt.connect()。
  6. 页面依次显示连接设备、检查电量服务和读取电量,读取成功后才显示“已连接”和百分比。 失败时保留设备和错误详情,可重试或换设备;连接中可取消并停止后台。 断线后隐藏旧电量并提示重连。关闭页面。在 Cmd+K 执行 蓝牙电量:查看连接与电量, 检查仍保持的连接。重新打开页面后,点击 恢复后台状态 订阅当前运行。
  7. 执行 蓝牙电量:断开并停止后台,结束进程并撤销设备授权。

示例使用 "bluetooth": true,允许读取、写入和订阅用户选中设备的 GATT 特征。 代码仍只读取电量并订阅通知,设备必须实际提供 Battery Service。 插件不能在 manifest 中指定设备 ID。后台的 navigator.bluetooth.getDevices() 只返回选中的设备;后台不能自行打开选择器, navigator.bluetooth.requestDevice() 在其中不可用。

服务白名单是可选限制。例如仅访问电量服务:

{ "bluetooth": { "services": [{ "uuid": "battery_service" }] } }

省略操作字段时,允许该服务下设备支持的读取、写入和通知。 如需进一步收窄,显式声明 "read": true, "notify": true; 只要声明了任意操作字段,未声明的操作就不允许。设备自身不支持的操作仍会失败。

取消选择保留原运行。确认设备会替换运行、清空内存状态并断开旧设备, 其他页面需重新订阅。如果新连接失败,不会自动恢复旧连接。 名称和广播信息不是设备身份的密码学证明,广播也不保证某个服务实际可用。

授权只针对本次运行,不会持久化。普通的 start() 永远不授予蓝牙权限。 设备断开会触发 gattserverdisconnected,示例会报告断开,不会悄悄重连。

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.py
EIDOS_LITE_DEV_BACKGROUND_ORIGIN=ws://127.0.0.1:8787 pnpm dev:eidos-lite

Python 只用标准库,仅监听本机回环地址。打开文件不发送内容;点击发送才读取当前文档文本,不发送路径。 开发授权必须匹配字面量 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
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
background.ts
const endpoint = "ws://127.0.0.1:8787/echo"
let socket: WebSocket | undefined
let connecting: Promise<WebSocket> | undefined
let sequence = 0
let connections = 0
let connectedAt = 0
let sentOnConnection = 0
const 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
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 mount
extension.ts
extension.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 activate
server.py
server.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 base64
import hashlib
import struct
from 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

模块运行在 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 后端, 不能作为真实设备或签名安装包权限验收的依据。 请在每个分发平台上单独验收。

按开发与分发检查和打包插件。 Manifest 字段与方法定义见 API 参考。