@mearl/client 2.17.1 → 2.18.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.
- package/README.md +54 -1
- package/dist/cli.js +2 -1
- package/dist/generated-browser-action-protocol.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/plugin.d.ts +11 -3
- package/dist/plugin.js +44 -7
- package/dist/pluginPageBundle.d.ts +8 -0
- package/dist/pluginPageBundle.js +420 -0
- package/dist/pluginRegistry.d.ts +39 -0
- package/dist/pluginRegistry.js +261 -1
- package/dist/pythonPlugin.d.ts +1 -0
- package/dist/pythonPlugin.js +6 -0
- package/dist/socket.js +8 -13
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -149,7 +149,7 @@ mearl yuque --version
|
|
|
149
149
|
Provider 与插件仍可独立安装、更新和卸载,帮助聚合不会启动 Provider 或执行业务命令。
|
|
150
150
|
|
|
151
151
|
目录默认使用 `scripts/main.mjs` 或 `scripts/main.py`;两者同时存在时使用 JavaScript 入口。JavaScript 入口默认导出 `register(app)`,Python 入口导出 `register(app)` 函数;两种入口都可声明简短的 `name` 和 `description`,并通过 `app.command(signature, handler, description?)`
|
|
152
|
-
注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。Python 的 `app.invoke` 是同步调用,handler 可以是普通函数或 `async def`。Python 插件要求 `python3 >= 3.9`,可用 `MEARL_PYTHON` 指定解释器路径;Mearl 不安装 Python 或执行 `pip install`。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
|
|
152
|
+
注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。JavaScript 插件还可传入 `{ message, data }`,让工具页通过可 JSON 序列化的 `data` 获取稳定的阶段、对象 ID 等机器信息;CLI 仍只显示 `message`。Python 的 `app.invoke` 是同步调用,handler 可以是普通函数或 `async def`。Python 插件要求 `python3 >= 3.9`,可用 `MEARL_PYTHON` 指定解释器路径;Mearl 不安装 Python 或执行 `pip install`。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
|
|
153
153
|
`--verbose` 时才把进度写入 stderr。handler 返回文本或 JSON,宿主统一处理 `--output`、连接和错误。写命令在用户调用后直接执行,业务模块负责目标、状态、幂等和结果校验。
|
|
154
154
|
自动注册默认以 skill 安装目录名作为命令名,也可在入口声明 `name` 指定短名称。
|
|
155
155
|
setup 会补齐缺失或低于自身版本的 client、native-host;安装参数透传给 Ali Skills,并固定全局共享安装。
|
|
@@ -186,6 +186,59 @@ MCP Server 通过固定工具 `mearl_plugin` 接受同样的 `plugin`、`command
|
|
|
186
186
|
卸载清理命令。其他错误按实际原因处理。
|
|
187
187
|
编写规范见 [Mearl Skill Creator 的 Skill 插件规范](../skills/mearl-skill-creator/references/cli-plugin.md)。
|
|
188
188
|
|
|
189
|
+
插件可选导出 `toolbox`,注册后会出现在扩展 Popup 的工具箱中。页面资源放在 Skill 目录内,注册时由
|
|
190
|
+
Mearl 将入口 HTML 引用的本地 CSS、JavaScript 和图片内联为不可变页面包,不需要启动 HTTP 服务,
|
|
191
|
+
也不要求普通 HTML/CSS/JS 页面自行构建:
|
|
192
|
+
|
|
193
|
+
```javascript
|
|
194
|
+
export const toolbox = {
|
|
195
|
+
version: 1,
|
|
196
|
+
title: '迭代管理',
|
|
197
|
+
description: '查看多个仓库的改动与发布状态',
|
|
198
|
+
icon: 'git-branch',
|
|
199
|
+
locales: {
|
|
200
|
+
en: {
|
|
201
|
+
title: 'Iteration Management',
|
|
202
|
+
description: 'Track changes and release status across repositories',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
agent: {
|
|
206
|
+
skill: 'iteration-manager',
|
|
207
|
+
layout: 'workspace',
|
|
208
|
+
},
|
|
209
|
+
page: {
|
|
210
|
+
type: 'sandbox',
|
|
211
|
+
runtime: 'preact',
|
|
212
|
+
entry: 'assets/toolbox/index.html',
|
|
213
|
+
commands: ['list', 'create', 'delete'],
|
|
214
|
+
},
|
|
215
|
+
order: 100,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export default function register(app) {
|
|
219
|
+
app.command('list', async () => ({ items: await listItems() }));
|
|
220
|
+
app.command('create <name>', async input => ({ item: await createItem(input.name) }));
|
|
221
|
+
app.command('delete <id>', async input => ({ item: await deleteItem(input.id) }));
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
声明 `agent` 后工具页可以把任务交给对话 Agent;`layout` 默认为 `drawer`,宿主会提供顶部入口。
|
|
226
|
+
页面也可在 `setAgentPanelVisible` 或 `runAgentTask` 中为单次交互指定 `drawer` / `workspace`。带固定
|
|
227
|
+
18rem 左侧导航的工作台可默认使用 `workspace`,由导航项切换 Agent,并占满导航栏以外的主内容区;详情页仍可按需唤起右侧抽屉。
|
|
228
|
+
|
|
229
|
+
页面通过 `await window.mearl.ready()` 取得 `{ locale, theme, title, fontSize }`,通过
|
|
230
|
+
`window.mearl.invoke(command, input?)` 调用 `commands` 白名单中的同一插件命令。页面运行在无扩展
|
|
231
|
+
API、默认不可联网的 sandbox iframe 中;Native Host 校验插件身份、命令白名单和消息大小。页面包按
|
|
232
|
+
内容哈希缓存并分块传输,因此不会增加 Popup 的启动开销。使用模块脚本时需先打包为不再包含 `import`
|
|
233
|
+
的单文件;React、Vue 等工程可以把构建后的 `index.html` 作为 `entry`,注册流程仍负责最终资源封装。
|
|
234
|
+
需要无构建的组件与状态管理时可声明 `page.runtime: 'preact'`,扩展会在页面完整性校验后注入固定的
|
|
235
|
+
Preact、Hooks 和 HTM 运行时。页面从 `window.mearl.ui` 读取 `html`、`render`、`useState` 等 API;
|
|
236
|
+
`window.mearl.ui.version` 仅用于诊断,插件不能选择运行时版本。
|
|
237
|
+
宿主会同步扩展主题、字号,并注入 `--mearl-bg-primary`、`--mearl-bg-secondary`、
|
|
238
|
+
`--mearl-text-primary`、`--mearl-text-secondary`、`--mearl-border`、`--mearl-bg-hover` 和
|
|
239
|
+
`--mearl-accent` 等语义变量;主操作按钮使用 `--mearl-action-primary`、
|
|
240
|
+
`--mearl-action-primary-hover` 和 `--mearl-action-primary-text`,以保持与扩展按钮一致。
|
|
241
|
+
|
|
189
242
|
需要 BUC 身份的内部 HTTP 接口通过官方 `buc` 插件调用。CLI 使用 `mearl buc request`,业务插件使用
|
|
190
243
|
`app.invoke('buc', { command: 'request', input })`;当前身份和人员资料分别使用 `whoami` 与 `profile`
|
|
191
244
|
子命令。BUC 插件按请求创建一次性凭据,只接受无 URL 凭据的 HTTPS 地址,过滤认证字段与 Cookie;
|
package/dist/cli.js
CHANGED
|
@@ -504,7 +504,8 @@ async function runPluginCli(args, commandName) {
|
|
|
504
504
|
...invokeOptions,
|
|
505
505
|
...(parsed.verbose
|
|
506
506
|
? {
|
|
507
|
-
onProgress(
|
|
507
|
+
onProgress(progress) {
|
|
508
|
+
const message = typeof progress === 'string' ? progress : progress.message;
|
|
508
509
|
process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
509
510
|
},
|
|
510
511
|
}
|
|
@@ -365,7 +365,7 @@ export interface BrowserEmulationOptions {
|
|
|
365
365
|
/** App 容器横屏时将根页面与业务页面分置左右 WebView;仅与 deviceShell、App 档案同时生效。 */
|
|
366
366
|
splitScreen?: boolean;
|
|
367
367
|
}
|
|
368
|
-
export type AppBridgeConfigBehavior = 'navigate' | 'closeAll' | 'closeBackData' | 'postMessage' | 'notifySubscribe' | 'notifyUnsubscribe' | 'notifyPost' | 'sharedFileRead' | 'sharedFileWrite' | 'sharedDataGet' | 'sharedDataSet' | 'legacyBroadcastCreate' | 'legacyBroadcastPost' | 'legacyBroadcastClose' | 'memoryGet' | 'memorySet' | 'memoryDelete' | 'availabilityMap' | 'userInfo' | 'networkInfo' | 'geolocation' | 'permission' | 'prefetch' | 'fptTrack' | 'mtopStream' | 'previewFile' | 'toast' | 'unsupportedNativeInput' | 'payment' | 'userTrack' | 'calendar' | 'networkQualityMonitor' | 'beaconRequest' | 'voiceRecognition';
|
|
368
|
+
export type AppBridgeConfigBehavior = 'navigate' | 'closeAll' | 'closeBackData' | 'postMessage' | 'notifySubscribe' | 'notifyUnsubscribe' | 'notifyPost' | 'sharedFileRead' | 'sharedFileWrite' | 'sharedDataGet' | 'sharedDataSet' | 'legacyBroadcastCreate' | 'legacyBroadcastPost' | 'legacyBroadcastClose' | 'memoryGet' | 'memorySet' | 'memoryDelete' | 'availabilityMap' | 'userInfo' | 'networkInfo' | 'geolocation' | 'permission' | 'imagePicker' | 'prefetch' | 'fptTrack' | 'mtopStream' | 'previewFile' | 'toast' | 'unsupportedNativeInput' | 'payment' | 'userTrack' | 'calendar' | 'networkQualityMonitor' | 'beaconRequest' | 'voiceRecognition';
|
|
369
369
|
/** 单个桥方法的声明式覆盖;行为、固定回包、成功空实现三选一。 */
|
|
370
370
|
export interface AppBridgeMethodConfig {
|
|
371
371
|
behavior?: AppBridgeConfigBehavior;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invoke, invokePlugin, listCloudConnectors, } from './unified.js';
|
|
8
8
|
export type { UnifiedClientOptions, UnifiedInvokeOptions, UnifiedInvokeOptions as InvokeOptions, } from './unified.js';
|
|
9
|
-
export type { PluginInvocation, PluginInvokeOptions } from './plugin.js';
|
|
9
|
+
export type { PluginInvocation, PluginInvokeOptions, PluginProgress, StructuredPluginProgress, } from './plugin.js';
|
|
10
10
|
export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
|
|
11
11
|
export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
|
|
12
12
|
import { type UnifiedInvokeOptions } from './unified.js';
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { type OfficialPluginInstaller } from './officialPluginInstaller.js';
|
|
2
|
+
import { type PluginToolboxContribution } from './pluginRegistry.js';
|
|
2
3
|
import type { UnifiedInvokeOptions } from './unified.js';
|
|
3
|
-
export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, type PluginRegistration, type PluginRegistrationOptions, } from './pluginRegistry.js';
|
|
4
|
+
export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, type PluginRegistration, type PluginRegistrationOptions, type PluginToolboxAgentConfig, type PluginPageBundleRegistration, type PluginToolboxContribution, type PluginToolboxLocale, type PluginToolboxSandboxPage, } from './pluginRegistry.js';
|
|
4
5
|
export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, type OfficialPluginDefinition, type OfficialPluginName, } from './officialPluginCatalog.js';
|
|
5
6
|
export type PluginOptions = UnifiedInvokeOptions;
|
|
7
|
+
export interface StructuredPluginProgress {
|
|
8
|
+
message: string;
|
|
9
|
+
data?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type PluginProgress = string | StructuredPluginProgress;
|
|
6
12
|
export interface PluginInvocation {
|
|
7
13
|
command: string;
|
|
8
14
|
input: Record<string, any>;
|
|
9
15
|
}
|
|
10
16
|
export interface PluginInvokeOptions extends UnifiedInvokeOptions {
|
|
11
|
-
onProgress?: (
|
|
17
|
+
onProgress?: (progress: PluginProgress) => void;
|
|
12
18
|
}
|
|
13
19
|
export interface PluginExecutionContext {
|
|
14
20
|
ensurePlugin?: OfficialPluginInstaller;
|
|
@@ -18,13 +24,14 @@ export interface PluginApp {
|
|
|
18
24
|
readonly options: Readonly<PluginOptions>;
|
|
19
25
|
command(signature: string, handler: PluginHandler, description?: string): void;
|
|
20
26
|
invoke(action: string, payload?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
|
|
21
|
-
progress(
|
|
27
|
+
progress(progress: PluginProgress): void;
|
|
22
28
|
}
|
|
23
29
|
export type PluginHandler = (input: Record<string, any>) => unknown | Promise<unknown>;
|
|
24
30
|
/** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
|
|
25
31
|
export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invoke' | 'options' | 'progress'>, pluginName?: string): Promise<{
|
|
26
32
|
name: string | undefined;
|
|
27
33
|
description: string | undefined;
|
|
34
|
+
toolbox: PluginToolboxContribution | undefined;
|
|
28
35
|
version: string | undefined;
|
|
29
36
|
commandHelp(namespace: string): string;
|
|
30
37
|
help(namespace: string, name?: string): string;
|
|
@@ -33,6 +40,7 @@ export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invo
|
|
|
33
40
|
export declare function loadRegisteredPlugin(name: string, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<{
|
|
34
41
|
name: string | undefined;
|
|
35
42
|
description: string | undefined;
|
|
43
|
+
toolbox: PluginToolboxContribution | undefined;
|
|
36
44
|
version: string | undefined;
|
|
37
45
|
commandHelp(namespace: string): string;
|
|
38
46
|
help(namespace: string, name?: string): string;
|
package/dist/plugin.js
CHANGED
|
@@ -5,9 +5,9 @@ import { Command } from 'commander';
|
|
|
5
5
|
import { COMMAND_MAP } from './commands.js';
|
|
6
6
|
import { findOfficialPlugin } from './officialPluginCatalog.js';
|
|
7
7
|
import { ensureOfficialPluginRegistered, } from './officialPluginInstaller.js';
|
|
8
|
-
import { findPluginRegistration, PluginError } from './pluginRegistry.js';
|
|
8
|
+
import { findPluginRegistration, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
|
|
9
9
|
import { describePythonPlugin, runPythonPluginCommand } from './pythonPlugin.js';
|
|
10
|
-
export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
|
|
10
|
+
export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, } from './pluginRegistry.js';
|
|
11
11
|
export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, } from './officialPluginCatalog.js';
|
|
12
12
|
function readPluginVersion(entry) {
|
|
13
13
|
try {
|
|
@@ -26,6 +26,36 @@ function isObject(value) {
|
|
|
26
26
|
function isPluginInvocation(value) {
|
|
27
27
|
return isObject(value) && typeof value.command === 'string' && isObject(value.input);
|
|
28
28
|
}
|
|
29
|
+
function normalizePluginProgress(progress) {
|
|
30
|
+
if (typeof progress === 'string') {
|
|
31
|
+
if (!progress)
|
|
32
|
+
throw new Error('app.progress 需要非空消息');
|
|
33
|
+
return progress;
|
|
34
|
+
}
|
|
35
|
+
if (!isObject(progress) || typeof progress.message !== 'string' || !progress.message) {
|
|
36
|
+
throw new Error('app.progress 需要非空消息');
|
|
37
|
+
}
|
|
38
|
+
if (progress.data !== undefined && !isObject(progress.data)) {
|
|
39
|
+
throw new Error('app.progress data 必须是对象');
|
|
40
|
+
}
|
|
41
|
+
let data;
|
|
42
|
+
if (progress.data) {
|
|
43
|
+
try {
|
|
44
|
+
const serialized = JSON.stringify(progress.data);
|
|
45
|
+
const parsed = serialized === undefined ? undefined : JSON.parse(serialized);
|
|
46
|
+
if (!isObject(parsed))
|
|
47
|
+
throw new Error('invalid object');
|
|
48
|
+
data = Object.freeze(parsed);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error('app.progress data 必须是可 JSON 序列化的对象');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
message: progress.message,
|
|
56
|
+
...(data ? { data } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
29
59
|
function missingPluginEntryMessage(entry, pluginName) {
|
|
30
60
|
const name = pluginName ?? '<name>';
|
|
31
61
|
return [
|
|
@@ -54,6 +84,7 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
54
84
|
let registering = true;
|
|
55
85
|
let name;
|
|
56
86
|
let description;
|
|
87
|
+
let toolbox;
|
|
57
88
|
const app = Object.freeze({
|
|
58
89
|
options: Object.freeze({ ...context.options }),
|
|
59
90
|
command(signature, handler, description = '') {
|
|
@@ -80,12 +111,10 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
80
111
|
throw new Error('app.invoke 只能在命令执行期间调用');
|
|
81
112
|
return context.invoke(action, payload, options);
|
|
82
113
|
},
|
|
83
|
-
progress(
|
|
114
|
+
progress(progress) {
|
|
84
115
|
if (!executing)
|
|
85
116
|
throw new Error('app.progress 只能在命令执行期间调用');
|
|
86
|
-
|
|
87
|
-
throw new Error('app.progress 需要非空字符串');
|
|
88
|
-
context.progress(message);
|
|
117
|
+
context.progress(normalizePluginProgress(progress));
|
|
89
118
|
},
|
|
90
119
|
});
|
|
91
120
|
try {
|
|
@@ -93,6 +122,7 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
93
122
|
const descriptor = await describePythonPlugin(entry, app.options);
|
|
94
123
|
name = descriptor.name;
|
|
95
124
|
description = descriptor.description;
|
|
125
|
+
toolbox = normalizePluginToolboxContribution(descriptor.toolbox);
|
|
96
126
|
for (const command of descriptor.commands) {
|
|
97
127
|
const commandName = command.signature.trim().split(/\s+/)[0];
|
|
98
128
|
app.command(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
|
|
@@ -108,12 +138,18 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
108
138
|
if (module.description !== undefined && typeof module.description !== 'string')
|
|
109
139
|
throw new Error('入口的 description 导出必须为字符串');
|
|
110
140
|
description = module.description;
|
|
141
|
+
toolbox = normalizePluginToolboxContribution(module.toolbox);
|
|
111
142
|
if (typeof module.default !== 'function')
|
|
112
143
|
throw new Error('入口必须默认导出 register(app) 函数');
|
|
113
144
|
await module.default(app);
|
|
114
145
|
}
|
|
115
146
|
if (commands.size === 0)
|
|
116
147
|
throw new Error('入口未注册任何命令');
|
|
148
|
+
const toolboxCommands = toolbox?.page.commands ?? [];
|
|
149
|
+
for (const command of toolboxCommands) {
|
|
150
|
+
if (!commands.has(command))
|
|
151
|
+
throw new Error(`toolbox 命令未注册: ${command}`);
|
|
152
|
+
}
|
|
117
153
|
}
|
|
118
154
|
catch (error) {
|
|
119
155
|
throw new PluginError('PLUGIN_LOAD_FAILED', `${entry}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -130,6 +166,7 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
130
166
|
return {
|
|
131
167
|
name,
|
|
132
168
|
description,
|
|
169
|
+
toolbox,
|
|
133
170
|
version: readPluginVersion(entry),
|
|
134
171
|
commandHelp(namespace) {
|
|
135
172
|
return [...commands.values()]
|
|
@@ -215,7 +252,7 @@ async function loadRegisteredPluginWithState(name, context, options = {}, state
|
|
|
215
252
|
}
|
|
216
253
|
let registration = findPluginRegistration(name);
|
|
217
254
|
if (!registration && findOfficialPlugin(name)) {
|
|
218
|
-
await (context.ensurePlugin ?? ensureOfficialPluginRegistered)(name, options.onProgress);
|
|
255
|
+
await (context.ensurePlugin ?? ensureOfficialPluginRegistered)(name, options.onProgress ? message => options.onProgress?.(message) : undefined);
|
|
219
256
|
registration = findPluginRegistration(name);
|
|
220
257
|
}
|
|
221
258
|
if (!registration) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const PLUGIN_PAGE_CHUNK_BYTES: number;
|
|
2
|
+
export interface BuiltPluginPageBundle {
|
|
3
|
+
content: string;
|
|
4
|
+
hash: string;
|
|
5
|
+
size: number;
|
|
6
|
+
chunks: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildPluginPageBundle(entry: string, pageEntry: string): BuiltPluginPageBundle;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
export const PLUGIN_PAGE_CHUNK_BYTES = 384 * 1024;
|
|
5
|
+
const MAX_PAGE_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
const MAX_PAGE_BUNDLE_BYTES = 24 * 1024 * 1024;
|
|
7
|
+
const MIME_TYPES = {
|
|
8
|
+
'.avif': 'image/avif',
|
|
9
|
+
'.gif': 'image/gif',
|
|
10
|
+
'.ico': 'image/x-icon',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.mp3': 'audio/mpeg',
|
|
14
|
+
'.mp4': 'video/mp4',
|
|
15
|
+
'.ogg': 'audio/ogg',
|
|
16
|
+
'.otf': 'font/otf',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.ttf': 'font/ttf',
|
|
20
|
+
'.wav': 'audio/wav',
|
|
21
|
+
'.webm': 'video/webm',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.woff': 'font/woff',
|
|
24
|
+
'.woff2': 'font/woff2',
|
|
25
|
+
};
|
|
26
|
+
const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
27
|
+
let context = null;
|
|
28
|
+
let port = null;
|
|
29
|
+
let requestSequence = 0;
|
|
30
|
+
const pending = new Map();
|
|
31
|
+
let resolveReady;
|
|
32
|
+
const ready = new Promise(resolve => { resolveReady = resolve; });
|
|
33
|
+
|
|
34
|
+
const requestHost = async (type, payload = {}) => {
|
|
35
|
+
await ready;
|
|
36
|
+
const requestId = String(++requestSequence);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
pending.set(requestId, { resolve, reject });
|
|
39
|
+
port.postMessage({ type, requestId, ...payload });
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const invoke = async (command, input = {}) => {
|
|
44
|
+
if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
|
|
45
|
+
throw new Error('Invalid plugin command');
|
|
46
|
+
}
|
|
47
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
48
|
+
throw new Error('Plugin command input must be an object');
|
|
49
|
+
}
|
|
50
|
+
return requestHost('invoke', { command, input });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const start = async (command, input = {}) => {
|
|
54
|
+
if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
|
|
55
|
+
throw new Error('Invalid plugin command');
|
|
56
|
+
}
|
|
57
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
58
|
+
throw new Error('Plugin command input must be an object');
|
|
59
|
+
}
|
|
60
|
+
return requestHost('invoke-start', { command, input });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const operation = async operationId => {
|
|
64
|
+
if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) {
|
|
65
|
+
throw new Error('Invalid plugin operation id');
|
|
66
|
+
}
|
|
67
|
+
return requestHost('invoke-operation', { operationId });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const runningOperations = async () => requestHost('invoke-operations');
|
|
71
|
+
|
|
72
|
+
const selectDirectory = async () => {
|
|
73
|
+
const path = await requestHost('select-directory');
|
|
74
|
+
if (path !== null && typeof path !== 'string') {
|
|
75
|
+
throw new Error('Directory selection returned an invalid path');
|
|
76
|
+
}
|
|
77
|
+
return path;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const copyText = async text => {
|
|
81
|
+
if (typeof text !== 'string' || text.length > 1000000) {
|
|
82
|
+
throw new Error('Clipboard text must be a string no longer than 1000000 characters');
|
|
83
|
+
}
|
|
84
|
+
await requestHost('clipboard-write', { text });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const toast = async (message, options = {}) => {
|
|
88
|
+
if (typeof message !== 'string' || !message.trim() || message.length > 4000) {
|
|
89
|
+
throw new Error('Toast message must be a non-empty string no longer than 4000 characters');
|
|
90
|
+
}
|
|
91
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
92
|
+
throw new Error('Toast options must be an object');
|
|
93
|
+
}
|
|
94
|
+
const type = options.type ?? 'info';
|
|
95
|
+
const duration = options.duration ?? 3000;
|
|
96
|
+
if (!['success', 'error', 'info'].includes(type)) throw new Error('Invalid toast type');
|
|
97
|
+
if (!Number.isInteger(duration) || duration < 1000 || duration > 30000) {
|
|
98
|
+
throw new Error('Toast duration must be between 1000 and 30000 milliseconds');
|
|
99
|
+
}
|
|
100
|
+
await ready;
|
|
101
|
+
port.postMessage({ type: 'toast', message: message.trim(), toastType: type, duration });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const agentPanelLayout = options => {
|
|
105
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
106
|
+
throw new Error('Agent panel options must be an object');
|
|
107
|
+
}
|
|
108
|
+
const layout = options.layout;
|
|
109
|
+
if (layout !== undefined && layout !== 'drawer' && layout !== 'workspace') {
|
|
110
|
+
throw new Error('Agent panel layout must be drawer or workspace');
|
|
111
|
+
}
|
|
112
|
+
return layout;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const setAgentPanelVisible = async (visible, options = {}) => {
|
|
116
|
+
if (typeof visible !== 'boolean') throw new Error('Agent panel visibility must be a boolean');
|
|
117
|
+
const layout = agentPanelLayout(options);
|
|
118
|
+
await requestHost('agent-panel', { visible, ...(layout ? { layout } : {}) });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const runAgentTask = async (prompt, options = {}) => {
|
|
122
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 16000) {
|
|
123
|
+
throw new Error('Agent task prompt must be a non-empty string no longer than 16000 characters');
|
|
124
|
+
}
|
|
125
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
126
|
+
throw new Error('Agent task options must be an object');
|
|
127
|
+
}
|
|
128
|
+
const cwd = options.cwd;
|
|
129
|
+
const layout = agentPanelLayout(options);
|
|
130
|
+
if (
|
|
131
|
+
cwd !== undefined &&
|
|
132
|
+
(typeof cwd !== 'string' ||
|
|
133
|
+
!cwd.trim() ||
|
|
134
|
+
cwd.length > 4096 ||
|
|
135
|
+
!/^(?:\/|[a-zA-Z]:[\\/])/.test(cwd.trim()))
|
|
136
|
+
) {
|
|
137
|
+
throw new Error('Agent task cwd must be an absolute path no longer than 4096 characters');
|
|
138
|
+
}
|
|
139
|
+
await requestHost('agent-task', {
|
|
140
|
+
prompt: prompt.trim(),
|
|
141
|
+
...(cwd !== undefined ? { cwd: cwd.trim() } : {}),
|
|
142
|
+
...(layout ? { layout } : {}),
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const setQueryParam = async (name, value) => {
|
|
147
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
|
|
148
|
+
throw new Error('Query parameter name is invalid');
|
|
149
|
+
}
|
|
150
|
+
if (value !== null && (typeof value !== 'string' || value.length > 512)) {
|
|
151
|
+
throw new Error('Query parameter value must be null or a string no longer than 512 characters');
|
|
152
|
+
}
|
|
153
|
+
const query = await requestHost('query-param', { name, value });
|
|
154
|
+
applyContext({ query });
|
|
155
|
+
return query;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const api = { copyText, invoke, operation, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentPanelVisible, setQueryParam, start, toast };
|
|
159
|
+
const ui = globalThis.__mearlToolboxUi;
|
|
160
|
+
if (ui !== undefined) {
|
|
161
|
+
if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
|
|
162
|
+
throw new Error('Invalid Mearl toolbox UI runtime');
|
|
163
|
+
}
|
|
164
|
+
Object.defineProperty(api, 'ui', { enumerable: true, value: ui });
|
|
165
|
+
delete globalThis.__mearlToolboxUi;
|
|
166
|
+
}
|
|
167
|
+
Object.defineProperty(api, 'context', { enumerable: true, get: () => context });
|
|
168
|
+
Object.defineProperty(window, 'mearl', { configurable: false, value: Object.freeze(api) });
|
|
169
|
+
|
|
170
|
+
const applyContext = nextContext => {
|
|
171
|
+
const theme =
|
|
172
|
+
nextContext?.theme === 'dark' ||
|
|
173
|
+
(nextContext?.theme !== 'light' && context?.theme === 'dark')
|
|
174
|
+
? 'dark'
|
|
175
|
+
: 'light';
|
|
176
|
+
const fontSize = String(nextContext?.fontSize || context?.fontSize || '');
|
|
177
|
+
document.documentElement.dataset.mearlTheme = theme;
|
|
178
|
+
document.documentElement.style.colorScheme = theme;
|
|
179
|
+
if (/^\d+(?:\.\d+)?px$/.test(fontSize)) document.documentElement.style.fontSize = fontSize;
|
|
180
|
+
context = Object.freeze({ ...(context || {}), ...(nextContext || {}), theme });
|
|
181
|
+
return context;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const connect = event => {
|
|
185
|
+
const message = event.data;
|
|
186
|
+
const nextPort = event.ports && event.ports[0];
|
|
187
|
+
if (
|
|
188
|
+
(event.source && event.source !== window.parent) ||
|
|
189
|
+
message?.type !== 'mearl-plugin-connect' ||
|
|
190
|
+
!nextPort
|
|
191
|
+
) return;
|
|
192
|
+
window.removeEventListener('message', connect);
|
|
193
|
+
applyContext(message.context);
|
|
194
|
+
port = nextPort;
|
|
195
|
+
port.onmessage = responseEvent => {
|
|
196
|
+
const response = responseEvent.data;
|
|
197
|
+
if (response?.type === 'context') {
|
|
198
|
+
applyContext(response.context);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (response?.type !== 'result' || typeof response.requestId !== 'string') return;
|
|
202
|
+
const request = pending.get(response.requestId);
|
|
203
|
+
if (!request) return;
|
|
204
|
+
pending.delete(response.requestId);
|
|
205
|
+
if (response.ok) request.resolve(response.result);
|
|
206
|
+
else request.reject(new Error(response.error || 'Plugin command failed'));
|
|
207
|
+
};
|
|
208
|
+
port.start();
|
|
209
|
+
resolveReady(context);
|
|
210
|
+
port.postMessage({ type: 'ready' });
|
|
211
|
+
};
|
|
212
|
+
window.addEventListener('message', connect);
|
|
213
|
+
window.parent.postMessage({ type: 'mearl-plugin-ready' }, '*');
|
|
214
|
+
})();`;
|
|
215
|
+
const PAGE_SECURITY_META = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'\">";
|
|
216
|
+
const PAGE_THEME_STYLE = `<style data-mearl-theme>
|
|
217
|
+
:root {
|
|
218
|
+
color-scheme: light dark;
|
|
219
|
+
--mearl-toolbar: #f3f3f3;
|
|
220
|
+
--mearl-toolbar-border: #d0d0d0;
|
|
221
|
+
--mearl-text-primary: #2c2c2c;
|
|
222
|
+
--mearl-text-secondary: #6e6e6e;
|
|
223
|
+
--mearl-bg-primary: #ffffff;
|
|
224
|
+
--mearl-bg-secondary: #f8f9fa;
|
|
225
|
+
--mearl-bg-list: #fafbfc;
|
|
226
|
+
--mearl-bg-hover: #f0f1f3;
|
|
227
|
+
--mearl-bg-selected: #f5f5f5;
|
|
228
|
+
--mearl-border: #e1e4e8;
|
|
229
|
+
--mearl-accent: #2a57c9;
|
|
230
|
+
--mearl-accent-hover: #1d4ed8;
|
|
231
|
+
--mearl-action-primary: oklch(54.6% 0.245 262.881);
|
|
232
|
+
--mearl-action-primary-hover: oklch(48.8% 0.243 264.376);
|
|
233
|
+
--mearl-action-primary-text: #ffffff;
|
|
234
|
+
--mearl-danger: #dc2626;
|
|
235
|
+
--mearl-danger-bg: rgb(220 38 38 / 8%);
|
|
236
|
+
--mearl-success: #16a34a;
|
|
237
|
+
--mearl-scrollbar-track: #f6f8fa;
|
|
238
|
+
--mearl-scrollbar-thumb: #d1d5db;
|
|
239
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
240
|
+
background: var(--mearl-bg-primary);
|
|
241
|
+
}
|
|
242
|
+
:root[data-mearl-theme="dark"] {
|
|
243
|
+
color-scheme: dark;
|
|
244
|
+
--mearl-toolbar: #1f1f1f;
|
|
245
|
+
--mearl-toolbar-border: #343434;
|
|
246
|
+
--mearl-text-primary: #e4e4e7;
|
|
247
|
+
--mearl-text-secondary: #a1a1aa;
|
|
248
|
+
--mearl-bg-primary: #0f0f0f;
|
|
249
|
+
--mearl-bg-secondary: #1a1a1a;
|
|
250
|
+
--mearl-bg-list: #0f0f0f;
|
|
251
|
+
--mearl-bg-hover: #262626;
|
|
252
|
+
--mearl-bg-selected: #1f1f1f;
|
|
253
|
+
--mearl-border: #30363d;
|
|
254
|
+
--mearl-accent: #58a6ff;
|
|
255
|
+
--mearl-accent-hover: #79b8ff;
|
|
256
|
+
--mearl-danger: #f87171;
|
|
257
|
+
--mearl-danger-bg: rgb(248 113 113 / 10%);
|
|
258
|
+
--mearl-success: #4ade80;
|
|
259
|
+
--mearl-scrollbar-track: #1a1a1a;
|
|
260
|
+
--mearl-scrollbar-thumb: #3b3b3b;
|
|
261
|
+
}
|
|
262
|
+
@media (prefers-color-scheme: dark) {
|
|
263
|
+
:root:not([data-mearl-theme="light"]) {
|
|
264
|
+
color-scheme: dark;
|
|
265
|
+
--mearl-toolbar: #1f1f1f;
|
|
266
|
+
--mearl-toolbar-border: #343434;
|
|
267
|
+
--mearl-text-primary: #e4e4e7;
|
|
268
|
+
--mearl-text-secondary: #a1a1aa;
|
|
269
|
+
--mearl-bg-primary: #0f0f0f;
|
|
270
|
+
--mearl-bg-secondary: #1a1a1a;
|
|
271
|
+
--mearl-bg-list: #0f0f0f;
|
|
272
|
+
--mearl-bg-hover: #262626;
|
|
273
|
+
--mearl-bg-selected: #1f1f1f;
|
|
274
|
+
--mearl-border: #30363d;
|
|
275
|
+
--mearl-accent: #58a6ff;
|
|
276
|
+
--mearl-accent-hover: #79b8ff;
|
|
277
|
+
--mearl-danger: #f87171;
|
|
278
|
+
--mearl-danger-bg: rgb(248 113 113 / 10%);
|
|
279
|
+
--mearl-success: #4ade80;
|
|
280
|
+
--mearl-scrollbar-track: #1a1a1a;
|
|
281
|
+
--mearl-scrollbar-thumb: #3b3b3b;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
body {
|
|
285
|
+
margin: 0;
|
|
286
|
+
background: var(--mearl-bg-primary);
|
|
287
|
+
color: var(--mearl-text-primary);
|
|
288
|
+
}
|
|
289
|
+
button, input, select, textarea { font: inherit; }
|
|
290
|
+
</style>`;
|
|
291
|
+
function pluginRoot(entry) {
|
|
292
|
+
const entryDirectory = dirname(entry);
|
|
293
|
+
return basename(entryDirectory) === 'scripts' ? dirname(entryDirectory) : entryDirectory;
|
|
294
|
+
}
|
|
295
|
+
function attributeValue(tag, name) {
|
|
296
|
+
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
|
297
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
298
|
+
}
|
|
299
|
+
function removeAttribute(tag, name) {
|
|
300
|
+
return tag.replace(new RegExp(`\\s+${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s>]+)`, 'i'), '');
|
|
301
|
+
}
|
|
302
|
+
function isEmbeddedReference(reference) {
|
|
303
|
+
return /^(?:data:|blob:|#)/i.test(reference.trim());
|
|
304
|
+
}
|
|
305
|
+
function isRemoteReference(reference) {
|
|
306
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(reference.trim());
|
|
307
|
+
}
|
|
308
|
+
function contentType(path) {
|
|
309
|
+
return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
310
|
+
}
|
|
311
|
+
function escapeStyleText(value) {
|
|
312
|
+
return value.replace(/<\/style/gi, '\\3C /style');
|
|
313
|
+
}
|
|
314
|
+
function escapeScriptText(value) {
|
|
315
|
+
return value.replace(/<\/script/gi, '<\\/script');
|
|
316
|
+
}
|
|
317
|
+
export function buildPluginPageBundle(entry, pageEntry) {
|
|
318
|
+
const root = realpathSync(pluginRoot(entry));
|
|
319
|
+
let sourceBytes = 0;
|
|
320
|
+
const countedFiles = new Set();
|
|
321
|
+
const resolveLocalFile = (reference, fromFile) => {
|
|
322
|
+
const cleanReference = reference.trim().split(/[?#]/, 1)[0];
|
|
323
|
+
if (!cleanReference || isEmbeddedReference(cleanReference))
|
|
324
|
+
return '';
|
|
325
|
+
if (isRemoteReference(cleanReference) || isAbsolute(cleanReference)) {
|
|
326
|
+
throw new Error(`toolbox.page 不允许远程或绝对资源: ${reference}`);
|
|
327
|
+
}
|
|
328
|
+
const resolved = realpathSync(resolve(dirname(fromFile), cleanReference));
|
|
329
|
+
const relativePath = relative(root, resolved);
|
|
330
|
+
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
331
|
+
throw new Error(`toolbox.page 资源超出插件目录: ${reference}`);
|
|
332
|
+
}
|
|
333
|
+
return resolved;
|
|
334
|
+
};
|
|
335
|
+
const readAsset = (path) => {
|
|
336
|
+
const content = readFileSync(path);
|
|
337
|
+
if (!countedFiles.has(path)) {
|
|
338
|
+
countedFiles.add(path);
|
|
339
|
+
sourceBytes += content.byteLength;
|
|
340
|
+
if (sourceBytes > MAX_PAGE_SOURCE_BYTES) {
|
|
341
|
+
throw new Error('toolbox.page 源文件总大小不能超过 16 MiB');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return content;
|
|
345
|
+
};
|
|
346
|
+
const dataUrl = (reference, fromFile) => {
|
|
347
|
+
if (isEmbeddedReference(reference))
|
|
348
|
+
return reference;
|
|
349
|
+
const path = resolveLocalFile(reference, fromFile);
|
|
350
|
+
return `data:${contentType(path)};base64,${readAsset(path).toString('base64')}`;
|
|
351
|
+
};
|
|
352
|
+
const inlineCss = (css, cssPath) => css.replace(/url\(\s*(['"]?)([^'"()]+)\1\s*\)/gi, (_match, _quote, reference) => {
|
|
353
|
+
const value = reference.trim();
|
|
354
|
+
if (isEmbeddedReference(value))
|
|
355
|
+
return `url("${value}")`;
|
|
356
|
+
return `url("${dataUrl(value, cssPath)}")`;
|
|
357
|
+
});
|
|
358
|
+
const absoluteEntry = resolveLocalFile(pageEntry, join(root, '__toolbox_entry__'));
|
|
359
|
+
if (extname(absoluteEntry).toLowerCase() !== '.html') {
|
|
360
|
+
throw new Error('toolbox.page.entry 必须指向 HTML 文件');
|
|
361
|
+
}
|
|
362
|
+
let html = readAsset(absoluteEntry)
|
|
363
|
+
.toString('utf8')
|
|
364
|
+
.replace(/^\uFEFF/, '');
|
|
365
|
+
if (!/<html\b|<!doctype\b/i.test(html)) {
|
|
366
|
+
throw new Error('toolbox.page.entry 必须是完整 HTML 文档');
|
|
367
|
+
}
|
|
368
|
+
if (/<base\b/i.test(html))
|
|
369
|
+
throw new Error('toolbox.page 不允许声明 base 标签');
|
|
370
|
+
if (/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy/i.test(html)) {
|
|
371
|
+
throw new Error('toolbox.page 的安全策略由 Mearl 管理,请移除 CSP meta 标签');
|
|
372
|
+
}
|
|
373
|
+
html = html.replace(/<link\b[^>]*>/gi, tag => {
|
|
374
|
+
const rel = attributeValue(tag, 'rel')?.toLowerCase().split(/\s+/) ?? [];
|
|
375
|
+
const href = attributeValue(tag, 'href');
|
|
376
|
+
if (rel.includes('stylesheet')) {
|
|
377
|
+
if (!href)
|
|
378
|
+
throw new Error('toolbox.page stylesheet 缺少 href');
|
|
379
|
+
const cssPath = resolveLocalFile(href, absoluteEntry);
|
|
380
|
+
const css = inlineCss(readAsset(cssPath).toString('utf8'), cssPath);
|
|
381
|
+
return `<style data-mearl-source="${href.replaceAll('"', '"')}">${escapeStyleText(css)}</style>`;
|
|
382
|
+
}
|
|
383
|
+
if (href && rel.some(value => ['icon', 'apple-touch-icon'].includes(value))) {
|
|
384
|
+
return tag.replace(href, dataUrl(href, absoluteEntry));
|
|
385
|
+
}
|
|
386
|
+
if (href && isRemoteReference(href)) {
|
|
387
|
+
throw new Error(`toolbox.page 不允许远程 link 资源: ${href}`);
|
|
388
|
+
}
|
|
389
|
+
return tag;
|
|
390
|
+
});
|
|
391
|
+
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, tag => {
|
|
392
|
+
const src = attributeValue(tag, 'src');
|
|
393
|
+
if (!src)
|
|
394
|
+
return tag;
|
|
395
|
+
const scriptPath = resolveLocalFile(src, absoluteEntry);
|
|
396
|
+
const script = readAsset(scriptPath).toString('utf8');
|
|
397
|
+
if (attributeValue(tag, 'type')?.toLowerCase() === 'module' &&
|
|
398
|
+
/(?:^|[;\n]\s*)(?:import\s|export\s)|\bimport\s*\(/m.test(script)) {
|
|
399
|
+
throw new Error('toolbox.page 的模块脚本必须先打包为无 import 的单文件');
|
|
400
|
+
}
|
|
401
|
+
const openingTag = removeAttribute(tag.slice(0, tag.indexOf('>') + 1), 'src');
|
|
402
|
+
return `${openingTag}${escapeScriptText(script)}</script>`;
|
|
403
|
+
});
|
|
404
|
+
html = html.replace(/(<(?:img|source|video|audio|track|input)\b[^>]*?\s(?:src|poster)\s*=\s*)(["'])([^"']+)\2/gi, (_match, prefix, quote, reference) => `${prefix}${quote}${dataUrl(reference, absoluteEntry)}${quote}`);
|
|
405
|
+
const injection = `<meta name="color-scheme" content="light dark">${PAGE_SECURITY_META}${PAGE_THEME_STYLE}<script data-mearl-bridge>${escapeScriptText(PAGE_BRIDGE_SCRIPT)}</script>`;
|
|
406
|
+
html = /<head\b[^>]*>/i.test(html)
|
|
407
|
+
? html.replace(/<head\b[^>]*>/i, tag => `${tag}${injection}`)
|
|
408
|
+
: html.replace(/<html\b[^>]*>/i, tag => `${tag}<head>${injection}</head>`);
|
|
409
|
+
const content = html;
|
|
410
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
411
|
+
if (buffer.byteLength > MAX_PAGE_BUNDLE_BYTES) {
|
|
412
|
+
throw new Error('toolbox.page 打包后不能超过 24 MiB');
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
content,
|
|
416
|
+
hash: createHash('sha256').update(buffer).digest('hex'),
|
|
417
|
+
size: buffer.byteLength,
|
|
418
|
+
chunks: Math.ceil(buffer.byteLength / PLUGIN_PAGE_CHUNK_BYTES),
|
|
419
|
+
};
|
|
420
|
+
}
|
package/dist/pluginRegistry.d.ts
CHANGED
|
@@ -2,20 +2,59 @@ export declare class PluginError extends Error {
|
|
|
2
2
|
readonly code: string;
|
|
3
3
|
constructor(code: string, message: string);
|
|
4
4
|
}
|
|
5
|
+
export interface PluginToolboxSandboxPage {
|
|
6
|
+
type: 'sandbox';
|
|
7
|
+
runtime?: 'preact';
|
|
8
|
+
entry: string;
|
|
9
|
+
commands: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface PluginToolboxLocale {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PluginToolboxAgentConfig {
|
|
16
|
+
skill: string;
|
|
17
|
+
layout?: 'drawer' | 'workspace';
|
|
18
|
+
}
|
|
19
|
+
export interface PluginToolboxContribution {
|
|
20
|
+
version: 1;
|
|
21
|
+
title: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
icon?: string;
|
|
24
|
+
locales?: Record<string, PluginToolboxLocale>;
|
|
25
|
+
agent?: PluginToolboxAgentConfig;
|
|
26
|
+
page: PluginToolboxSandboxPage;
|
|
27
|
+
order?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface PluginPageBundleRegistration {
|
|
30
|
+
hash: string;
|
|
31
|
+
size: number;
|
|
32
|
+
chunks: number;
|
|
33
|
+
}
|
|
5
34
|
export interface PluginRegistration {
|
|
6
35
|
entry: string;
|
|
7
36
|
description?: string;
|
|
8
37
|
managedBy?: string;
|
|
38
|
+
toolbox?: PluginToolboxContribution;
|
|
39
|
+
pageBundle?: PluginPageBundleRegistration;
|
|
9
40
|
}
|
|
10
41
|
export interface PluginRegistrationOptions {
|
|
11
42
|
managedBy?: string;
|
|
43
|
+
toolbox?: PluginToolboxContribution;
|
|
12
44
|
}
|
|
13
45
|
export declare function pluginDirectory(): string;
|
|
14
46
|
export declare function assertPluginName(name: string): void;
|
|
47
|
+
export declare function normalizePluginToolboxContribution(value: unknown): PluginToolboxContribution | undefined;
|
|
15
48
|
export declare function findPluginDirectoryEntry(directory: string): string | undefined;
|
|
16
49
|
export declare function resolvePluginEntry(target: string): string;
|
|
17
50
|
export declare function registerPlugin(name: string, target: string, description?: string, options?: PluginRegistrationOptions): string;
|
|
18
51
|
export declare function unregisterPlugin(name: string): boolean;
|
|
19
52
|
export declare function findPluginRegistration(name: string): PluginRegistration | undefined;
|
|
53
|
+
export declare function readPluginPageBundleChunk(name: string, hash: string, index: number): {
|
|
54
|
+
data: string;
|
|
55
|
+
hash: string;
|
|
56
|
+
index: number;
|
|
57
|
+
total: number;
|
|
58
|
+
};
|
|
20
59
|
export declare function findPluginEntry(name: string): string | undefined;
|
|
21
60
|
export declare function listPluginNames(): string[];
|
package/dist/pluginRegistry.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { COMMAND_MAP } from './commands.js';
|
|
6
|
+
import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
|
|
6
7
|
const RESERVED_NAMES = new Set([
|
|
7
8
|
'check',
|
|
8
9
|
'help',
|
|
@@ -21,11 +22,196 @@ export class PluginError extends Error {
|
|
|
21
22
|
export function pluginDirectory() {
|
|
22
23
|
return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
|
|
23
24
|
}
|
|
25
|
+
function pluginPageDirectory() {
|
|
26
|
+
return join(pluginDirectory(), '.pages');
|
|
27
|
+
}
|
|
28
|
+
function pluginPageBundlePath(name, hash) {
|
|
29
|
+
return join(pluginPageDirectory(), `${name}-${hash}.html`);
|
|
30
|
+
}
|
|
31
|
+
function removePluginPageBundles(name, exceptHash) {
|
|
32
|
+
let files;
|
|
33
|
+
try {
|
|
34
|
+
files = readdirSync(pluginPageDirectory());
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const keep = exceptHash ? `${name}-${exceptHash}.html` : undefined;
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
if (file === keep || !file.startsWith(`${name}-`) || !file.endsWith('.html'))
|
|
44
|
+
continue;
|
|
45
|
+
rmSync(join(pluginPageDirectory(), file), { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizePageBundle(value) {
|
|
49
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
50
|
+
throw new Error('invalid page bundle');
|
|
51
|
+
}
|
|
52
|
+
const candidate = value;
|
|
53
|
+
if (typeof candidate.hash !== 'string' ||
|
|
54
|
+
!/^[a-f0-9]{64}$/.test(candidate.hash) ||
|
|
55
|
+
!Number.isInteger(candidate.size) ||
|
|
56
|
+
(candidate.size ?? 0) <= 0 ||
|
|
57
|
+
!Number.isInteger(candidate.chunks) ||
|
|
58
|
+
candidate.chunks !== Math.ceil((candidate.size ?? 0) / PLUGIN_PAGE_CHUNK_BYTES)) {
|
|
59
|
+
throw new Error('invalid page bundle');
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
hash: candidate.hash,
|
|
63
|
+
size: candidate.size,
|
|
64
|
+
chunks: candidate.chunks,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function persistPluginPageBundle(name, bundle) {
|
|
68
|
+
const directory = pluginPageDirectory();
|
|
69
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
70
|
+
const target = pluginPageBundlePath(name, bundle.hash);
|
|
71
|
+
try {
|
|
72
|
+
if (statSync(target).isFile())
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// 页面内容哈希变化时创建新的不可变产物。
|
|
77
|
+
}
|
|
78
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(temporary, bundle.content, { mode: 0o600, flag: 'wx' });
|
|
81
|
+
renameSync(temporary, target);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rmSync(temporary, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
24
87
|
export function assertPluginName(name) {
|
|
25
88
|
if (!/^[a-z][a-z0-9-]*$/.test(name) || RESERVED_NAMES.has(name) || COMMAND_MAP.has(name)) {
|
|
26
89
|
throw new PluginError('PLUGIN_INVALID_NAME', `不可用的插件名称: ${name}`);
|
|
27
90
|
}
|
|
28
91
|
}
|
|
92
|
+
export function normalizePluginToolboxContribution(value) {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
96
|
+
throw new Error('toolbox 必须是对象');
|
|
97
|
+
}
|
|
98
|
+
const candidate = value;
|
|
99
|
+
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
|
|
100
|
+
const description = typeof candidate.description === 'string' ? candidate.description.trim() : undefined;
|
|
101
|
+
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : undefined;
|
|
102
|
+
if (candidate.version !== 1)
|
|
103
|
+
throw new Error('toolbox.version 必须为 1');
|
|
104
|
+
if (!title || title.length > 80)
|
|
105
|
+
throw new Error('toolbox.title 必须是 1-80 个字符');
|
|
106
|
+
if (description && description.length > 240) {
|
|
107
|
+
throw new Error('toolbox.description 不能超过 240 个字符');
|
|
108
|
+
}
|
|
109
|
+
if (icon && (!/^[a-z][a-z0-9-]*$/.test(icon) || icon.length > 40)) {
|
|
110
|
+
throw new Error('toolbox.icon 必须是短横线命名的图标名称');
|
|
111
|
+
}
|
|
112
|
+
let locales;
|
|
113
|
+
if (candidate.locales !== undefined) {
|
|
114
|
+
if (!candidate.locales ||
|
|
115
|
+
typeof candidate.locales !== 'object' ||
|
|
116
|
+
Array.isArray(candidate.locales)) {
|
|
117
|
+
throw new Error('toolbox.locales 必须是对象');
|
|
118
|
+
}
|
|
119
|
+
const entries = Object.entries(candidate.locales);
|
|
120
|
+
if (entries.length === 0 || entries.length > 20) {
|
|
121
|
+
throw new Error('toolbox.locales 必须包含 1-20 个语言');
|
|
122
|
+
}
|
|
123
|
+
locales = Object.fromEntries(entries.map(([locale, localized]) => {
|
|
124
|
+
if (!/^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*$/.test(locale)) {
|
|
125
|
+
throw new Error(`toolbox.locales 包含无效语言: ${locale}`);
|
|
126
|
+
}
|
|
127
|
+
if (!localized || typeof localized !== 'object' || Array.isArray(localized)) {
|
|
128
|
+
throw new Error(`toolbox.locales.${locale} 必须是对象`);
|
|
129
|
+
}
|
|
130
|
+
const localizedTitle = typeof localized.title === 'string' ? localized.title.trim() : undefined;
|
|
131
|
+
const localizedDescription = typeof localized.description === 'string' ? localized.description.trim() : undefined;
|
|
132
|
+
if (localizedTitle !== undefined && (!localizedTitle || localizedTitle.length > 80)) {
|
|
133
|
+
throw new Error(`toolbox.locales.${locale}.title 必须是 1-80 个字符`);
|
|
134
|
+
}
|
|
135
|
+
if (localizedDescription !== undefined && localizedDescription.length > 240) {
|
|
136
|
+
throw new Error(`toolbox.locales.${locale}.description 不能超过 240 个字符`);
|
|
137
|
+
}
|
|
138
|
+
if (!localizedTitle && !localizedDescription) {
|
|
139
|
+
throw new Error(`toolbox.locales.${locale} 至少声明 title 或 description`);
|
|
140
|
+
}
|
|
141
|
+
return [
|
|
142
|
+
locale,
|
|
143
|
+
{
|
|
144
|
+
...(localizedTitle ? { title: localizedTitle } : {}),
|
|
145
|
+
...(localizedDescription ? { description: localizedDescription } : {}),
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
if (!candidate.page || typeof candidate.page !== 'object' || Array.isArray(candidate.page)) {
|
|
151
|
+
throw new Error('toolbox.page 必须是对象');
|
|
152
|
+
}
|
|
153
|
+
const pageCandidate = candidate.page;
|
|
154
|
+
if (pageCandidate.type !== 'sandbox') {
|
|
155
|
+
throw new Error('toolbox.page.type 必须为 sandbox');
|
|
156
|
+
}
|
|
157
|
+
if (pageCandidate.runtime !== undefined && pageCandidate.runtime !== 'preact') {
|
|
158
|
+
throw new Error('toolbox.page.runtime 仅支持 preact');
|
|
159
|
+
}
|
|
160
|
+
const pageEntry = typeof pageCandidate.entry === 'string' ? pageCandidate.entry.trim() : '';
|
|
161
|
+
if (!pageEntry ||
|
|
162
|
+
pageEntry.length > 240 ||
|
|
163
|
+
isAbsolute(pageEntry) ||
|
|
164
|
+
pageEntry.includes('\\') ||
|
|
165
|
+
pageEntry.split('/').some(segment => !segment || segment === '.' || segment === '..')) {
|
|
166
|
+
throw new Error('toolbox.page.entry 必须是插件目录内的相对 HTML 路径');
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(pageCandidate.commands)) {
|
|
169
|
+
throw new Error('toolbox.page.commands 必须是命令名称数组');
|
|
170
|
+
}
|
|
171
|
+
const commands = pageCandidate.commands.map(command => typeof command === 'string' ? command.trim() : '');
|
|
172
|
+
if (commands.length === 0 ||
|
|
173
|
+
commands.length > 64 ||
|
|
174
|
+
new Set(commands).size !== commands.length ||
|
|
175
|
+
commands.some(command => !/^[a-z][a-z0-9-]*$/.test(command))) {
|
|
176
|
+
throw new Error('toolbox.page.commands 必须包含 1-64 个不重复的插件命令名称');
|
|
177
|
+
}
|
|
178
|
+
const page = {
|
|
179
|
+
type: 'sandbox',
|
|
180
|
+
...(pageCandidate.runtime ? { runtime: pageCandidate.runtime } : {}),
|
|
181
|
+
entry: pageEntry,
|
|
182
|
+
commands,
|
|
183
|
+
};
|
|
184
|
+
let agent;
|
|
185
|
+
if (candidate.agent !== undefined) {
|
|
186
|
+
if (!candidate.agent || typeof candidate.agent !== 'object' || Array.isArray(candidate.agent)) {
|
|
187
|
+
throw new Error('toolbox.agent 必须是对象');
|
|
188
|
+
}
|
|
189
|
+
const agentCandidate = candidate.agent;
|
|
190
|
+
const skill = typeof agentCandidate.skill === 'string' ? agentCandidate.skill.trim() : '';
|
|
191
|
+
if (!skill || skill.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/.test(skill)) {
|
|
192
|
+
throw new Error('toolbox.agent.skill 必须是有效的 Skill 名称');
|
|
193
|
+
}
|
|
194
|
+
const layout = agentCandidate.layout;
|
|
195
|
+
if (layout !== undefined && layout !== 'drawer' && layout !== 'workspace') {
|
|
196
|
+
throw new Error('toolbox.agent.layout 必须是 drawer 或 workspace');
|
|
197
|
+
}
|
|
198
|
+
agent = { skill, ...(layout ? { layout } : {}) };
|
|
199
|
+
}
|
|
200
|
+
if (candidate.order !== undefined &&
|
|
201
|
+
(!Number.isInteger(candidate.order) || candidate.order < 0 || candidate.order > 10_000)) {
|
|
202
|
+
throw new Error('toolbox.order 必须是 0-10000 的整数');
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
version: 1,
|
|
206
|
+
title,
|
|
207
|
+
...(description ? { description } : {}),
|
|
208
|
+
...(icon ? { icon } : {}),
|
|
209
|
+
...(locales ? { locales } : {}),
|
|
210
|
+
...(agent ? { agent } : {}),
|
|
211
|
+
page,
|
|
212
|
+
...(candidate.order !== undefined ? { order: candidate.order } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
29
215
|
export function findPluginDirectoryEntry(directory) {
|
|
30
216
|
for (const filename of ['main.mjs', 'main.py']) {
|
|
31
217
|
const entry = join(directory, 'scripts', filename);
|
|
@@ -63,10 +249,31 @@ export function registerPlugin(name, target, description, options = {}) {
|
|
|
63
249
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
64
250
|
const summary = description?.trim();
|
|
65
251
|
const managedBy = options.managedBy?.trim();
|
|
252
|
+
let toolbox;
|
|
253
|
+
try {
|
|
254
|
+
toolbox = normalizePluginToolboxContribution(options.toolbox);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
throw new PluginError('PLUGIN_INVALID_TOOLBOX', error instanceof Error ? error.message : String(error));
|
|
258
|
+
}
|
|
259
|
+
let builtPage;
|
|
260
|
+
if (toolbox) {
|
|
261
|
+
try {
|
|
262
|
+
builtPage = buildPluginPageBundle(entry, toolbox.page.entry);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
throw new PluginError('PLUGIN_INVALID_TOOLBOX_PAGE', error instanceof Error ? error.message : String(error));
|
|
266
|
+
}
|
|
267
|
+
persistPluginPageBundle(name, builtPage);
|
|
268
|
+
}
|
|
66
269
|
const record = {
|
|
67
270
|
entry,
|
|
68
271
|
...(summary ? { description: summary } : {}),
|
|
69
272
|
...(managedBy ? { managedBy } : {}),
|
|
273
|
+
...(toolbox ? { toolbox } : {}),
|
|
274
|
+
...(builtPage
|
|
275
|
+
? { pageBundle: { hash: builtPage.hash, size: builtPage.size, chunks: builtPage.chunks } }
|
|
276
|
+
: {}),
|
|
70
277
|
};
|
|
71
278
|
try {
|
|
72
279
|
writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: 'wx' });
|
|
@@ -75,6 +282,7 @@ export function registerPlugin(name, target, description, options = {}) {
|
|
|
75
282
|
finally {
|
|
76
283
|
rmSync(temporary, { force: true });
|
|
77
284
|
}
|
|
285
|
+
removePluginPageBundles(name, builtPage?.hash);
|
|
78
286
|
return entry;
|
|
79
287
|
}
|
|
80
288
|
export function unregisterPlugin(name) {
|
|
@@ -82,6 +290,7 @@ export function unregisterPlugin(name) {
|
|
|
82
290
|
const file = join(pluginDirectory(), `${name}.json`);
|
|
83
291
|
try {
|
|
84
292
|
rmSync(file);
|
|
293
|
+
removePluginPageBundles(name);
|
|
85
294
|
return true;
|
|
86
295
|
}
|
|
87
296
|
catch (error) {
|
|
@@ -113,16 +322,67 @@ export function findPluginRegistration(name) {
|
|
|
113
322
|
const managedBy = typeof record.managedBy === 'string' && record.managedBy.trim()
|
|
114
323
|
? record.managedBy.trim()
|
|
115
324
|
: undefined;
|
|
325
|
+
const toolbox = normalizePluginToolboxContribution(record.toolbox);
|
|
326
|
+
const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
|
|
116
327
|
return {
|
|
117
328
|
entry: record.entry,
|
|
118
329
|
...(description ? { description } : {}),
|
|
119
330
|
...(managedBy ? { managedBy } : {}),
|
|
331
|
+
...(toolbox ? { toolbox } : {}),
|
|
332
|
+
...(pageBundle ? { pageBundle } : {}),
|
|
120
333
|
};
|
|
121
334
|
}
|
|
122
335
|
catch {
|
|
123
336
|
throw new PluginError('PLUGIN_REGISTRY_INVALID', `无效的插件记录: ${file}`);
|
|
124
337
|
}
|
|
125
338
|
}
|
|
339
|
+
export function readPluginPageBundleChunk(name, hash, index) {
|
|
340
|
+
assertPluginName(name);
|
|
341
|
+
const registration = findPluginRegistration(name);
|
|
342
|
+
const bundle = registration?.pageBundle;
|
|
343
|
+
if (!bundle || bundle.hash !== hash) {
|
|
344
|
+
throw new PluginError('PLUGIN_PAGE_CHANGED', `插件页面已更新: ${name}`);
|
|
345
|
+
}
|
|
346
|
+
if (!Number.isInteger(index) || index < 0 || index >= bundle.chunks) {
|
|
347
|
+
throw new PluginError('PLUGIN_PAGE_CHUNK_INVALID', `插件页面分块无效: ${index}`);
|
|
348
|
+
}
|
|
349
|
+
const path = pluginPageBundlePath(name, hash);
|
|
350
|
+
let size;
|
|
351
|
+
try {
|
|
352
|
+
size = statSync(path).size;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物不存在: ${name}`);
|
|
356
|
+
}
|
|
357
|
+
if (size !== bundle.size) {
|
|
358
|
+
throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
|
|
359
|
+
}
|
|
360
|
+
const start = index * PLUGIN_PAGE_CHUNK_BYTES;
|
|
361
|
+
const content = Buffer.allocUnsafe(Math.min(PLUGIN_PAGE_CHUNK_BYTES, bundle.size - start));
|
|
362
|
+
let descriptor;
|
|
363
|
+
try {
|
|
364
|
+
descriptor = openSync(path, 'r');
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物无法读取: ${name}`);
|
|
368
|
+
}
|
|
369
|
+
let bytesRead;
|
|
370
|
+
try {
|
|
371
|
+
bytesRead = readSync(descriptor, content, 0, content.byteLength, start);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
closeSync(descriptor);
|
|
375
|
+
}
|
|
376
|
+
if (bytesRead !== content.byteLength) {
|
|
377
|
+
throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
data: content.toString('base64'),
|
|
381
|
+
hash,
|
|
382
|
+
index,
|
|
383
|
+
total: bundle.chunks,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
126
386
|
export function findPluginEntry(name) {
|
|
127
387
|
return findPluginRegistration(name)?.entry;
|
|
128
388
|
}
|
package/dist/pythonPlugin.d.ts
CHANGED
package/dist/pythonPlugin.js
CHANGED
|
@@ -143,10 +143,13 @@ try:
|
|
|
143
143
|
|
|
144
144
|
plugin_name = getattr(module, "name", None)
|
|
145
145
|
plugin_description = getattr(module, "description", None)
|
|
146
|
+
plugin_toolbox = getattr(module, "toolbox", None)
|
|
146
147
|
if plugin_name is not None and not isinstance(plugin_name, str):
|
|
147
148
|
raise TypeError("入口的 name 必须为字符串")
|
|
148
149
|
if plugin_description is not None and not isinstance(plugin_description, str):
|
|
149
150
|
raise TypeError("入口的 description 必须为字符串")
|
|
151
|
+
if plugin_toolbox is not None and not isinstance(plugin_toolbox, dict):
|
|
152
|
+
raise TypeError("入口的 toolbox 必须为对象")
|
|
150
153
|
register = getattr(module, "register", None)
|
|
151
154
|
if not callable(register):
|
|
152
155
|
raise TypeError("入口必须导出 register(app) 函数")
|
|
@@ -168,6 +171,8 @@ try:
|
|
|
168
171
|
descriptor["name"] = plugin_name
|
|
169
172
|
if plugin_description is not None:
|
|
170
173
|
descriptor["description"] = plugin_description
|
|
174
|
+
if plugin_toolbox is not None:
|
|
175
|
+
descriptor["toolbox"] = plugin_toolbox
|
|
171
176
|
_send(descriptor)
|
|
172
177
|
|
|
173
178
|
if mode == "run":
|
|
@@ -233,6 +238,7 @@ function parseDescriptor(message) {
|
|
|
233
238
|
commands,
|
|
234
239
|
...(typeof message.name === 'string' ? { name: message.name } : {}),
|
|
235
240
|
...(typeof message.description === 'string' ? { description: message.description } : {}),
|
|
241
|
+
...(message.toolbox !== undefined ? { toolbox: message.toolbox } : {}),
|
|
236
242
|
};
|
|
237
243
|
}
|
|
238
244
|
function bridgedError(message) {
|
package/dist/socket.js
CHANGED
|
@@ -13,7 +13,8 @@ import os from 'node:os';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
import { StringDecoder } from 'node:string_decoder';
|
|
16
|
-
import {
|
|
16
|
+
import { listInstalledProviders } from '@mearl/provider/host';
|
|
17
|
+
import { readLiveProvider } from '@mearl/provider/runtime';
|
|
17
18
|
import { resolveFileRefs } from './fileRefs.js';
|
|
18
19
|
import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
|
|
19
20
|
import { resolveActionTimeoutSec } from './actionTimeouts.js';
|
|
@@ -95,12 +96,8 @@ function browserListFromProviderRecords(browsers) {
|
|
|
95
96
|
browsers: providerBrowsers,
|
|
96
97
|
};
|
|
97
98
|
}
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
? result.warnings.filter((warning) => typeof warning === 'string')
|
|
101
|
-
: [];
|
|
102
|
-
const merged = [...new Set([...resultWarnings, ...warnings])];
|
|
103
|
-
return { ...result, ...(merged.length > 0 ? { warnings: merged } : {}) };
|
|
99
|
+
function areInstalledProvidersRunning() {
|
|
100
|
+
return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
|
|
104
101
|
}
|
|
105
102
|
function isConnectedBrowser(browser) {
|
|
106
103
|
return browser.status === undefined || browser.status === 'connected';
|
|
@@ -243,11 +240,7 @@ export function invoke(action, data = {}, options = {}) {
|
|
|
243
240
|
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
244
241
|
const resolvedData = resolveFileRefs(data);
|
|
245
242
|
if (action === 'browser_list') {
|
|
246
|
-
|
|
247
|
-
const providerStartup = hasNativeAggregator
|
|
248
|
-
? Promise.resolve([])
|
|
249
|
-
: ensureInstalledProvidersRunning();
|
|
250
|
-
return providerStartup.then(providerWarnings => dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(mergeWarnings(result, providerWarnings))));
|
|
243
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(result));
|
|
251
244
|
}
|
|
252
245
|
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
253
246
|
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
|
|
@@ -325,7 +318,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
325
318
|
throw error;
|
|
326
319
|
}
|
|
327
320
|
}
|
|
328
|
-
if (action === 'browser_list' &&
|
|
321
|
+
if (action === 'browser_list' &&
|
|
322
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
323
|
+
areInstalledProvidersRunning()) {
|
|
329
324
|
return browserListFromProviderRecords(browsers);
|
|
330
325
|
}
|
|
331
326
|
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Unified Mearl SDK & CLI for local and remote browsers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"commander": "^15.0.0",
|
|
60
60
|
"fastest-levenshtein": "^1.0.16",
|
|
61
61
|
"ws": "^8.18.0",
|
|
62
|
-
"@mearl/cloud-types": "2.
|
|
63
|
-
"@mearl/provider": "2.
|
|
62
|
+
"@mearl/cloud-types": "2.18.0",
|
|
63
|
+
"@mearl/provider": "2.18.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^24.9.1",
|