@clipbus/plugin-sdk 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/API.md +641 -0
  2. package/LICENSE +21 -0
  3. package/README.md +466 -0
  4. package/SPECIFICATION.md +355 -0
  5. package/dist/dom/autoFit.d.ts +15 -0
  6. package/dist/dom/consolePatch.d.ts +1 -0
  7. package/dist/dom/index.cjs +211 -0
  8. package/dist/dom/index.d.cts +6 -0
  9. package/dist/dom/index.d.ts +6 -0
  10. package/dist/dom/index.js +188 -0
  11. package/dist/dom/textInputState.d.ts +1 -0
  12. package/dist/dom/topicAdapter.d.ts +30 -0
  13. package/dist/generated/INDEX.runtime.generated.d.ts +6 -0
  14. package/dist/generated/INDEX.ui.generated.d.ts +4 -0
  15. package/dist/generated/capabilityClients.generated.d.ts +199 -0
  16. package/dist/generated/data.generated.d.ts +193 -0
  17. package/dist/generated/hostClients.generated.d.ts +38 -0
  18. package/dist/generated/runtime.actionResult.generated.d.ts +28 -0
  19. package/dist/generated/runtime.definePlugin.generated.d.ts +16 -0
  20. package/dist/generated/runtime.handlers.generated.d.ts +20 -0
  21. package/dist/generated/runtime.host.generated.d.ts +34 -0
  22. package/dist/generated/topicSubscribers.generated.d.ts +32 -0
  23. package/dist/generated/ui.bootstrap.generated.d.ts +15 -0
  24. package/dist/generated/ui.clipbus.generated.d.ts +79 -0
  25. package/dist/generated/wireConstants.generated.d.ts +3 -0
  26. package/dist/internal/capabilities.d.ts +31 -0
  27. package/dist/internal/index.cjs +68 -0
  28. package/dist/internal/index.d.ts +1 -0
  29. package/dist/internal/internalConsole.d.ts +7 -0
  30. package/dist/internal/ipcBus.d.ts +48 -0
  31. package/dist/internal/runtimeInvokeClient.d.ts +3 -0
  32. package/dist/internal/topic.d.ts +20 -0
  33. package/dist/runtime/defineMessage.d.ts +6 -0
  34. package/dist/runtime/index.cjs +163 -0
  35. package/dist/runtime/index.d.cts +4 -0
  36. package/dist/runtime/index.d.ts +4 -0
  37. package/dist/runtime/index.js +132 -0
  38. package/dist/shared/defineMessage.d.ts +7 -0
  39. package/dist/ui/defineMessage.d.ts +7 -0
  40. package/dist/ui/index.cjs +362 -0
  41. package/dist/ui/index.d.cts +4 -0
  42. package/dist/ui/index.d.ts +4 -0
  43. package/dist/ui/index.js +339 -0
  44. package/docs/README.md +34 -0
  45. package/docs/authoring.md +288 -0
  46. package/docs/capability-detection.md +105 -0
  47. package/docs/concepts.md +80 -0
  48. package/docs/entry.md +137 -0
  49. package/docs/faq.md +65 -0
  50. package/docs/item-context.md +186 -0
  51. package/docs/manifest.md +149 -0
  52. package/docs/permissions.md +32 -0
  53. package/docs/rpc.md +84 -0
  54. package/package.json +76 -0
@@ -0,0 +1,339 @@
1
+ // src/generated/wireConstants.generated.ts
2
+ var STRUCTURED_ERROR_PREFIX = "__clipbus_structured_error__:";
3
+ var CAPABILITIES_GLOBAL = "__CLIPBUS_PLUGIN_CAPABILITIES__";
4
+ var CAPABILITY_UNSUPPORTED_ERROR_NAME = "PluginCapabilityUnsupported";
5
+
6
+ // src/internal/capabilities.ts
7
+ var CapabilityUnsupportedError = class extends Error {
8
+ capability;
9
+ constructor(capability) {
10
+ super(`Capability not supported by host: ${capability}`);
11
+ this.name = "CapabilityUnsupportedError";
12
+ this.capability = capability;
13
+ Object.setPrototypeOf(this, new.target.prototype);
14
+ }
15
+ };
16
+ function readInjectedCapabilities(global, key) {
17
+ const obj = global;
18
+ const raw = obj?.[key];
19
+ if (Array.isArray(raw?.capabilities)) {
20
+ return new Set(raw.capabilities);
21
+ }
22
+ return /* @__PURE__ */ new Set();
23
+ }
24
+ function createCapabilitiesApi(getSet) {
25
+ return {
26
+ has: (name) => getSet().has(name)
27
+ };
28
+ }
29
+ function mapToCapabilityError(err, unsupportedName) {
30
+ if (err.name === unsupportedName) {
31
+ const cap = err.data?.capability ?? "";
32
+ return new CapabilityUnsupportedError(cap);
33
+ }
34
+ const m = /^Unknown method:\s*(.+)$/.exec(err.message);
35
+ if (m) return new CapabilityUnsupportedError(m[1]);
36
+ return err;
37
+ }
38
+
39
+ // src/internal/runtimeInvokeClient.ts
40
+ var HANDLER_NAME = "clipbusPluginCall";
41
+ async function callRuntimeInvokeStrict(payload) {
42
+ const handler = globalThis.webkit?.messageHandlers?.[HANDLER_NAME];
43
+ if (!handler) {
44
+ throw new Error("runtime.invoke is only available inside a Clipbus plugin WebView");
45
+ }
46
+ const normalized = JSON.parse(JSON.stringify(payload));
47
+ let reply;
48
+ try {
49
+ reply = await handler.postMessage({ method: "runtime.invoke", payload: normalized });
50
+ } catch (err) {
51
+ throw parseReplyError(err);
52
+ }
53
+ return reply?.response;
54
+ }
55
+ function parseReplyError(raw) {
56
+ const message = extractMessage(raw);
57
+ if (message.startsWith(STRUCTURED_ERROR_PREFIX)) {
58
+ const json = message.slice(STRUCTURED_ERROR_PREFIX.length);
59
+ try {
60
+ const parsed = JSON.parse(json);
61
+ const err = new Error(typeof parsed.message === "string" ? parsed.message : "");
62
+ if (typeof parsed.name === "string" && parsed.name.length > 0) {
63
+ err.name = parsed.name;
64
+ }
65
+ if (parsed.data !== void 0) {
66
+ err.data = parsed.data;
67
+ }
68
+ return mapToCapabilityError(err, CAPABILITY_UNSUPPORTED_ERROR_NAME);
69
+ } catch {
70
+ return new Error(message);
71
+ }
72
+ }
73
+ return mapToCapabilityError(new Error(message), CAPABILITY_UNSUPPORTED_ERROR_NAME);
74
+ }
75
+ function extractMessage(raw) {
76
+ if (typeof raw === "string") return raw;
77
+ if (raw instanceof Error) return raw.message;
78
+ if (raw && typeof raw === "object" && "message" in raw) {
79
+ const m = raw.message;
80
+ if (typeof m === "string") return m;
81
+ }
82
+ return String(raw);
83
+ }
84
+
85
+ // src/generated/capabilityClients.generated.ts
86
+ var HANDLER_NAME2 = "clipbusPluginCall";
87
+ async function callPluginMethod(method, payload) {
88
+ const handler = globalThis.webkit?.messageHandlers?.[HANDLER_NAME2];
89
+ if (!handler) {
90
+ const err = new Error("clipbus." + method + " is only available inside a Clipbus plugin WebView");
91
+ err.name = "PluginHostBridgeUnavailable";
92
+ throw err;
93
+ }
94
+ const normalized = JSON.parse(JSON.stringify(payload));
95
+ let reply;
96
+ try {
97
+ reply = await handler.postMessage({ method, payload: normalized });
98
+ } catch (err) {
99
+ throw parseReplyError(err);
100
+ }
101
+ return reply;
102
+ }
103
+ var callItemReadAttachment = (payload) => callPluginMethod("item.readAttachment", payload);
104
+ var callAssetCurrentItemImageUrl = () => callPluginMethod("asset.currentItemImageUrl", {});
105
+ var callAssetPathReferenceImageUrl = (payload) => callPluginMethod("asset.pathReferenceImageUrl", payload);
106
+ var callClipboardCopyText = (payload) => callPluginMethod("clipboard.copyText", payload);
107
+ var callNavigationOpenUrl = (payload) => callPluginMethod("navigation.openUrl", payload);
108
+ var callNavigationRevealInFinder = (payload) => callPluginMethod("navigation.revealInFinder", payload);
109
+ var callNavigationOpenFilePath = (payload) => callPluginMethod("navigation.openFilePath", payload);
110
+ var callActionSetButtons = (payload) => callPluginMethod("action.setButtons", payload);
111
+ var callActionComplete = (payload) => callPluginMethod("action.complete", payload);
112
+ var callAttachmentRendererSetButtons = (payload) => callPluginMethod("attachmentRenderer.setButtons", payload);
113
+ var callWindowSetHeight = (payload) => callPluginMethod("window.setHeight", payload);
114
+ var callWindowAutoFit = () => callPluginMethod("window.autoFit", {});
115
+ var callInfoPanelOpen = (payload) => callPluginMethod("infoPanel.open", payload);
116
+ var callInfoPanelClose = (payload) => callPluginMethod("infoPanel.close", payload);
117
+ var callSettingsGet = (payload) => callPluginMethod("settings.get", payload);
118
+ var callSettingsGetAll = () => callPluginMethod("settings.getAll", {});
119
+ var callConsoleLog = (payload) => callPluginMethod("console.log", payload);
120
+ var callTextInputStateChanged = (payload) => callPluginMethod("textInput.stateChanged", payload);
121
+
122
+ // src/internal/topic.ts
123
+ function createTopic(initial) {
124
+ let value = initial;
125
+ const listeners = /* @__PURE__ */ new Set();
126
+ return {
127
+ current: () => value,
128
+ on(listener) {
129
+ listeners.add(listener);
130
+ return () => listeners.delete(listener);
131
+ },
132
+ set(next) {
133
+ value = next;
134
+ for (const l of listeners) {
135
+ try {
136
+ l(value);
137
+ } catch {
138
+ }
139
+ }
140
+ }
141
+ };
142
+ }
143
+ function createStream() {
144
+ const listeners = /* @__PURE__ */ new Set();
145
+ return {
146
+ on(listener) {
147
+ listeners.add(listener);
148
+ return () => listeners.delete(listener);
149
+ },
150
+ emit(event) {
151
+ for (const l of listeners) {
152
+ try {
153
+ l(event);
154
+ } catch {
155
+ }
156
+ }
157
+ }
158
+ };
159
+ }
160
+ function readWindowGlobal(key) {
161
+ if (typeof window === "undefined") return void 0;
162
+ return window[key];
163
+ }
164
+
165
+ // src/generated/topicSubscribers.generated.ts
166
+ function onContext(listener) {
167
+ const handler = (e) => listener(e.detail);
168
+ globalThis.addEventListener("clipbus-plugin-context", handler);
169
+ return () => globalThis.removeEventListener("clipbus-plugin-context", handler);
170
+ }
171
+ function onItem(listener) {
172
+ const handler = (e) => listener(e.detail);
173
+ globalThis.addEventListener("clipbus-plugin-item", handler);
174
+ return () => globalThis.removeEventListener("clipbus-plugin-item", handler);
175
+ }
176
+ function onAttachment(listener) {
177
+ const handler = (e) => listener(e.detail);
178
+ globalThis.addEventListener("clipbus-plugin-attachment", handler);
179
+ return () => globalThis.removeEventListener("clipbus-plugin-attachment", handler);
180
+ }
181
+ function onDraft(listener) {
182
+ const handler = (e) => listener(e.detail);
183
+ globalThis.addEventListener("clipbus-plugin-draft", handler);
184
+ return () => globalThis.removeEventListener("clipbus-plugin-draft", handler);
185
+ }
186
+ function onTheme(listener) {
187
+ const handler = (e) => listener(e.detail);
188
+ globalThis.addEventListener("clipbus-plugin-theme", handler);
189
+ return () => globalThis.removeEventListener("clipbus-plugin-theme", handler);
190
+ }
191
+ function onAttachmentHostInvoke(listener) {
192
+ const handler = (e) => listener(e.detail);
193
+ globalThis.addEventListener("clipbus-plugin-attachment-host-invoke", handler);
194
+ return () => globalThis.removeEventListener("clipbus-plugin-attachment-host-invoke", handler);
195
+ }
196
+ function onActionHostInvoke(listener) {
197
+ const handler = (e) => listener(e.detail);
198
+ globalThis.addEventListener("clipbus-plugin-action-host-invoke", handler);
199
+ return () => globalThis.removeEventListener("clipbus-plugin-action-host-invoke", handler);
200
+ }
201
+ function onInfoPanelOnAction(listener) {
202
+ const handler = (e) => listener(e.detail);
203
+ globalThis.addEventListener("clipbus-plugin-info-panel-on-action", handler);
204
+ return () => globalThis.removeEventListener("clipbus-plugin-info-panel-on-action", handler);
205
+ }
206
+ function onInfoPanelOnClose(listener) {
207
+ const handler = (e) => listener(e.detail);
208
+ globalThis.addEventListener("clipbus-plugin-info-panel-on-close", handler);
209
+ return () => globalThis.removeEventListener("clipbus-plugin-info-panel-on-close", handler);
210
+ }
211
+
212
+ // src/generated/ui.bootstrap.generated.ts
213
+ var _pluginContextTopic = createTopic(readWindowGlobal("__CLIPBUS_PLUGIN_CONTEXT__"));
214
+ onContext((p) => _pluginContextTopic.set(p));
215
+ var _itemTopic = createTopic(readWindowGlobal("__CLIPBUS_PLUGIN_ITEM__"));
216
+ onItem((p) => _itemTopic.set(p));
217
+ var _itemAttachmentTopic = createTopic(readWindowGlobal("__CLIPBUS_PLUGIN_ATTACHMENT__"));
218
+ onAttachment((p) => _itemAttachmentTopic.set(p));
219
+ var _actionDraftTopic = createTopic(readWindowGlobal("__CLIPBUS_PLUGIN_DRAFT__"));
220
+ onDraft((p) => _actionDraftTopic.set(p));
221
+ var _themeTopic = createTopic(readWindowGlobal("__CLIPBUS_PLUGIN_THEME__"));
222
+ onTheme((p) => _themeTopic.set(p));
223
+ var _attachmentRendererOnHostInvokeStream = createStream();
224
+ onAttachmentHostInvoke((p) => _attachmentRendererOnHostInvokeStream.emit(p));
225
+ var _actionOnHostInvokeStream = createStream();
226
+ onActionHostInvoke((p) => _actionOnHostInvokeStream.emit(p));
227
+ var _infoPanelOnActionStream = createStream();
228
+ onInfoPanelOnAction((p) => _infoPanelOnActionStream.emit(p));
229
+ var _infoPanelOnCloseStream = createStream();
230
+ onInfoPanelOnClose((p) => _infoPanelOnCloseStream.emit(p));
231
+ var PluginContextError = class extends Error {
232
+ constructor(message) {
233
+ super(message);
234
+ this.name = "PluginContextError";
235
+ }
236
+ };
237
+ function guardContext(expected, run) {
238
+ return (...args) => {
239
+ const current = _pluginContextTopic.current()?.mode;
240
+ if (current !== expected) {
241
+ throw new PluginContextError(
242
+ `This verb is not available in the current plugin context (expected: ${expected}, got: ${current || "unknown"})`
243
+ );
244
+ }
245
+ return run(...args);
246
+ };
247
+ }
248
+
249
+ // src/generated/ui.clipbus.generated.ts
250
+ var clipbus = {
251
+ capabilities: createCapabilitiesApi(() => readInjectedCapabilities(globalThis, CAPABILITIES_GLOBAL)),
252
+ runtime: {
253
+ invoke: (payload) => callRuntimeInvokeStrict(payload)
254
+ },
255
+ item: {
256
+ current: () => _itemTopic.current(),
257
+ on: (fn) => _itemTopic.on(fn),
258
+ readAttachment: (payload) => callItemReadAttachment(payload),
259
+ attachment: {
260
+ current: () => _itemAttachmentTopic.current(),
261
+ on: (fn) => _itemAttachmentTopic.on(fn)
262
+ }
263
+ },
264
+ asset: {
265
+ currentItemImageUrl: () => callAssetCurrentItemImageUrl(),
266
+ pathReferenceImageUrl: (payload) => callAssetPathReferenceImageUrl(payload)
267
+ },
268
+ clipboard: {
269
+ copyText: (payload) => callClipboardCopyText(payload)
270
+ },
271
+ navigation: {
272
+ openUrl: (payload) => callNavigationOpenUrl(payload),
273
+ revealInFinder: (payload) => callNavigationRevealInFinder(payload),
274
+ openFilePath: (payload) => callNavigationOpenFilePath(payload)
275
+ },
276
+ action: {
277
+ setButtons: guardContext("action", (payload) => callActionSetButtons(payload)),
278
+ complete: guardContext("action", (payload) => callActionComplete(payload)),
279
+ draft: {
280
+ current: () => _actionDraftTopic.current(),
281
+ on: (fn) => _actionDraftTopic.on(fn)
282
+ },
283
+ onHostInvoke: {
284
+ on: (fn) => _actionOnHostInvokeStream.on(fn)
285
+ }
286
+ },
287
+ attachmentRenderer: {
288
+ setButtons: guardContext("attachmentRenderer", (payload) => callAttachmentRendererSetButtons(payload)),
289
+ onHostInvoke: {
290
+ on: (fn) => _attachmentRendererOnHostInvokeStream.on(fn)
291
+ }
292
+ },
293
+ window: {
294
+ setHeight: (payload) => callWindowSetHeight(payload),
295
+ autoFit: () => callWindowAutoFit()
296
+ },
297
+ infoPanel: {
298
+ open: (payload) => callInfoPanelOpen(payload),
299
+ close: (payload) => callInfoPanelClose(payload),
300
+ onAction: {
301
+ on: (fn) => _infoPanelOnActionStream.on(fn)
302
+ },
303
+ onClose: {
304
+ on: (fn) => _infoPanelOnCloseStream.on(fn)
305
+ }
306
+ },
307
+ settings: {
308
+ get: (payload) => callSettingsGet(payload),
309
+ getAll: () => callSettingsGetAll()
310
+ },
311
+ console: {
312
+ log: (payload) => callConsoleLog(payload)
313
+ },
314
+ textInput: {
315
+ stateChanged: (payload) => callTextInputStateChanged(payload)
316
+ },
317
+ pluginContext: {
318
+ current: () => _pluginContextTopic.current(),
319
+ on: (fn) => _pluginContextTopic.on(fn)
320
+ },
321
+ theme: {
322
+ current: () => _themeTopic.current(),
323
+ on: (fn) => _themeTopic.on(fn)
324
+ }
325
+ };
326
+
327
+ // src/ui/defineMessage.ts
328
+ function defineMessage(key) {
329
+ return {
330
+ key,
331
+ invoke: async (payload, options) => await clipbus.runtime.invoke({ key, payload, timeoutMs: options?.timeoutMs })
332
+ };
333
+ }
334
+ export {
335
+ CapabilityUnsupportedError,
336
+ PluginContextError,
337
+ clipbus,
338
+ defineMessage
339
+ };
package/docs/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @clipbus/plugin-sdk 开发文档
2
+
3
+ 这是 Clipbus 三方插件开发的**权威教程文档**,随 [`@clipbus/plugin-sdk`](https://www.npmjs.com/package/@clipbus/plugin-sdk) 一起发布——你安装/升级 SDK 时,这份文档就是与该版本匹配的最新版。
4
+
5
+ > **capability 真相源是 [`../API.md`](../API.md)**(由 `protocol/plugin/src/catalog.ts` codegen 直出,保证与代码一致)。教程描述模式与约定,API.md 描述每个符号的精确 payload / response / wire path / context。**有冲突时以 `API.md` 为准。**
6
+
7
+ ## 文档地图
8
+
9
+ 按推荐阅读顺序:
10
+
11
+ | 文档 | 内容 |
12
+ |---|---|
13
+ | [concepts.md](./concepts.md) | 架构:两个执行上下文(runtime / WebView)、三类产物(detector / renderer / action)、数据流、Host→WebView 状态形状 |
14
+ | [manifest.md](./manifest.md) | `manifest.json` 规范:顶层字段、`plugin` / `install` / `runtime` / `permissions` / `attachmentRenderers[]` / `detectors[]` / `actions[]` |
15
+ | [entry.md](./entry.md) | SDK 入口与初始化:runtime 入口、UI 入口、context guards、`definePlugin` |
16
+ | [authoring.md](./authoring.md) | Detector / Renderer / Action 的开发约定与生命周期 |
17
+ | [item-context.md](./item-context.md) | 入参形状(`PluginContentEnvelope`)、图片懒副本、Action 返回图片、在 WebView 展示本地图片(`clipbus-asset://`) |
18
+ | [rpc.md](./rpc.md) | UI ↔ Runtime RPC:`clipbus.runtime.invoke`、`defineMessage` 共享契约、超时与错误 |
19
+ | [permissions.md](./permissions.md) | 权限模型:manifest 声明、受门控的 verb、最小权限原则 |
20
+ | [capability-detection.md](./capability-detection.md) | 能力检测:`has()` 门控、UI/Node 侧 catch 差异、老宿主降级 |
21
+ | [faq.md](./faq.md) | 常见坑点 Q&A |
22
+
23
+ ## 同包参考
24
+
25
+ | 文件 | 用途 |
26
+ |---|---|
27
+ | [`../API.md`](../API.md) | **capability 真相源**(generated)——capability / host event / Node IPC / 命名类型全表与精确签名 |
28
+ | [`../README.md`](../README.md) | SDK 公共符号速查(导出、runtime 入口、UI 入口) |
29
+ | [`../SPECIFICATION.md`](../SPECIFICATION.md) | SDK 形状规则、命名约定、扩展 capability 的流程 |
30
+
31
+ ## 给 AI / 工具的说明
32
+
33
+ - 这份文档目录随 `npm update @clipbus/plugin-sdk` 刷新;它**始终对应你当前安装的 SDK 版本**。
34
+ - 任何脚手架工程(template)内的代码与说明都是**示例**,可能滞后;**与本文档冲突时以本文档为准**,capability 签名以 [`../API.md`](../API.md) 为准。
@@ -0,0 +1,288 @@
1
+ # Detector / Renderer / Action 开发约定
2
+
3
+ > 本文档属于 @clipbus/plugin-sdk 开发文档 · 返回[文档地图](./README.md) · capability 真相源见 [API.md](../API.md)
4
+
5
+ ---
6
+
7
+ ## Detector
8
+
9
+ 入参 `PluginDetectorInput`(完整字段见 [API.md](../API.md)):
10
+
11
+ ```ts
12
+ input.item // PluginClipboardItem: {id, type, tags, sourceAppID}
13
+ input.content // PluginContentEnvelope(字段含义见 [item-context.md](./item-context.md))
14
+ input.attachments // PluginAttachmentRef[]:当前 item 已有的附件引用
15
+ ```
16
+
17
+ **返回值是 artifact 数组本身**(不是 `{artifacts}` 包装):
18
+
19
+ ```ts
20
+ import type { PluginDetectorHandler, PluginDetectorArtifact } from '@clipbus/plugin-sdk/runtime';
21
+
22
+ const detector: PluginDetectorHandler = {
23
+ async detect(input, ctx) {
24
+ if (input.content.kind !== 'text') return [];
25
+
26
+ return [
27
+ {
28
+ attachmentType: 'plugin.example.sample.card', // 必须已在 manifest 声明
29
+ attachmentKey: `card-${input.item.id}`, // 稳定 key,不允许空字符串
30
+ payloadJson: JSON.stringify({ text: input.content.text }),
31
+ attachmentSyncScope: 'syncable', // 'syncable' | 'local_only'
32
+ searchProjection: { // 可选
33
+ scope: 'sample',
34
+ searchText: input.content.text.slice(0, 80),
35
+ label: null,
36
+ },
37
+ },
38
+ ] satisfies PluginDetectorArtifact[];
39
+ },
40
+ };
41
+ ```
42
+
43
+ **约定:**
44
+
45
+ - 未命中时返回 `[]`,不要返回半成品 artifact
46
+ - Detector 内不应执行宿主 mutation(标签写入等);那是 action 的职责
47
+ - `attachmentType` 必须出自 manifest 的 `detectors[].attachmentTypes`(manifest 结构见 [manifest.md](./manifest.md))
48
+
49
+ ---
50
+
51
+ ## Attachment Renderer
52
+
53
+ Runtime 入口**只有** `resolveAttachment`。所有按钮副作用、用户交互都在 UI 侧完成。
54
+
55
+ 入参 `PluginResolveAttachmentInput`:
56
+
57
+ ```ts
58
+ input.item // PluginClipboardItem
59
+ input.content // PluginContentEnvelope
60
+ input.attachments // PluginAttachmentRef[]
61
+ input.attachment // PluginAttachmentEntry:当前要渲染的附件
62
+ .historyID
63
+ .owner
64
+ .attachmentType
65
+ .attachmentKey
66
+ .payloadJson // 完整 JSON 字符串;插件自行解析
67
+ ```
68
+
69
+ 返回值 `PluginAttachmentResolveResult`:
70
+
71
+ ```ts
72
+ return {
73
+ displayName: 'Sample Card', // string | undefined;建议始终返回
74
+ tintHex: '#2563EB', // string | undefined;卡片强调色
75
+ shouldDisplay: true, // boolean | undefined(默认 true)
76
+ buttons: [ // 可选首屏 seed
77
+ { id: 'copy-json', title: 'Copy JSON', isEnabled: true },
78
+ ],
79
+ };
80
+ ```
81
+
82
+ **`shouldDisplay` 语义:** 宿主在 WebView 启动**之前**读取它。
83
+
84
+ - `true` / 省略 — 正常进入渲染队列
85
+ - `false` — 宿主跳过该 attachment,**不**分配卡片位、**不**启动 WebView
86
+
87
+ 典型用法:payload 解析失败时静默退出,避免 UI 报错:
88
+
89
+ ```ts
90
+ async resolveAttachment(input, ctx) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(input.attachment.payloadJson);
94
+ } catch {
95
+ return { shouldDisplay: false };
96
+ }
97
+ if (!parsed.kind) return { shouldDisplay: false };
98
+ return {
99
+ displayName: parsed.title ?? 'Sample Card',
100
+ buttons: [{ id: 'copy-json', title: 'Copy JSON', isEnabled: true }],
101
+ };
102
+ }
103
+ ```
104
+
105
+ **按钮 seed 与 UI 覆盖:** `resolveAttachment.buttons` 是首屏 seed(供宿主在 WebView 启动前渲染 native chrome)。UI 一旦调用 `clipbus.attachmentRenderer.setButtons([...])`,列表被**整体替换**,seed 永远不再生效。**没有差分更新**——UI 每次推送完整列表。
106
+
107
+ ### Renderer UI 典型生命周期
108
+
109
+ ```ts
110
+ import { clipbus } from '@clipbus/plugin-sdk/ui';
111
+
112
+ const attachment = clipbus.item.attachment.current();
113
+ if (attachment) {
114
+ const payload = JSON.parse(attachment.payloadJson);
115
+ renderInitial(payload);
116
+ }
117
+
118
+ const unsub = clipbus.item.attachment.on(newAttachment => {
119
+ renderInitial(JSON.parse(newAttachment.payloadJson));
120
+ });
121
+
122
+ // 启用自适应高度(manifest height: { min, max } 时必须调用);
123
+ // 实际像素由 UI 之后通过 clipbus.window.setHeight({ height }) 持续上报,
124
+ // 或使用 @clipbus/plugin-sdk/dom 的 autoFit 辅助函数自动做 ResizeObserver。
125
+ await clipbus.window.autoFit();
126
+
127
+ // 按需更新按钮(覆盖 seed)
128
+ await clipbus.attachmentRenderer.setButtons({
129
+ buttons: [
130
+ { id: 'copy-json', title: 'Copy JSON', isEnabled: true },
131
+ { id: 'copy-raw', title: 'Copy Raw', isEnabled: false },
132
+ ],
133
+ });
134
+
135
+ // 订阅宿主派发的按钮点击
136
+ const unsubClick = clipbus.attachmentRenderer.onHostInvoke.on(({ buttonID }) => {
137
+ if (buttonID === 'copy-json') {
138
+ clipbus.clipboard.copyText({ text: JSON.stringify(payload, null, 2) });
139
+ }
140
+ });
141
+ ```
142
+
143
+ UI 端完整 topic/verb 列表见 [API.md](../API.md)。
144
+
145
+ ---
146
+
147
+ ## Action
148
+
149
+ 按 lifecycle 分叉为两种形态,runtime 入口不同:
150
+
151
+ | lifecycle | runtime 入口 | UI 入口 |
152
+ |---|---|---|
153
+ | `auto-run` | `runAutoAction(input, ctx)` | 无 |
154
+ | `draft` | `resolveSession(input, ctx)`(可选) | 必填 `uiEntry`;UI 自管表单状态,最终调 `clipbus.action.complete(...)` 提交 |
155
+
156
+ ### resolveSession 入参
157
+
158
+ ```ts
159
+ input.item // PluginClipboardItem
160
+ input.content // PluginContentEnvelope
161
+ input.attachments // PluginAttachmentRef[]
162
+ ```
163
+
164
+ ### resolveSession 返回值
165
+
166
+ ```ts
167
+ return {
168
+ displayName: 'Apply Metadata', // string | undefined
169
+ buttons: [ // 可选首屏 seed
170
+ { id: 'apply', title: 'Apply', isEnabled: true },
171
+ ],
172
+ defaultButtonID: 'apply', // string | undefined
173
+ initialDraft: { subject: '', note: '' } // Record<string, JSONValue>
174
+ };
175
+ ```
176
+
177
+ `initialDraft` 由宿主存入 draft topic,UI 可通过 `clipbus.action.draft.current()` 读到。`auto-run` lifecycle 可省略 `resolveSession`,宿主按空 session 处理。
178
+
179
+ ### runAutoAction 入参
180
+
181
+ ```ts
182
+ input.item // PluginClipboardItem
183
+ input.content // PluginContentEnvelope
184
+ input.attachments // PluginAttachmentRef[]
185
+ ```
186
+
187
+ ### runAutoAction 返回值(用 `actionResult`)
188
+
189
+ ```ts
190
+ const { actionResult } = require('@clipbus/plugin-sdk/runtime');
191
+
192
+ // text 结果
193
+ actionResult.text('hello world', { userMessage: 'Copied' });
194
+ // → { result: { resultKind: 'text', text: 'hello world' }, userMessage: 'Copied' }
195
+
196
+ // image 结果(注意:第一个参数是 imageTempPath 字符串,不是对象)
197
+ actionResult.image(imageTempPath, { imageFormatHint: 'png', userMessage: 'Saved' });
198
+
199
+ // 仅副作用,无输出
200
+ actionResult.none({ userMessage: 'Applied' });
201
+ ```
202
+
203
+ 签名:
204
+
205
+ ```ts
206
+ actionResult.text(text: string, options?: { userMessage?: string })
207
+ actionResult.image(imageTempPath: string, options?: { imageFormatHint?: string; userMessage?: string })
208
+ actionResult.none(options?: { userMessage?: string })
209
+ ```
210
+
211
+ ### Draft Action UI 生命周期(强类型)
212
+
213
+ Draft 是**只读 OptionalTopic**:UI 通过 `current()` / `on()` 读宿主的 `initialDraft`。**SDK 不暴露 `draft.update()`**——UI 自管表单本地状态,最终通过 `clipbus.action.complete(...)` 提交结果。如果 runtime 端需要参与(例如生成图片),通过 `clipbus.runtime.invoke(...)` 桥接(RPC 机制详见 [rpc.md](./rpc.md))。
214
+
215
+ ```ts
216
+ import { clipbus } from '@clipbus/plugin-sdk/ui';
217
+
218
+ interface MyDraft { subject: string; note: string; }
219
+
220
+ // 读 initialDraft 作为表单初始状态
221
+ const initial = clipbus.action.draft.current() as MyDraft | undefined;
222
+ const form = reactive({ subject: initial?.subject ?? '', note: initial?.note ?? '' });
223
+
224
+ // 订阅 host 推送的后续更新(罕见;通常 initialDraft 之后就由 UI 接管)
225
+ clipbus.action.draft.on(next => {
226
+ Object.assign(form, next);
227
+ });
228
+
229
+ // 订阅按钮点击
230
+ clipbus.action.onHostInvoke.on(({ buttonID }) => {
231
+ if (buttonID === 'apply') handleApply();
232
+ });
233
+
234
+ // 按需推送按钮列表(启用/禁用通过完整列表的 isEnabled 表达)
235
+ await clipbus.action.setButtons({
236
+ buttons: [
237
+ { id: 'apply', title: 'Apply', isEnabled: form.subject.length > 0 },
238
+ { id: 'cancel', title: 'Cancel', isEnabled: true },
239
+ ],
240
+ });
241
+
242
+ async function handleApply() {
243
+ await clipbus.action.complete({
244
+ result: { resultKind: 'text', text: form.subject },
245
+ userMessage: 'Applied',
246
+ });
247
+ }
248
+ ```
249
+
250
+ ### Draft Action:UI 需要 runtime 协助时
251
+
252
+ `clipbus.action.complete` 接收 `{result: {resultKind: 'text' | 'image' | 'none', …}}`。**image 结果的 `imageTempPath` 必须由 runtime 端通过 `host.action.allocateImageTempPath` 分配**(UI 端没有这个能力),所以图片类 draft 的典型流程是:
253
+
254
+ ```ts
255
+ // shared/contracts.ts —— 两端共享类型契约
256
+ import { defineMessage } from '@clipbus/plugin-sdk/runtime'; // 或 /ui,类型相同
257
+ export const GenerateImage = defineMessage<{ prompt: string }, { imageTempPath: string }>('generate-image');
258
+
259
+ // runtime/index.ts
260
+ const { definePlugin, host } = require('@clipbus/plugin-sdk/runtime');
261
+ const fs = require('node:fs/promises');
262
+ const { GenerateImage } = require('../shared/contracts');
263
+
264
+ module.exports = definePlugin({
265
+ messageHandlers: Object.fromEntries([
266
+ GenerateImage.handle(async (req, ctx) => {
267
+ const { path } = await ctx.host.action.allocateImageTempPath({ formatHint: 'png' });
268
+ await fs.writeFile(path, await generateImage(req.prompt));
269
+ return { imageTempPath: path };
270
+ }),
271
+ ]),
272
+ });
273
+
274
+ // UI 端
275
+ import { defineMessage, clipbus } from '@clipbus/plugin-sdk/ui';
276
+ const GenerateImage = defineMessage<{ prompt: string }, { imageTempPath: string }>('generate-image');
277
+
278
+ async function handleApplyImage(prompt: string) {
279
+ const { imageTempPath } = await GenerateImage.invoke({ prompt }, { timeoutMs: 60_000 });
280
+ await clipbus.action.complete({
281
+ result: { resultKind: 'image', imageTempPath, imageFormatHint: 'png' },
282
+ });
283
+ }
284
+ ```
285
+
286
+ > **不要**尝试通过 `runtime.invoke` 把图片字节传回 UI。postMessage 经 base64 + JSON 双重序列化,几 MB 图片就会卡顿数百 ms。让 runtime 直接写文件、只把路径返回给 UI。
287
+
288
+ RPC 机制(`defineMessage` / `messageHandlers`)的完整说明见 [rpc.md](./rpc.md)。入参结构(`PluginContentEnvelope`、`PluginClipboardItem`)详见 [item-context.md](./item-context.md)。