@phnx-labs/agents-cli 1.22.94 → 1.22.95
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/CHANGELOG.md +20 -2
- package/dist/commands/browser.js +19 -8
- package/dist/lib/browser/chrome.d.ts +17 -0
- package/dist/lib/browser/chrome.js +121 -16
- package/dist/lib/browser/chromium-discovery.d.ts +27 -0
- package/dist/lib/browser/chromium-discovery.js +96 -0
- package/dist/lib/browser/drivers/firefox.d.ts +103 -0
- package/dist/lib/browser/drivers/firefox.js +377 -0
- package/dist/lib/browser/drivers/local.d.ts +8 -0
- package/dist/lib/browser/drivers/local.js +38 -3
- package/dist/lib/browser/firefox-discovery.d.ts +68 -0
- package/dist/lib/browser/firefox-discovery.js +162 -0
- package/dist/lib/browser/profiles.d.ts +36 -2
- package/dist/lib/browser/profiles.js +200 -55
- package/dist/lib/browser/resolve-target.js +7 -1
- package/dist/lib/browser/service.d.ts +51 -2
- package/dist/lib/browser/service.js +487 -14
- package/dist/lib/browser/types.d.ts +32 -3
- package/dist/lib/daemon/usage-sync-service.js +12 -0
- package/dist/lib/open-url.js +6 -0
- package/dist/lib/types.d.ts +13 -1
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ import { resolveActor } from '../actor.js';
|
|
|
23
23
|
import { recordBrowserSession } from '../session/db.js';
|
|
24
24
|
import { sshExecAsync } from '../ssh-exec.js';
|
|
25
25
|
import { resolveFfmpeg } from './ffmpeg.js';
|
|
26
|
+
import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
|
|
26
27
|
import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
|
|
27
28
|
/**
|
|
28
29
|
* Canonical form for comparing a requested URL against what CDP reports for a
|
|
@@ -235,6 +236,11 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
235
236
|
if (conn.backend === 'arc-native') {
|
|
236
237
|
return isArcRunning();
|
|
237
238
|
}
|
|
239
|
+
// Firefox BiDi connections: healthy while the WebSocket is open (a killed
|
|
240
|
+
// Firefox closes it). No lightweight round-trip verb is needed.
|
|
241
|
+
if (conn.backend === 'bidi') {
|
|
242
|
+
return conn.bidi.isOpen;
|
|
243
|
+
}
|
|
238
244
|
if (!conn.cdp.isOpen)
|
|
239
245
|
return false;
|
|
240
246
|
try {
|
|
@@ -248,9 +254,18 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
248
254
|
return false;
|
|
249
255
|
}
|
|
250
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Narrow `conn` to the CDP backend, or throw the backend's capability error.
|
|
259
|
+
* Because the union now carries `arc-native` AND `bidi`, every action that
|
|
260
|
+
* reaches `conn.cdp` without a preceding backend branch fails to type-check —
|
|
261
|
+
* the compiler is the checklist that no verb silently calls CDP on a
|
|
262
|
+
* non-CDP connection.
|
|
263
|
+
*/
|
|
251
264
|
function requireCdp(conn, capability) {
|
|
252
265
|
if (conn.backend === 'arc-native')
|
|
253
266
|
throw new ArcNativeCapabilityError(capability);
|
|
267
|
+
if (conn.backend === 'bidi')
|
|
268
|
+
throw new FirefoxCapabilityError(capability);
|
|
254
269
|
}
|
|
255
270
|
/** Join error lines so callers get a next command, not a dead-end message. */
|
|
256
271
|
export function actionable(...lines) {
|
|
@@ -536,7 +551,7 @@ export class BrowserService {
|
|
|
536
551
|
// pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
|
|
537
552
|
// the branch only fires when the profile has no page target at all.
|
|
538
553
|
let startupBlankTargetId;
|
|
539
|
-
if (!opts.url && !conn.electron && conn.backend !== 'arc-native') {
|
|
554
|
+
if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
540
555
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
541
556
|
if (!targetInfos.some((t) => t.type === 'page')) {
|
|
542
557
|
const created = await this.createPageTarget(conn, {
|
|
@@ -581,7 +596,7 @@ export class BrowserService {
|
|
|
581
596
|
task.currentTabId = shortId;
|
|
582
597
|
}
|
|
583
598
|
// For Electron, get the existing window as the tab
|
|
584
|
-
if (conn.electron && conn.backend !== 'arc-native') {
|
|
599
|
+
if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
585
600
|
const windowId = await this.getOrCreateWindow(conn);
|
|
586
601
|
if (windowId) {
|
|
587
602
|
const shortId = generateShortId();
|
|
@@ -633,7 +648,14 @@ export class BrowserService {
|
|
|
633
648
|
// opening a fresh target is created:true.
|
|
634
649
|
let tabId;
|
|
635
650
|
let firstOpen;
|
|
636
|
-
if (opts.url && conn.backend
|
|
651
|
+
if (opts.url && conn.backend === 'bidi') {
|
|
652
|
+
// Firefox: the implicit first navigate opens a fresh tab (no adopt path —
|
|
653
|
+
// Firefox tabs aren't shared across abandoned tasks the way CDP ones are).
|
|
654
|
+
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
655
|
+
tabId = result.tabId;
|
|
656
|
+
firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
|
|
657
|
+
}
|
|
658
|
+
else if (opts.url && conn.backend !== 'arc-native' && conn.backend !== 'bidi' && !conn.electron && conn.browserType !== 'arc') {
|
|
637
659
|
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
638
660
|
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
639
661
|
const shortId = generateShortId();
|
|
@@ -884,6 +906,21 @@ export class BrowserService {
|
|
|
884
906
|
await this.saveToHistory(task, Array.from(domains));
|
|
885
907
|
await this.closeArcNativeTabs(conn, task);
|
|
886
908
|
}
|
|
909
|
+
else if (conn.backend === 'bidi') {
|
|
910
|
+
try {
|
|
911
|
+
for (const tab of await this.listFirefoxTaskTabs(conn, task)) {
|
|
912
|
+
try {
|
|
913
|
+
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
914
|
+
if (domain && domain !== 'blank')
|
|
915
|
+
domains.add(domain);
|
|
916
|
+
}
|
|
917
|
+
catch { /* invalid URL */ }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch { /* Firefox not responding */ }
|
|
921
|
+
await this.saveToHistory(task, Array.from(domains));
|
|
922
|
+
await this.closeFirefoxTabs(conn, task);
|
|
923
|
+
}
|
|
887
924
|
else {
|
|
888
925
|
try {
|
|
889
926
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
@@ -931,7 +968,12 @@ export class BrowserService {
|
|
|
931
968
|
});
|
|
932
969
|
}).catch(() => { });
|
|
933
970
|
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
934
|
-
if (conn.backend
|
|
971
|
+
if (conn.backend === 'bidi') {
|
|
972
|
+
conn.bidi.close();
|
|
973
|
+
if (conn.pid)
|
|
974
|
+
killChrome(conn.pid);
|
|
975
|
+
}
|
|
976
|
+
else if (conn.backend !== 'arc-native') {
|
|
935
977
|
conn.cdp.close();
|
|
936
978
|
killChrome(conn.pid);
|
|
937
979
|
}
|
|
@@ -976,6 +1018,18 @@ export class BrowserService {
|
|
|
976
1018
|
clearProfileRuntime(key);
|
|
977
1019
|
continue;
|
|
978
1020
|
}
|
|
1021
|
+
// Firefox: close the BiDi socket and kill the Firefox WE launched (pid !=
|
|
1022
|
+
// 0). An attached Firefox the user started (pid 0) is left running — the
|
|
1023
|
+
// CLI does not own that process, same as Arc.
|
|
1024
|
+
if (conn.backend === 'bidi') {
|
|
1025
|
+
conn.bidi.close();
|
|
1026
|
+
if (conn.pid)
|
|
1027
|
+
killChrome(conn.pid);
|
|
1028
|
+
conn.cleanup?.();
|
|
1029
|
+
this.connections.delete(key);
|
|
1030
|
+
clearProfileRuntime(key);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
979
1033
|
conn.cdp.close();
|
|
980
1034
|
killChrome(conn.pid);
|
|
981
1035
|
conn.cleanup?.();
|
|
@@ -1006,6 +1060,10 @@ export class BrowserService {
|
|
|
1006
1060
|
if (conn.backend === 'arc-native') {
|
|
1007
1061
|
return this.navigateArcNative(conn, task, url);
|
|
1008
1062
|
}
|
|
1063
|
+
// Firefox BiDi backend (PHNX-4043).
|
|
1064
|
+
if (conn.backend === 'bidi') {
|
|
1065
|
+
return this.navigateFirefox(conn, task, url);
|
|
1066
|
+
}
|
|
1009
1067
|
requireCdp(conn, 'navigate');
|
|
1010
1068
|
// Same-task reopen (PHNX-2399): the URL is already live in a tab this task
|
|
1011
1069
|
// owns → reload THAT tab in place and keep its id, before the create/reuse
|
|
@@ -1111,6 +1169,9 @@ export class BrowserService {
|
|
|
1111
1169
|
if (conn.backend === 'arc-native') {
|
|
1112
1170
|
return this.navigateArcNative(conn, task, url, true);
|
|
1113
1171
|
}
|
|
1172
|
+
if (conn.backend === 'bidi') {
|
|
1173
|
+
return this.navigateFirefox(conn, task, url, true);
|
|
1174
|
+
}
|
|
1114
1175
|
requireCdp(conn, 'createTab');
|
|
1115
1176
|
if (conn.electron) {
|
|
1116
1177
|
throw new Error('Electron apps do not support opening additional tabs');
|
|
@@ -1145,6 +1206,12 @@ export class BrowserService {
|
|
|
1145
1206
|
});
|
|
1146
1207
|
return { tabId: resolvedTabId };
|
|
1147
1208
|
}
|
|
1209
|
+
if (conn.backend === 'bidi') {
|
|
1210
|
+
await bidiActivate(conn.bidi, this.firefoxContext(task, resolvedTabId));
|
|
1211
|
+
task.currentTabId = resolvedTabId;
|
|
1212
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1213
|
+
return { tabId: resolvedTabId };
|
|
1214
|
+
}
|
|
1148
1215
|
task.currentTabId = resolvedTabId;
|
|
1149
1216
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
1150
1217
|
return { tabId: resolvedTabId };
|
|
@@ -1154,6 +1221,11 @@ export class BrowserService {
|
|
|
1154
1221
|
if (conn.backend === 'arc-native') {
|
|
1155
1222
|
return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
|
|
1156
1223
|
}
|
|
1224
|
+
if (conn.backend === 'bidi') {
|
|
1225
|
+
return (await this.listFirefoxTaskTabs(conn, task)).map((tab) => ({
|
|
1226
|
+
id: tab.id, url: tab.url, title: tab.title, current: tab.current === true,
|
|
1227
|
+
}));
|
|
1228
|
+
}
|
|
1157
1229
|
requireCdp(conn, 'enumerate');
|
|
1158
1230
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1159
1231
|
const tabs = [];
|
|
@@ -1185,6 +1257,9 @@ export class BrowserService {
|
|
|
1185
1257
|
// and title are display data and must never become fallback identities.
|
|
1186
1258
|
if (conn.backend === 'arc-native')
|
|
1187
1259
|
throw new Error(`Tab "${hint}" not found`);
|
|
1260
|
+
// Firefox tabs are addressed by the task's stable short id too.
|
|
1261
|
+
if (conn.backend === 'bidi')
|
|
1262
|
+
throw new Error(`Tab "${hint}" not found`);
|
|
1188
1263
|
// URL substring match
|
|
1189
1264
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1190
1265
|
const matches = [];
|
|
@@ -1219,6 +1294,8 @@ export class BrowserService {
|
|
|
1219
1294
|
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1220
1295
|
if (conn.backend === 'arc-native')
|
|
1221
1296
|
return this.listArcTaskTabs(task);
|
|
1297
|
+
if (conn.backend === 'bidi')
|
|
1298
|
+
return this.listFirefoxTaskTabs(conn, task);
|
|
1222
1299
|
return this.getTabsForTask(conn.cdp, task);
|
|
1223
1300
|
}
|
|
1224
1301
|
const allTabs = [];
|
|
@@ -1226,7 +1303,9 @@ export class BrowserService {
|
|
|
1226
1303
|
for (const [, task] of conn.tasks) {
|
|
1227
1304
|
const tabs = conn.backend === 'arc-native'
|
|
1228
1305
|
? await this.listArcTaskTabs(task)
|
|
1229
|
-
:
|
|
1306
|
+
: conn.backend === 'bidi'
|
|
1307
|
+
? await this.listFirefoxTaskTabs(conn, task)
|
|
1308
|
+
: await this.getTabsForTask(conn.cdp, task);
|
|
1230
1309
|
allTabs.push(...tabs);
|
|
1231
1310
|
}
|
|
1232
1311
|
}
|
|
@@ -1257,6 +1336,30 @@ export class BrowserService {
|
|
|
1257
1336
|
});
|
|
1258
1337
|
return;
|
|
1259
1338
|
}
|
|
1339
|
+
if (conn.backend === 'bidi') {
|
|
1340
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
1341
|
+
const ids = tabHint === undefined
|
|
1342
|
+
? Object.keys(task.tabs)
|
|
1343
|
+
: [await this.resolveTabHint(conn, task, tabHint)];
|
|
1344
|
+
for (const shortId of ids) {
|
|
1345
|
+
const context = task.tabs[shortId];
|
|
1346
|
+
// A borrowed tab is released, not closed — it outlives this task.
|
|
1347
|
+
if (context && !borrowed.has(shortId)) {
|
|
1348
|
+
try {
|
|
1349
|
+
await bidiCloseTab(conn.bidi, context);
|
|
1350
|
+
}
|
|
1351
|
+
catch { /* already closed */ }
|
|
1352
|
+
}
|
|
1353
|
+
delete task.tabs[shortId];
|
|
1354
|
+
delete task.refDescriptors?.[shortId];
|
|
1355
|
+
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
1356
|
+
}
|
|
1357
|
+
if (!task.currentTabId || ids.includes(task.currentTabId)) {
|
|
1358
|
+
task.currentTabId = Object.keys(task.tabs).at(-1);
|
|
1359
|
+
}
|
|
1360
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1260
1363
|
requireCdp(conn, 'closeTab');
|
|
1261
1364
|
if (tabHint !== undefined) {
|
|
1262
1365
|
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
@@ -1298,6 +1401,10 @@ export class BrowserService {
|
|
|
1298
1401
|
if (conn.backend === 'arc-native') {
|
|
1299
1402
|
return this.evaluateArcNative(conn, task, shortId, expression);
|
|
1300
1403
|
}
|
|
1404
|
+
// Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
|
|
1405
|
+
if (conn.backend === 'bidi') {
|
|
1406
|
+
return this.evaluateFirefox(conn, task, shortId, expression);
|
|
1407
|
+
}
|
|
1301
1408
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1302
1409
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1303
1410
|
if (!target) {
|
|
@@ -1334,6 +1441,10 @@ export class BrowserService {
|
|
|
1334
1441
|
'This task still belongs to the Arc profile. ' +
|
|
1335
1442
|
'Use a Chromium-family browser for screenshot capability.');
|
|
1336
1443
|
}
|
|
1444
|
+
// Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
|
|
1445
|
+
if (conn.backend === 'bidi') {
|
|
1446
|
+
return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
|
|
1447
|
+
}
|
|
1337
1448
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1338
1449
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1339
1450
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1731,6 +1842,14 @@ export class BrowserService {
|
|
|
1731
1842
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
1732
1843
|
return result;
|
|
1733
1844
|
}
|
|
1845
|
+
// Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
|
|
1846
|
+
// uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
|
|
1847
|
+
if (conn.backend === 'bidi') {
|
|
1848
|
+
const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
|
|
1849
|
+
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1850
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1851
|
+
return result;
|
|
1852
|
+
}
|
|
1734
1853
|
requireCdp(conn, 'enumerate');
|
|
1735
1854
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1736
1855
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1797,6 +1916,19 @@ export class BrowserService {
|
|
|
1797
1916
|
await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
|
|
1798
1917
|
return healed ? { healed } : {};
|
|
1799
1918
|
}
|
|
1919
|
+
if (conn.backend === 'bidi') {
|
|
1920
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1921
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1922
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1923
|
+
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1924
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
1925
|
+
if (!selector)
|
|
1926
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1927
|
+
const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
|
|
1928
|
+
const context = this.firefoxContext(task, shortId);
|
|
1929
|
+
await bidiClickAt(conn.bidi, context, coords.x, coords.y);
|
|
1930
|
+
return healed ? { healed } : {};
|
|
1931
|
+
}
|
|
1800
1932
|
requireCdp(conn, 'click');
|
|
1801
1933
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1802
1934
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1852,6 +1984,17 @@ export class BrowserService {
|
|
|
1852
1984
|
await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
1853
1985
|
return;
|
|
1854
1986
|
}
|
|
1987
|
+
if (conn.backend === 'bidi') {
|
|
1988
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1989
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1990
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1991
|
+
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1992
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
1993
|
+
if (!selector)
|
|
1994
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1995
|
+
await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1855
1998
|
requireCdp(conn, 'type');
|
|
1856
1999
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1857
2000
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1911,6 +2054,10 @@ export class BrowserService {
|
|
|
1911
2054
|
await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
1912
2055
|
return;
|
|
1913
2056
|
}
|
|
2057
|
+
if (conn.backend === 'bidi') {
|
|
2058
|
+
await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
1914
2061
|
requireCdp(conn, 'scroll');
|
|
1915
2062
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1916
2063
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2482,7 +2629,9 @@ export class BrowserService {
|
|
|
2482
2629
|
this.recordings.delete(taskId);
|
|
2483
2630
|
}
|
|
2484
2631
|
for (const [, conn] of this.connections) {
|
|
2485
|
-
if (conn.backend
|
|
2632
|
+
if (conn.backend === 'bidi')
|
|
2633
|
+
conn.bidi.close();
|
|
2634
|
+
else if (conn.backend !== 'arc-native')
|
|
2486
2635
|
conn.cdp.close();
|
|
2487
2636
|
conn.cleanup?.();
|
|
2488
2637
|
}
|
|
@@ -2538,6 +2687,11 @@ export class BrowserService {
|
|
|
2538
2687
|
async connectProfile(effectiveProfile, target, key, opts = {}) {
|
|
2539
2688
|
const existingInfo = getRunningChromeInfo(key);
|
|
2540
2689
|
const tunnelled = opts.persistRemote || target.startsWith('ssh:');
|
|
2690
|
+
// Native Arc and Firefox BiDi speak their own transport, not CDP — never
|
|
2691
|
+
// route them through the CDP reuse branch (which would probe /json/version
|
|
2692
|
+
// and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
|
|
2693
|
+
// their attach-or-launch decision.
|
|
2694
|
+
const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
|
|
2541
2695
|
if (tunnelled) {
|
|
2542
2696
|
// A leftover local chrome-data / pid file under this key is the
|
|
2543
2697
|
// pre-T2 logged-out browser on THIS box. Attaching localhost CDP
|
|
@@ -2548,7 +2702,7 @@ export class BrowserService {
|
|
|
2548
2702
|
clearProfileRuntime(key);
|
|
2549
2703
|
}
|
|
2550
2704
|
}
|
|
2551
|
-
else if (existingInfo) {
|
|
2705
|
+
else if (existingInfo && !nativeTransport) {
|
|
2552
2706
|
try {
|
|
2553
2707
|
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
|
|
2554
2708
|
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
@@ -2660,6 +2814,39 @@ export class BrowserService {
|
|
|
2660
2814
|
sessionCache: new Map(),
|
|
2661
2815
|
};
|
|
2662
2816
|
}
|
|
2817
|
+
// Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
|
|
2818
|
+
// over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
|
|
2819
|
+
// serving the port, else launches one headless bound to the pinned profile
|
|
2820
|
+
// directory, else fails loud (Firefox is single-instance per profile).
|
|
2821
|
+
if (url.protocol === 'firefox-bidi:') {
|
|
2822
|
+
if (!profile.firefox) {
|
|
2823
|
+
throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
|
|
2824
|
+
`Re-select it with: agents browser use ${profile.name}`);
|
|
2825
|
+
}
|
|
2826
|
+
const profileDir = profile.userDataDir;
|
|
2827
|
+
if (!profileDir) {
|
|
2828
|
+
throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
|
|
2829
|
+
}
|
|
2830
|
+
const port = parseInt(url.port || '9600', 10);
|
|
2831
|
+
const conn = await connectFirefox(profile, key, port, {
|
|
2832
|
+
profileDir,
|
|
2833
|
+
headless: profile.chrome?.headless,
|
|
2834
|
+
});
|
|
2835
|
+
return {
|
|
2836
|
+
backend: 'bidi',
|
|
2837
|
+
browserType: 'firefox',
|
|
2838
|
+
bidi: conn.bidi,
|
|
2839
|
+
firefoxProfile: profile.firefox,
|
|
2840
|
+
sessionId: conn.sessionId,
|
|
2841
|
+
profileDir,
|
|
2842
|
+
port: conn.port,
|
|
2843
|
+
pid: conn.pid,
|
|
2844
|
+
electron: false,
|
|
2845
|
+
targetFilter: profile.targetFilter,
|
|
2846
|
+
tasks: this.loadTaskState(key),
|
|
2847
|
+
sessionCache: new Map(),
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2663
2850
|
// Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
|
|
2664
2851
|
// Apple Events instead of CDP. No debugging port, no browser launch.
|
|
2665
2852
|
if (url.protocol === 'arc-native:') {
|
|
@@ -2702,6 +2889,8 @@ export class BrowserService {
|
|
|
2702
2889
|
if (conn.browserType === 'arc') {
|
|
2703
2890
|
throw arcNotDrivableError(conn.profile);
|
|
2704
2891
|
}
|
|
2892
|
+
// Firefox opens tabs through the BiDi driver, never Target.createTarget.
|
|
2893
|
+
requireCdp(conn, 'createTab');
|
|
2705
2894
|
return (await conn.cdp.send('Target.createTarget', params));
|
|
2706
2895
|
}
|
|
2707
2896
|
async getOrCreateWindow(conn) {
|
|
@@ -3136,6 +3325,44 @@ export class BrowserService {
|
|
|
3136
3325
|
arcProfile: profile.arc,
|
|
3137
3326
|
};
|
|
3138
3327
|
}
|
|
3328
|
+
// Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
|
|
3329
|
+
// socket, but the Firefox we launched is still serving the port — reattach
|
|
3330
|
+
// by opening a fresh session, then merge the disk tasks. connectFirefox
|
|
3331
|
+
// attaches (never launches) when the port is already served. A failure
|
|
3332
|
+
// returns null like the CDP path, so the caller falls through to disk
|
|
3333
|
+
// reconcile instead of crashing.
|
|
3334
|
+
if (resolved.target.startsWith('firefox-bidi:')) {
|
|
3335
|
+
if (!profile.firefox || !profile.userDataDir)
|
|
3336
|
+
return null;
|
|
3337
|
+
const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
|
|
3338
|
+
try {
|
|
3339
|
+
const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
|
|
3340
|
+
const tasks = this.loadTaskState(key);
|
|
3341
|
+
for (const [k, t] of diskTasks) {
|
|
3342
|
+
if (!tasks.has(k))
|
|
3343
|
+
tasks.set(k, t);
|
|
3344
|
+
}
|
|
3345
|
+
return {
|
|
3346
|
+
backend: 'bidi',
|
|
3347
|
+
browserType: 'firefox',
|
|
3348
|
+
bidi: ff.bidi,
|
|
3349
|
+
firefoxProfile: profile.firefox,
|
|
3350
|
+
sessionId: ff.sessionId,
|
|
3351
|
+
profileDir: profile.userDataDir,
|
|
3352
|
+
port: ff.port,
|
|
3353
|
+
pid: ff.pid,
|
|
3354
|
+
electron: false,
|
|
3355
|
+
targetFilter: profile.targetFilter,
|
|
3356
|
+
key,
|
|
3357
|
+
profile: bare,
|
|
3358
|
+
tasks,
|
|
3359
|
+
sessionCache: new Map(),
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
catch {
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3139
3366
|
const existingInfo = getRunningChromeInfo(key);
|
|
3140
3367
|
const parsed = parseEndpointUrl(resolved.target);
|
|
3141
3368
|
const port = existingInfo?.port ?? parsed?.port;
|
|
@@ -3251,8 +3478,14 @@ export class BrowserService {
|
|
|
3251
3478
|
}
|
|
3252
3479
|
else {
|
|
3253
3480
|
// Shared/borrowed tunnel (or a local connection with no tunnel): close
|
|
3254
|
-
// only our own
|
|
3255
|
-
if (conn.backend
|
|
3481
|
+
// only our own client; killing the tunnel would break the winner.
|
|
3482
|
+
if (conn.backend === 'bidi') {
|
|
3483
|
+
try {
|
|
3484
|
+
conn.bidi.close();
|
|
3485
|
+
}
|
|
3486
|
+
catch { /* best effort */ }
|
|
3487
|
+
}
|
|
3488
|
+
else if (conn.backend !== 'arc-native') {
|
|
3256
3489
|
try {
|
|
3257
3490
|
conn.cdp.close();
|
|
3258
3491
|
}
|
|
@@ -3298,7 +3531,7 @@ export class BrowserService {
|
|
|
3298
3531
|
if (registered !== conn)
|
|
3299
3532
|
continue;
|
|
3300
3533
|
try {
|
|
3301
|
-
if (conn.backend !== 'arc-native')
|
|
3534
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3302
3535
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3303
3536
|
}
|
|
3304
3537
|
catch {
|
|
@@ -3360,7 +3593,7 @@ export class BrowserService {
|
|
|
3360
3593
|
conn = this.registerRehydratedConnection(key, attached);
|
|
3361
3594
|
if (conn === attached) {
|
|
3362
3595
|
try {
|
|
3363
|
-
if (conn.backend !== 'arc-native')
|
|
3596
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3364
3597
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3365
3598
|
}
|
|
3366
3599
|
catch {
|
|
@@ -3433,6 +3666,45 @@ export class BrowserService {
|
|
|
3433
3666
|
tasks,
|
|
3434
3667
|
};
|
|
3435
3668
|
}
|
|
3669
|
+
if (conn.backend === 'bidi') {
|
|
3670
|
+
const tasks = [];
|
|
3671
|
+
for (const task of conn.tasks.values()) {
|
|
3672
|
+
let tabs = [];
|
|
3673
|
+
try {
|
|
3674
|
+
tabs = await this.listFirefoxTaskTabs(conn, task);
|
|
3675
|
+
}
|
|
3676
|
+
catch { /* Firefox down */ }
|
|
3677
|
+
const domains = tabs.flatMap((tab) => {
|
|
3678
|
+
try {
|
|
3679
|
+
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
3680
|
+
return domain && domain !== 'blank' ? [domain] : [];
|
|
3681
|
+
}
|
|
3682
|
+
catch {
|
|
3683
|
+
return [];
|
|
3684
|
+
}
|
|
3685
|
+
});
|
|
3686
|
+
tasks.push({
|
|
3687
|
+
id: task.id,
|
|
3688
|
+
name: task.name,
|
|
3689
|
+
label: task.label ?? task.name,
|
|
3690
|
+
tabCount: Object.keys(task.tabs).length,
|
|
3691
|
+
currentTabId: task.currentTabId,
|
|
3692
|
+
createdAt: task.createdAt,
|
|
3693
|
+
tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
|
|
3694
|
+
domains: domains.length ? [...new Set(domains)] : undefined,
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3697
|
+
const parsed = parseConnectionKey(key);
|
|
3698
|
+
return {
|
|
3699
|
+
name: conn.profile ?? parsed.profile,
|
|
3700
|
+
endpoint: parsed.endpoint,
|
|
3701
|
+
key,
|
|
3702
|
+
running: conn.bidi.isOpen,
|
|
3703
|
+
port: conn.port,
|
|
3704
|
+
pid: conn.pid,
|
|
3705
|
+
tasks,
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3436
3708
|
// Fetch all targets once for efficiency
|
|
3437
3709
|
let targets = [];
|
|
3438
3710
|
try {
|
|
@@ -3655,7 +3927,13 @@ export class BrowserService {
|
|
|
3655
3927
|
return undefined;
|
|
3656
3928
|
if (await isConnHealthy(conn))
|
|
3657
3929
|
return conn;
|
|
3658
|
-
if (conn.backend
|
|
3930
|
+
if (conn.backend === 'bidi') {
|
|
3931
|
+
try {
|
|
3932
|
+
conn.bidi.close();
|
|
3933
|
+
}
|
|
3934
|
+
catch { /* already closed */ }
|
|
3935
|
+
}
|
|
3936
|
+
else if (conn.backend !== 'arc-native') {
|
|
3659
3937
|
try {
|
|
3660
3938
|
conn.cdp.close();
|
|
3661
3939
|
}
|
|
@@ -3894,14 +4172,205 @@ export class BrowserService {
|
|
|
3894
4172
|
task.currentTabId = undefined;
|
|
3895
4173
|
});
|
|
3896
4174
|
}
|
|
4175
|
+
// ---------------------------------------------------------------------------
|
|
4176
|
+
// Firefox WebDriver BiDi backend helpers (PHNX-4043)
|
|
4177
|
+
// ---------------------------------------------------------------------------
|
|
4178
|
+
/** The BiDi browsing-context id a task owns for `shortId`. */
|
|
4179
|
+
firefoxContext(task, shortId) {
|
|
4180
|
+
const context = task.tabs[shortId];
|
|
4181
|
+
if (!context)
|
|
4182
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
4183
|
+
return context;
|
|
4184
|
+
}
|
|
4185
|
+
/** The context ids currently live in this Firefox (a killed tab drops out). */
|
|
4186
|
+
async firefoxLiveContexts(conn) {
|
|
4187
|
+
const contexts = await bidiTopLevelContexts(conn.bidi);
|
|
4188
|
+
return new Set(contexts.map((c) => c.context));
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
|
|
4192
|
+
* already live in one of this task's own tabs → reload THAT tab in place and
|
|
4193
|
+
* keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
|
|
4194
|
+
* Firefox never borrows, but the guard keeps parity. Returns the retained
|
|
4195
|
+
* short id, or undefined when nothing matches.
|
|
4196
|
+
*/
|
|
4197
|
+
async reopenOwnedFirefoxTab(conn, task, url) {
|
|
4198
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4199
|
+
// Read each live tab's URL from browsingContext.getTree metadata rather than
|
|
4200
|
+
// evaluating location.href per tab: the same source the CDP path reads
|
|
4201
|
+
// (Target.getTargets), so a mid-navigation or just-closed sibling tab can
|
|
4202
|
+
// never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
|
|
4203
|
+
// absent from the tree is simply not a reopen candidate.
|
|
4204
|
+
const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
|
|
4205
|
+
const wanted = canonicalTabUrl(url);
|
|
4206
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4207
|
+
if (borrowed.has(shortId))
|
|
4208
|
+
continue;
|
|
4209
|
+
const liveUrl = liveUrlByContext.get(context);
|
|
4210
|
+
if (liveUrl === undefined)
|
|
4211
|
+
continue; // registered but gone from the browser
|
|
4212
|
+
if (canonicalTabUrl(liveUrl) === wanted) {
|
|
4213
|
+
await bidiReload(conn.bidi, context);
|
|
4214
|
+
task.currentTabId = shortId;
|
|
4215
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4216
|
+
return { tabId: shortId };
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
return undefined;
|
|
4220
|
+
}
|
|
4221
|
+
async navigateFirefox(conn, task, url, addTab = false) {
|
|
4222
|
+
if (!addTab) {
|
|
4223
|
+
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4224
|
+
if (reopened) {
|
|
4225
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
|
|
4226
|
+
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4227
|
+
}
|
|
4228
|
+
// Reuse the current tab when there is one — navigate() semantics.
|
|
4229
|
+
const currentShortId = task.currentTabId;
|
|
4230
|
+
if (currentShortId && task.tabs[currentShortId]) {
|
|
4231
|
+
const context = task.tabs[currentShortId];
|
|
4232
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4233
|
+
if (live.has(context)) {
|
|
4234
|
+
await bidiNavigate(conn.bidi, context, url);
|
|
4235
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4236
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
4237
|
+
return { tabId: currentShortId, url, created: false, refreshed: false };
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
else {
|
|
4242
|
+
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4243
|
+
if (reopened) {
|
|
4244
|
+
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
// Open a fresh tab.
|
|
4248
|
+
const context = await bidiCreateTab(conn.bidi);
|
|
4249
|
+
await bidiNavigate(conn.bidi, context, url);
|
|
4250
|
+
const shortId = generateShortId();
|
|
4251
|
+
task.tabs[shortId] = context;
|
|
4252
|
+
task.currentTabId = shortId;
|
|
4253
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4254
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
4255
|
+
return { tabId: shortId, url, created: true, refreshed: false };
|
|
4256
|
+
}
|
|
4257
|
+
async evaluateFirefox(conn, task, shortId, expression) {
|
|
4258
|
+
const context = this.firefoxContext(task, shortId);
|
|
4259
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4260
|
+
if (!live.has(context))
|
|
4261
|
+
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4262
|
+
return bidiEvaluate(conn.bidi, context, expression);
|
|
4263
|
+
}
|
|
4264
|
+
async listFirefoxTaskTabs(conn, task) {
|
|
4265
|
+
const live = await bidiTopLevelContexts(conn.bidi);
|
|
4266
|
+
const byId = new Map(live.map((c) => [c.context, c]));
|
|
4267
|
+
const tabs = [];
|
|
4268
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4269
|
+
const info = byId.get(context);
|
|
4270
|
+
if (!info)
|
|
4271
|
+
continue; // tab closed out from under the task — drop it silently.
|
|
4272
|
+
let title = '';
|
|
4273
|
+
try {
|
|
4274
|
+
title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
|
|
4275
|
+
}
|
|
4276
|
+
catch {
|
|
4277
|
+
/* about: pages or a mid-navigation context — url is enough */
|
|
4278
|
+
}
|
|
4279
|
+
tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
|
|
4280
|
+
}
|
|
4281
|
+
return tabs;
|
|
4282
|
+
}
|
|
4283
|
+
/**
|
|
4284
|
+
* Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
|
|
4285
|
+
* process (that is `stopProfile`'s job) — a killed browser would take the
|
|
4286
|
+
* user's other tabs with it. A tab already gone is skipped.
|
|
4287
|
+
*/
|
|
4288
|
+
async closeFirefoxTabs(conn, task) {
|
|
4289
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4290
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4291
|
+
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4292
|
+
if (borrowed.has(shortId))
|
|
4293
|
+
continue;
|
|
4294
|
+
if (live.has(context)) {
|
|
4295
|
+
try {
|
|
4296
|
+
await bidiCloseTab(conn.bidi, context);
|
|
4297
|
+
}
|
|
4298
|
+
catch { /* already closed */ }
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
/**
|
|
4303
|
+
* Viewport-center coordinates of a selector-addressed element in a Firefox
|
|
4304
|
+
* tab, scrolled into view first — the (x, y) `input.performActions` clicks.
|
|
4305
|
+
* Throws when the element is gone or has zero size (off-screen / display:none).
|
|
4306
|
+
*/
|
|
4307
|
+
async firefoxRefCoords(conn, task, shortId, selector) {
|
|
4308
|
+
const expr = `(() => {
|
|
4309
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
4310
|
+
if (!el) throw new Error('DOM ref is missing');
|
|
4311
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
4312
|
+
const r = el.getBoundingClientRect();
|
|
4313
|
+
if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
|
|
4314
|
+
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
4315
|
+
})()`;
|
|
4316
|
+
const raw = await this.evaluateFirefox(conn, task, shortId, expr);
|
|
4317
|
+
const parsed = JSON.parse(String(raw));
|
|
4318
|
+
return parsed;
|
|
4319
|
+
}
|
|
4320
|
+
/**
|
|
4321
|
+
* Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
|
|
4322
|
+
* `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
|
|
4323
|
+
* under 100 KB so chat-injected screenshots stay token-cheap.
|
|
4324
|
+
*/
|
|
4325
|
+
async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
|
|
4326
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
4327
|
+
const context = this.firefoxContext(task, shortId);
|
|
4328
|
+
const live = await this.firefoxLiveContexts(conn);
|
|
4329
|
+
if (!live.has(context))
|
|
4330
|
+
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4331
|
+
let buffer;
|
|
4332
|
+
let extension;
|
|
4333
|
+
if (quality === 'raw') {
|
|
4334
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
|
|
4335
|
+
extension = 'png';
|
|
4336
|
+
}
|
|
4337
|
+
else {
|
|
4338
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
|
|
4339
|
+
const MAX_SIZE = 100 * 1024;
|
|
4340
|
+
let q = 0.5;
|
|
4341
|
+
while (buffer.length > MAX_SIZE && q > 0.1) {
|
|
4342
|
+
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
|
|
4343
|
+
q -= 0.1;
|
|
4344
|
+
}
|
|
4345
|
+
extension = 'jpg';
|
|
4346
|
+
}
|
|
4347
|
+
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
4348
|
+
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
4349
|
+
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
4350
|
+
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
4351
|
+
await fs.promises.writeFile(finalPath, buffer);
|
|
4352
|
+
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
4353
|
+
{ width: 0, height: 0 };
|
|
4354
|
+
emit('browser.screenshot', {
|
|
4355
|
+
profile: parseConnectionKey(runtimeKey).profile,
|
|
4356
|
+
task: task.name,
|
|
4357
|
+
tabId: shortId,
|
|
4358
|
+
path: finalPath,
|
|
4359
|
+
bytes: buffer.length,
|
|
4360
|
+
width: dims.width,
|
|
4361
|
+
height: dims.height,
|
|
4362
|
+
quality,
|
|
4363
|
+
});
|
|
4364
|
+
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
4365
|
+
}
|
|
3897
4366
|
/** Connect a profile at `target`, register it under `key`, and arm downloads. */
|
|
3898
4367
|
async openConnection(profile, target, key, profileName, opts = {}) {
|
|
3899
4368
|
const conn = await this.connectProfile(profile, target, key, opts);
|
|
3900
4369
|
conn.key = key;
|
|
3901
4370
|
conn.profile = profileName;
|
|
3902
4371
|
this.connections.set(key, conn);
|
|
3903
|
-
// Download behavior configuration is CDP-specific; skip for native Arc.
|
|
3904
|
-
if (conn.backend !== 'arc-native') {
|
|
4372
|
+
// Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
|
|
4373
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
3905
4374
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3906
4375
|
}
|
|
3907
4376
|
return conn;
|
|
@@ -3941,6 +4410,10 @@ export class BrowserService {
|
|
|
3941
4410
|
if (conn.backend === 'arc-native') {
|
|
3942
4411
|
throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
|
|
3943
4412
|
}
|
|
4413
|
+
if (conn.backend === 'bidi') {
|
|
4414
|
+
throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
|
|
4415
|
+
'Use a Chromium-family profile, or open the URL in your OS browser.');
|
|
4416
|
+
}
|
|
3944
4417
|
// createPageTarget refuses Arc with an actionable error rather than crashing it.
|
|
3945
4418
|
const created = await this.createPageTarget(conn, { url });
|
|
3946
4419
|
this.invalidateTargetCache(conn);
|