@mearl/client 2.19.2 → 2.20.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 +35 -3
- package/dist/actionTimeouts.js +2 -0
- package/dist/artifacts.d.ts +17 -0
- package/dist/artifacts.js +80 -0
- package/dist/check.js +16 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +47 -3
- package/dist/commands.d.ts +138 -14
- package/dist/commands.js +276 -9
- package/dist/generated-browser-action-protocol.d.ts +122 -9
- package/dist/generated-browser-action-protocol.js +24 -6
- package/dist/index.d.ts +10 -1
- package/dist/index.js +15 -3
- package/dist/plugin.d.ts +5 -2
- package/dist/plugin.js +66 -21
- package/dist/pluginPageBundle.d.ts +1 -0
- package/dist/pluginPageBundle.js +54 -1
- package/dist/pluginRegistry.d.ts +5 -0
- package/dist/pluginRegistry.js +28 -0
- package/dist/pluginSource.d.ts +2 -0
- package/dist/pluginSource.js +8 -0
- package/dist/socket.d.ts +1 -0
- package/dist/socket.js +208 -52
- package/dist/unified.d.ts +17 -0
- package/dist/unified.js +119 -9
- package/package.json +3 -3
package/dist/pluginRegistry.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { COMMAND_MAP } from './commands.js';
|
|
6
6
|
import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
|
|
7
|
+
import { pluginFileVersion } from './pluginSource.js';
|
|
7
8
|
const RESERVED_NAMES = new Set([
|
|
8
9
|
'check',
|
|
9
10
|
'help',
|
|
@@ -19,6 +20,17 @@ export class PluginError extends Error {
|
|
|
19
20
|
this.code = code;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
export function pluginRegistrationNeedsRefresh(registration, includePage = false) {
|
|
24
|
+
if (!registration.source)
|
|
25
|
+
return true;
|
|
26
|
+
if (pluginFileVersion(registration.entry) !== registration.source.entry)
|
|
27
|
+
return true;
|
|
28
|
+
if (!includePage || !registration.toolbox)
|
|
29
|
+
return false;
|
|
30
|
+
if (!registration.source.page)
|
|
31
|
+
return true;
|
|
32
|
+
return Object.entries(registration.source.page).some(([path, version]) => pluginFileVersion(path) !== version);
|
|
33
|
+
}
|
|
22
34
|
export function pluginDirectory() {
|
|
23
35
|
return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
|
|
24
36
|
}
|
|
@@ -242,6 +254,7 @@ export function resolvePluginEntry(target) {
|
|
|
242
254
|
export function registerPlugin(name, target, description, options = {}) {
|
|
243
255
|
assertPluginName(name);
|
|
244
256
|
const entry = resolvePluginEntry(target);
|
|
257
|
+
const entryVersion = pluginFileVersion(entry);
|
|
245
258
|
const directory = pluginDirectory();
|
|
246
259
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
247
260
|
const file = join(directory, `${name}.json`);
|
|
@@ -268,6 +281,7 @@ export function registerPlugin(name, target, description, options = {}) {
|
|
|
268
281
|
}
|
|
269
282
|
const record = {
|
|
270
283
|
entry,
|
|
284
|
+
source: { entry: entryVersion, ...(builtPage ? { page: builtPage.sources } : {}) },
|
|
271
285
|
...(summary ? { description: summary } : {}),
|
|
272
286
|
...(managedBy ? { managedBy } : {}),
|
|
273
287
|
...(toolbox ? { toolbox } : {}),
|
|
@@ -324,12 +338,26 @@ export function findPluginRegistration(name) {
|
|
|
324
338
|
: undefined;
|
|
325
339
|
const toolbox = normalizePluginToolboxContribution(record.toolbox);
|
|
326
340
|
const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
|
|
341
|
+
const source = record.source;
|
|
342
|
+
if (source !== undefined &&
|
|
343
|
+
(!source ||
|
|
344
|
+
typeof source !== 'object' ||
|
|
345
|
+
Array.isArray(source) ||
|
|
346
|
+
typeof source.entry !== 'string' ||
|
|
347
|
+
(source.page !== undefined &&
|
|
348
|
+
(!source.page ||
|
|
349
|
+
typeof source.page !== 'object' ||
|
|
350
|
+
Array.isArray(source.page) ||
|
|
351
|
+
Object.entries(source.page).some(([path, version]) => !isAbsolute(path) || typeof version !== 'string'))))) {
|
|
352
|
+
throw new Error('invalid source');
|
|
353
|
+
}
|
|
327
354
|
return {
|
|
328
355
|
entry: record.entry,
|
|
329
356
|
...(description ? { description } : {}),
|
|
330
357
|
...(managedBy ? { managedBy } : {}),
|
|
331
358
|
...(toolbox ? { toolbox } : {}),
|
|
332
359
|
...(pageBundle ? { pageBundle } : {}),
|
|
360
|
+
...(source ? { source } : {}),
|
|
333
361
|
};
|
|
334
362
|
}
|
|
335
363
|
catch {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
/** 文件替换和同大小写入都需要使缓存失效;检测只读取文件元数据。 */
|
|
3
|
+
export function pluginFileVersion(path) {
|
|
4
|
+
const stat = statSync(path);
|
|
5
|
+
if (!stat.isFile())
|
|
6
|
+
throw new Error(`插件资源不是文件: ${path}`);
|
|
7
|
+
return `${stat.dev}-${stat.ino}-${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`;
|
|
8
|
+
}
|
package/dist/socket.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export declare const SOCKET_PATH: string;
|
|
|
17
17
|
export type ControlSource = 'local' | 'remote';
|
|
18
18
|
type CompatibleControlSource = ControlSource | 'cloud';
|
|
19
19
|
export interface InvokeOptions {
|
|
20
|
+
controlSessionId?: string;
|
|
20
21
|
/** 超时时间(秒),默认 15 */
|
|
21
22
|
timeoutSec?: number;
|
|
22
23
|
/** Force CDP transport instead of extension */
|
package/dist/socket.js
CHANGED
|
@@ -13,7 +13,7 @@ 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 { listInstalledProviders } from '@mearl/provider/host';
|
|
16
|
+
import { ensureProviderControlHost, listInstalledProviders } from '@mearl/provider/host';
|
|
17
17
|
import { readLiveProvider } from '@mearl/provider/runtime';
|
|
18
18
|
import { resolveFileRefs } from './fileRefs.js';
|
|
19
19
|
import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
|
|
@@ -32,6 +32,7 @@ export const SOCKET_PATH = process.platform === 'win32'
|
|
|
32
32
|
function toControlSourceWireValue(source) {
|
|
33
33
|
return source === 'remote' || source === 'cloud' ? 'cloud' : 'local';
|
|
34
34
|
}
|
|
35
|
+
const SESSION_OPTION = Symbol('control-session');
|
|
35
36
|
/**
|
|
36
37
|
* Process-wide default browser selector. Lets a long-lived host (e.g. the MCP
|
|
37
38
|
* server) pin a target once via `select_browser` without threading it through
|
|
@@ -96,13 +97,50 @@ function browserListFromProviderRecords(browsers) {
|
|
|
96
97
|
browsers: providerBrowsers,
|
|
97
98
|
};
|
|
98
99
|
}
|
|
100
|
+
function withDiscoveryWarning(result, error) {
|
|
101
|
+
const cause = error.cause ?? error;
|
|
102
|
+
const code = cause?.code;
|
|
103
|
+
const detail = code ?? (error instanceof Error ? error.message.split('\n')[0] : String(error));
|
|
104
|
+
return {
|
|
105
|
+
...result,
|
|
106
|
+
warnings: [
|
|
107
|
+
...(Array.isArray(result.warnings) ? result.warnings : []),
|
|
108
|
+
`Local CDP discovery could not complete: ${detail}`,
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function cdpOnlyBrowserList(result) {
|
|
113
|
+
const browsers = Array.isArray(result.browsers) ? result.browsers : [];
|
|
114
|
+
const cdpBrowsers = browsers.filter(browser => browser.transport === 'cdp');
|
|
115
|
+
return {
|
|
116
|
+
...result,
|
|
117
|
+
count: cdpBrowsers.length,
|
|
118
|
+
connectedCount: cdpBrowsers.filter(browser => browser.status === 'connected').length,
|
|
119
|
+
browsers: cdpBrowsers,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
99
122
|
function areInstalledProvidersRunning() {
|
|
100
123
|
return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
|
|
101
124
|
}
|
|
102
125
|
function isConnectedBrowser(browser) {
|
|
103
126
|
return browser.status === undefined || browser.status === 'connected';
|
|
104
127
|
}
|
|
128
|
+
function isOrdinaryLocalBrowser(browser) {
|
|
129
|
+
return (!browser.browserId.startsWith('managed:') &&
|
|
130
|
+
(browser.transport === 'extension' || browser.transport === 'cdp'));
|
|
131
|
+
}
|
|
105
132
|
function supportsAction(browser, action, data) {
|
|
133
|
+
if (browser.transport === 'provider') {
|
|
134
|
+
if (action === 'browser_control' ||
|
|
135
|
+
(action === 'browser_release' && data[SESSION_OPTION]))
|
|
136
|
+
return true;
|
|
137
|
+
if (action.startsWith('__artifact_'))
|
|
138
|
+
return (browser.capabilities?.actions.includes('page_upload') === true ||
|
|
139
|
+
((action === '__artifact_read' || action === '__artifact_remove') &&
|
|
140
|
+
browser.capabilities?.actions.includes('page_download') === true));
|
|
141
|
+
if (action === 'run_actions' && data[SESSION_OPTION])
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
106
144
|
if (action === 'get_versions')
|
|
107
145
|
return true;
|
|
108
146
|
if (action === 'get_browser_access_url') {
|
|
@@ -141,47 +179,62 @@ function selectBrowserResult(data) {
|
|
|
141
179
|
}
|
|
142
180
|
/** Deduplicates concurrent recovery attempts in long-lived clients such as the MCP server. */
|
|
143
181
|
let persistentSpawnPromise = null;
|
|
182
|
+
let discoveryListPromise = null;
|
|
183
|
+
let discoveryListCache = null;
|
|
184
|
+
const DISCOVERY_STARTUP_TIMEOUT_MS = 3_000;
|
|
185
|
+
const DISCOVERY_RETRY_INTERVAL_MS = 50;
|
|
186
|
+
const DISCOVERY_MISS_CACHE_MS = 2_000;
|
|
187
|
+
function instanceSocketPath(pid) {
|
|
188
|
+
return process.platform === 'win32'
|
|
189
|
+
? `\\\\.\\pipe\\mearl-agent-${pid}`
|
|
190
|
+
: path.join(os.tmpdir(), `mearl-agent-${pid}.sock`);
|
|
191
|
+
}
|
|
192
|
+
function discoveryCacheKey() {
|
|
193
|
+
const browsers = listLiveBrowsers()
|
|
194
|
+
.map(browser => [
|
|
195
|
+
browser.browserId,
|
|
196
|
+
browser.pid,
|
|
197
|
+
browser.socketPath,
|
|
198
|
+
browser.status,
|
|
199
|
+
browser.updatedAt,
|
|
200
|
+
])
|
|
201
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
202
|
+
const providers = listInstalledProviders()
|
|
203
|
+
.map(provider => provider.providerId)
|
|
204
|
+
.sort();
|
|
205
|
+
return JSON.stringify({ browsers, providers });
|
|
206
|
+
}
|
|
207
|
+
async function spawnNativeHost(args) {
|
|
208
|
+
const { spawn } = await import('node:child_process');
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const child = spawn('mearl-native-host', args, {
|
|
211
|
+
detached: true,
|
|
212
|
+
stdio: 'ignore',
|
|
213
|
+
windowsHide: true,
|
|
214
|
+
});
|
|
215
|
+
child.once('error', reject);
|
|
216
|
+
child.once('spawn', () => {
|
|
217
|
+
child.unref();
|
|
218
|
+
resolve(child.pid);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
144
222
|
/**
|
|
145
223
|
* Attempt to spawn `mearl-native-host` in the background (detached).
|
|
146
|
-
* Browser discovery hosts probe the default local CDP endpoint for browser_list and only
|
|
147
|
-
* persist when a real browser is available.
|
|
148
224
|
* Returns true if a spawn was attempted (regardless of success).
|
|
149
225
|
*/
|
|
150
|
-
async function trySpawnNativeHost(
|
|
151
|
-
|
|
152
|
-
if (persistent && persistentSpawnPromise)
|
|
226
|
+
async function trySpawnNativeHost() {
|
|
227
|
+
if (persistentSpawnPromise)
|
|
153
228
|
return persistentSpawnPromise;
|
|
154
229
|
const spawnPromise = (async () => {
|
|
155
230
|
try {
|
|
156
|
-
|
|
157
|
-
return
|
|
158
|
-
const args = persistent ? ['--daemon'] : ['--daemon', '--discover-cdp'];
|
|
159
|
-
const child = spawn('mearl-native-host', args, {
|
|
160
|
-
detached: true,
|
|
161
|
-
stdio: 'ignore',
|
|
162
|
-
windowsHide: true,
|
|
163
|
-
});
|
|
164
|
-
let settled = false;
|
|
165
|
-
const finish = (ok) => {
|
|
166
|
-
if (settled)
|
|
167
|
-
return;
|
|
168
|
-
settled = true;
|
|
169
|
-
resolve(ok);
|
|
170
|
-
};
|
|
171
|
-
// `spawn ENOENT` 等错误会通过事件异步抛出;必须显式监听避免进程崩溃。
|
|
172
|
-
child.once('error', () => finish(false));
|
|
173
|
-
child.once('spawn', () => {
|
|
174
|
-
child.unref();
|
|
175
|
-
finish(true);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
231
|
+
await spawnNativeHost(['--daemon']);
|
|
232
|
+
return true;
|
|
178
233
|
}
|
|
179
234
|
catch {
|
|
180
235
|
return false;
|
|
181
236
|
}
|
|
182
237
|
})();
|
|
183
|
-
if (!persistent)
|
|
184
|
-
return spawnPromise;
|
|
185
238
|
persistentSpawnPromise = spawnPromise;
|
|
186
239
|
try {
|
|
187
240
|
return await spawnPromise;
|
|
@@ -191,6 +244,54 @@ async function trySpawnNativeHost(mode = 'control') {
|
|
|
191
244
|
persistentSpawnPromise = null;
|
|
192
245
|
}
|
|
193
246
|
}
|
|
247
|
+
async function discoverLocalBrowserList(timeoutSec, controlSource) {
|
|
248
|
+
if (discoveryListPromise)
|
|
249
|
+
return discoveryListPromise;
|
|
250
|
+
const cacheKey = discoveryCacheKey();
|
|
251
|
+
if (discoveryListCache &&
|
|
252
|
+
discoveryListCache.key === cacheKey &&
|
|
253
|
+
Date.now() < discoveryListCache.until) {
|
|
254
|
+
if (discoveryListCache.error)
|
|
255
|
+
throw discoveryListCache.error;
|
|
256
|
+
return discoveryListCache.result;
|
|
257
|
+
}
|
|
258
|
+
discoveryListCache = null;
|
|
259
|
+
const request = (async () => {
|
|
260
|
+
const pid = await spawnNativeHost(['--daemon', '--discover-cdp']);
|
|
261
|
+
if (pid === undefined)
|
|
262
|
+
throw new Error('Native host did not report its process id');
|
|
263
|
+
const socketPath = instanceSocketPath(pid);
|
|
264
|
+
const deadline = Date.now() + Math.min(timeoutSec * 1_000, DISCOVERY_STARTUP_TIMEOUT_MS);
|
|
265
|
+
while (true) {
|
|
266
|
+
try {
|
|
267
|
+
return await invokeOnce('browser_list', {}, timeoutSec, undefined, socketPath, controlSource);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (!isConnError(error) || Date.now() >= deadline)
|
|
271
|
+
throw error;
|
|
272
|
+
await new Promise(resolve => setTimeout(resolve, DISCOVERY_RETRY_INTERVAL_MS));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
discoveryListPromise = request;
|
|
277
|
+
try {
|
|
278
|
+
const result = await request;
|
|
279
|
+
if (!result.browsers?.some((browser) => browser.transport === 'cdp' &&
|
|
280
|
+
browser.status === 'connected' &&
|
|
281
|
+
!browser.browserId?.startsWith('managed:'))) {
|
|
282
|
+
discoveryListCache = { key: cacheKey, result, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
discoveryListCache = { key: cacheKey, error, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (discoveryListPromise === request)
|
|
292
|
+
discoveryListPromise = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
194
295
|
function readOwnedLegacySocketIdentity() {
|
|
195
296
|
if (process.platform === 'win32')
|
|
196
297
|
return null;
|
|
@@ -239,8 +340,9 @@ export function invoke(action, data = {}, options = {}) {
|
|
|
239
340
|
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
|
|
240
341
|
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
241
342
|
const resolvedData = resolveFileRefs(data);
|
|
343
|
+
Object.defineProperty(resolvedData, SESSION_OPTION, { value: options.controlSessionId });
|
|
242
344
|
if (action === 'browser_list') {
|
|
243
|
-
return dispatchInvoke(action, resolvedData, timeoutSec,
|
|
345
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
|
|
244
346
|
}
|
|
245
347
|
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
246
348
|
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
|
|
@@ -252,6 +354,11 @@ function isConnError(error) {
|
|
|
252
354
|
function isSocketSandboxError(error) {
|
|
253
355
|
return error?.code === 'EPERM';
|
|
254
356
|
}
|
|
357
|
+
function isUnavailableConnection(error) {
|
|
358
|
+
const cause = error.cause ?? error;
|
|
359
|
+
const code = cause?.code;
|
|
360
|
+
return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
|
|
361
|
+
}
|
|
255
362
|
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
|
|
256
363
|
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
|
|
257
364
|
if (!selector && configuredSocketPath) {
|
|
@@ -269,6 +376,44 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
269
376
|
}
|
|
270
377
|
const browsers = listLiveBrowsers();
|
|
271
378
|
const controllableBrowsers = browsers.filter(isConnectedBrowser);
|
|
379
|
+
if (action === 'browser_list' && transport === 'cdp') {
|
|
380
|
+
const registeredCdp = controllableBrowsers.find(browser => browser.transport === 'cdp' && isOrdinaryLocalBrowser(browser));
|
|
381
|
+
if (registeredCdp) {
|
|
382
|
+
try {
|
|
383
|
+
return await invokeOnce(action, data, timeoutSec, transport, registeredCdp.socketPath, controlSource);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (!isConnError(error))
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (isUnavailableConnection(error) &&
|
|
395
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
396
|
+
areInstalledProvidersRunning()) {
|
|
397
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// A Provider record can keep the registry nonempty without representing a local
|
|
403
|
+
// browser transport. Let the discovery daemon probe CDP before returning that inventory.
|
|
404
|
+
if (action === 'browser_list' && !controllableBrowsers.some(isOrdinaryLocalBrowser)) {
|
|
405
|
+
try {
|
|
406
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (isUnavailableConnection(error) &&
|
|
410
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
411
|
+
areInstalledProvidersRunning()) {
|
|
412
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
272
417
|
// Explicit target: must match a live browser; do not silently fall back.
|
|
273
418
|
if (selector) {
|
|
274
419
|
const match = findBrowser(browsers, selector);
|
|
@@ -281,14 +426,15 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
281
426
|
if (match.status === 'pairing' &&
|
|
282
427
|
action !== 'get_versions' &&
|
|
283
428
|
action !== 'browser_close' &&
|
|
284
|
-
action !== 'browser_release'
|
|
429
|
+
action !== 'browser_release' &&
|
|
430
|
+
action !== 'browser_control') {
|
|
285
431
|
throw new Error(`Browser ${describeBrowser(match)} is waiting for device pairing`);
|
|
286
432
|
}
|
|
287
433
|
if (!supportsAction(match, action, data)) {
|
|
288
434
|
throw new Error(`Browser ${describeBrowser(match)} does not support ${action}`);
|
|
289
435
|
}
|
|
290
436
|
try {
|
|
291
|
-
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource, match.
|
|
437
|
+
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource, match.transport === 'provider' ? match.browserId : undefined);
|
|
292
438
|
}
|
|
293
439
|
catch (error) {
|
|
294
440
|
if (isConnError(error) || isSocketSandboxError(error)) {
|
|
@@ -301,12 +447,13 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
301
447
|
}
|
|
302
448
|
// No explicit target: prefer the most-recently-focused live browser; on a
|
|
303
449
|
// stale socket, fall through to the legacy path + auto-spawn.
|
|
304
|
-
const routableBrowsers = controllableBrowsers.filter(browser =>
|
|
450
|
+
const routableBrowsers = controllableBrowsers.filter(browser => (action !== 'browser_list' || browser.transport !== 'provider') &&
|
|
451
|
+
!(transport === 'cdp' && browser.transport === 'provider') &&
|
|
305
452
|
supportsAction(browser, action, data));
|
|
306
453
|
if (routableBrowsers.length > 0) {
|
|
307
454
|
const target = routableBrowsers[0];
|
|
308
455
|
try {
|
|
309
|
-
return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.
|
|
456
|
+
return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
|
|
310
457
|
}
|
|
311
458
|
catch (error) {
|
|
312
459
|
if (isSocketSandboxError(error)) {
|
|
@@ -318,10 +465,18 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
318
465
|
throw error;
|
|
319
466
|
}
|
|
320
467
|
}
|
|
321
|
-
if (action === 'browser_list'
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
468
|
+
if (action === 'browser_list') {
|
|
469
|
+
try {
|
|
470
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (isUnavailableConnection(error) &&
|
|
474
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
475
|
+
areInstalledProvidersRunning()) {
|
|
476
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
325
480
|
}
|
|
326
481
|
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
|
|
327
482
|
}
|
|
@@ -337,21 +492,15 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
|
|
|
337
492
|
if (shouldAttemptSpawn && !isRetry) {
|
|
338
493
|
if (err.code === 'ECONNREFUSED')
|
|
339
494
|
removeStaleLegacySocket(legacySocketIdentity);
|
|
340
|
-
const spawned = await trySpawnNativeHost(
|
|
495
|
+
const spawned = await trySpawnNativeHost();
|
|
341
496
|
if (spawned) {
|
|
342
|
-
// Wait for the native host to start listening. Discovery retries the legacy socket directly
|
|
343
|
-
// so the spawned host receives the probe even if another browser registers in the meantime.
|
|
344
497
|
await new Promise(r => setTimeout(r, 1200));
|
|
345
|
-
if (action === 'browser_list') {
|
|
346
|
-
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
|
|
347
|
-
/* isRetry */ true);
|
|
348
|
-
}
|
|
349
498
|
const browsers = listLiveBrowsers();
|
|
350
499
|
const target = browsers.find(browser => isConnectedBrowser(browser) &&
|
|
351
500
|
!(transport === 'cdp' && browser.transport === 'provider') &&
|
|
352
501
|
supportsAction(browser, action, data));
|
|
353
502
|
if (target) {
|
|
354
|
-
return invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.
|
|
503
|
+
return invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
|
|
355
504
|
}
|
|
356
505
|
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
|
|
357
506
|
/* isRetry */ true);
|
|
@@ -362,7 +511,11 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
|
|
|
362
511
|
throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });
|
|
363
512
|
}
|
|
364
513
|
}
|
|
365
|
-
function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource,
|
|
514
|
+
async function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource, providerTarget) {
|
|
515
|
+
if (providerTarget) {
|
|
516
|
+
const host = await ensureProviderControlHost(Math.min(timeoutSec * 1000, 10_000));
|
|
517
|
+
socketPath = host.socketPath;
|
|
518
|
+
}
|
|
366
519
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
367
520
|
return new Promise((resolve, reject) => {
|
|
368
521
|
if (process.platform !== 'win32' && fs.existsSync(socketPath) && process.getuid) {
|
|
@@ -401,10 +554,12 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSour
|
|
|
401
554
|
version: CLIENT_VERSION,
|
|
402
555
|
controlSource: toControlSourceWireValue(controlSource),
|
|
403
556
|
};
|
|
557
|
+
if (data[SESSION_OPTION])
|
|
558
|
+
message.controlSessionId = data[SESSION_OPTION];
|
|
404
559
|
if (transport)
|
|
405
560
|
message.transport = transport;
|
|
406
|
-
if (
|
|
407
|
-
message.
|
|
561
|
+
if (providerTarget)
|
|
562
|
+
message.providerTarget = providerTarget;
|
|
408
563
|
socket.write(JSON.stringify(message) + '\n');
|
|
409
564
|
});
|
|
410
565
|
socket.on('data', chunk => {
|
|
@@ -453,7 +608,8 @@ function formatConnectError(error, socketPath) {
|
|
|
453
608
|
if (error.code === 'ENOENT') {
|
|
454
609
|
return [
|
|
455
610
|
`未找到 Socket: ${socketPath}`,
|
|
456
|
-
'native host
|
|
611
|
+
'native host 自动拉起失败。先运行 mearl check --local;缺少命令时运行 npx @mearl/setup --local --yes。',
|
|
612
|
+
'自动拉起只使用默认 daemon 参数;自定义 CDP/Profile/浏览器参数需手工启动,参见 mearl-native-host --help。',
|
|
457
613
|
'',
|
|
458
614
|
'【方式一:浏览器插件模式】',
|
|
459
615
|
`1. 确认已安装 Mearl 浏览器插件(${EXTENSION_INSTALL_URL})`,
|
|
@@ -469,7 +625,7 @@ function formatConnectError(error, socketPath) {
|
|
|
469
625
|
if (error.code === 'ECONNREFUSED') {
|
|
470
626
|
return [
|
|
471
627
|
`Socket 连接被拒绝: ${socketPath}`,
|
|
472
|
-
'native host
|
|
628
|
+
'已尝试处理 stale socket,但 native host 自动恢复仍失败。请运行 mearl check --local 定位故障。',
|
|
473
629
|
'请确认 Chrome 浏览器正在运行,可尝试在 chrome://extensions 重新加载插件。',
|
|
474
630
|
'',
|
|
475
631
|
installHint,
|
package/dist/unified.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { BrowserArtifact } from './generated-browser-action-protocol.js';
|
|
1
2
|
import { type CloudConnectorListResult } from '@mearl/cloud-types';
|
|
2
3
|
import type { PluginInvocation, PluginInvokeOptions } from './plugin.js';
|
|
3
4
|
export interface UnifiedInvokeOptions {
|
|
5
|
+
/** Use new on the first tab_open/page_snapshot, then reuse the returned id. */
|
|
6
|
+
session?: string;
|
|
7
|
+
/** Source-local session id used by transport adapters. */
|
|
8
|
+
controlSessionId?: string;
|
|
4
9
|
timeoutSec?: number;
|
|
5
10
|
/** Global browser id, source-local browser id, or unique browser name. */
|
|
6
11
|
browser?: string;
|
|
@@ -25,6 +30,11 @@ interface BrowserTarget {
|
|
|
25
30
|
}
|
|
26
31
|
export declare function decodeBrowserTarget(selector: string | undefined): BrowserTarget | undefined;
|
|
27
32
|
export declare function normalizeBrowserSelector(selector: string): string;
|
|
33
|
+
export declare function decodeControlSessionId(id: string): {
|
|
34
|
+
browser: string;
|
|
35
|
+
id: string;
|
|
36
|
+
transport: 'extension' | 'cdp' | 'provider';
|
|
37
|
+
};
|
|
28
38
|
export declare class UnifiedClient {
|
|
29
39
|
private readonly options;
|
|
30
40
|
private defaultBrowser;
|
|
@@ -39,6 +49,13 @@ export declare class UnifiedClient {
|
|
|
39
49
|
disconnect(): void;
|
|
40
50
|
invokePlugin(name: string, invocation: PluginInvocation, options?: PluginInvokeOptions): Promise<unknown>;
|
|
41
51
|
invoke(action: string, data?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
|
|
52
|
+
saveArtifact(artifact: BrowserArtifact & {
|
|
53
|
+
browserId?: string;
|
|
54
|
+
}, outputPath: string, options?: UnifiedInvokeOptions): Promise<{
|
|
55
|
+
outputPath: string;
|
|
56
|
+
bytes: number;
|
|
57
|
+
}>;
|
|
58
|
+
private invokeRouted;
|
|
42
59
|
private invokeExplicitLocal;
|
|
43
60
|
private invokeLaunchWithoutControlBrowser;
|
|
44
61
|
private selectDefaultBrowser;
|