@phnx-labs/agents-cli 1.22.93 → 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 -0
- package/dist/commands/browser.d.ts +1 -0
- package/dist/commands/browser.js +156 -11
- package/dist/lib/browser/arc-discovery.d.ts +60 -0
- package/dist/lib/browser/arc-discovery.js +187 -0
- package/dist/lib/browser/arc-dom.d.ts +14 -0
- package/dist/lib/browser/arc-dom.js +121 -0
- 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/arc.d.ts +57 -0
- package/dist/lib/browser/drivers/arc.js +281 -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 +39 -1
- package/dist/lib/browser/profiles.js +239 -9
- package/dist/lib/browser/refs.d.ts +2 -0
- package/dist/lib/browser/refs.js +2 -1
- package/dist/lib/browser/resolve-target.d.ts +2 -0
- package/dist/lib/browser/resolve-target.js +14 -0
- package/dist/lib/browser/runtime-state.js +9 -1
- package/dist/lib/browser/service.d.ts +86 -3
- package/dist/lib/browser/service.js +1045 -42
- package/dist/lib/browser/types.d.ts +85 -2
- package/dist/lib/daemon/usage-sync-service.js +12 -0
- package/dist/lib/open-url.js +6 -0
- package/dist/lib/types.d.ts +20 -1
- package/package.json +1 -1
|
@@ -16,12 +16,15 @@ import { taskMatchesCaller } from './caller-identity.js';
|
|
|
16
16
|
import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
|
|
17
17
|
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
18
18
|
import { typeEditorText } from './editor.js';
|
|
19
|
+
import { arcClickExpression, arcFillExpression, arcRefsExpression, arcScrollExpression, parseArcRefsResult, } from './arc-dom.js';
|
|
19
20
|
import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
|
|
20
21
|
import { emit } from '../feed/events.js';
|
|
21
22
|
import { resolveActor } from '../actor.js';
|
|
22
23
|
import { recordBrowserSession } from '../session/db.js';
|
|
23
24
|
import { sshExecAsync } from '../ssh-exec.js';
|
|
24
25
|
import { resolveFfmpeg } from './ffmpeg.js';
|
|
26
|
+
import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
|
|
27
|
+
import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
|
|
25
28
|
/**
|
|
26
29
|
* Canonical form for comparing a requested URL against what CDP reports for a
|
|
27
30
|
* live target. `new URL('https://example.com').href` is `https://example.com/`,
|
|
@@ -229,6 +232,15 @@ function expandHome(p) {
|
|
|
229
232
|
* can make; if it doesn't round-trip within 1s the connection is dead.
|
|
230
233
|
*/
|
|
231
234
|
async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
235
|
+
// Native Arc connections: healthy if Arc is still running (no CDP socket to probe).
|
|
236
|
+
if (conn.backend === 'arc-native') {
|
|
237
|
+
return isArcRunning();
|
|
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
|
+
}
|
|
232
244
|
if (!conn.cdp.isOpen)
|
|
233
245
|
return false;
|
|
234
246
|
try {
|
|
@@ -242,6 +254,19 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
242
254
|
return false;
|
|
243
255
|
}
|
|
244
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
|
+
*/
|
|
264
|
+
function requireCdp(conn, capability) {
|
|
265
|
+
if (conn.backend === 'arc-native')
|
|
266
|
+
throw new ArcNativeCapabilityError(capability);
|
|
267
|
+
if (conn.backend === 'bidi')
|
|
268
|
+
throw new FirefoxCapabilityError(capability);
|
|
269
|
+
}
|
|
245
270
|
/** Join error lines so callers get a next command, not a dead-end message. */
|
|
246
271
|
export function actionable(...lines) {
|
|
247
272
|
return lines.filter((l) => l != null && l !== '').join('\n');
|
|
@@ -294,6 +319,20 @@ export function resolveTaskIdentity(forwarded, resolveLocalActor) {
|
|
|
294
319
|
sessionId: forwarded.sessionId,
|
|
295
320
|
};
|
|
296
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* The unique URL a native Arc tab is created at before it is navigated to its
|
|
324
|
+
* real target (PHNX-2399). Arc's `make new tab` accepts only http(s) URLs (it
|
|
325
|
+
* rejects `data:` and `about:` outright), so the marker is an https URL on the
|
|
326
|
+
* reserved `.invalid` TLD: DNS fails locally, no request leaves the machine,
|
|
327
|
+
* and the tab keeps the exact URL for the enumerate-and-match that follows.
|
|
328
|
+
*/
|
|
329
|
+
const ARC_CREATE_MARKER_PREFIX = 'https://agents-browser.invalid/';
|
|
330
|
+
export function arcCreateMarker() {
|
|
331
|
+
return `${ARC_CREATE_MARKER_PREFIX}${crypto.randomUUID()}`;
|
|
332
|
+
}
|
|
333
|
+
export function isArcCreateMarker(url) {
|
|
334
|
+
return url.startsWith(ARC_CREATE_MARKER_PREFIX);
|
|
335
|
+
}
|
|
297
336
|
export class BrowserService {
|
|
298
337
|
static SOURCE_PREFIX = {
|
|
299
338
|
'rush-app': 'rush-app-',
|
|
@@ -435,6 +474,9 @@ export class BrowserService {
|
|
|
435
474
|
endpointName: opts.endpointName,
|
|
436
475
|
probe: opts.probe,
|
|
437
476
|
});
|
|
477
|
+
if (!routed.local && routed.commandDispatch) {
|
|
478
|
+
throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
|
|
479
|
+
}
|
|
438
480
|
const composite = routed.key;
|
|
439
481
|
const effectiveProfile = routed.profile;
|
|
440
482
|
const taskId = generateTaskId();
|
|
@@ -509,7 +551,7 @@ export class BrowserService {
|
|
|
509
551
|
// pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
|
|
510
552
|
// the branch only fires when the profile has no page target at all.
|
|
511
553
|
let startupBlankTargetId;
|
|
512
|
-
if (!opts.url && !conn.electron) {
|
|
554
|
+
if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
513
555
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
514
556
|
if (!targetInfos.some((t) => t.type === 'page')) {
|
|
515
557
|
const created = await this.createPageTarget(conn, {
|
|
@@ -538,13 +580,23 @@ export class BrowserService {
|
|
|
538
580
|
// actor (the RUSH-2020 bug).
|
|
539
581
|
...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId, sessionId: opts.sessionId }, () => resolveActor().id),
|
|
540
582
|
};
|
|
583
|
+
if (conn.backend === 'arc-native') {
|
|
584
|
+
const selected = await this.resolveArcSpace(conn.arcProfile);
|
|
585
|
+
task.arcNative = {
|
|
586
|
+
profileId: conn.arcProfile.profileId,
|
|
587
|
+
windowId: selected.windowId,
|
|
588
|
+
spaceId: selected.spaceId,
|
|
589
|
+
spaceTitle: selected.spaceTitle,
|
|
590
|
+
tabs: {},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
541
593
|
if (startupBlankTargetId) {
|
|
542
594
|
const shortId = generateShortId();
|
|
543
595
|
task.tabs[shortId] = startupBlankTargetId;
|
|
544
596
|
task.currentTabId = shortId;
|
|
545
597
|
}
|
|
546
598
|
// For Electron, get the existing window as the tab
|
|
547
|
-
if (conn.electron) {
|
|
599
|
+
if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
548
600
|
const windowId = await this.getOrCreateWindow(conn);
|
|
549
601
|
if (windowId) {
|
|
550
602
|
const shortId = generateShortId();
|
|
@@ -596,7 +648,14 @@ export class BrowserService {
|
|
|
596
648
|
// opening a fresh target is created:true.
|
|
597
649
|
let tabId;
|
|
598
650
|
let firstOpen;
|
|
599
|
-
if (opts.url &&
|
|
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') {
|
|
600
659
|
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
601
660
|
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
602
661
|
const shortId = generateShortId();
|
|
@@ -607,7 +666,7 @@ export class BrowserService {
|
|
|
607
666
|
tabId = shortId;
|
|
608
667
|
firstOpen = { tabId: shortId, created: !adopted, refreshed: false };
|
|
609
668
|
}
|
|
610
|
-
else if (opts.url && (conn.electron || conn.browserType === 'arc')) {
|
|
669
|
+
else if (opts.url && (conn.backend === 'arc-native' || conn.electron || conn.browserType === 'arc')) {
|
|
611
670
|
// Electron and Arc share the reuse-in-place path: neither may open a fresh
|
|
612
671
|
// tab here (Electron drives its one window; Arc crashes on Target.createTarget),
|
|
613
672
|
// so the implicit first navigate attaches to an existing tab — honoring the
|
|
@@ -830,36 +889,70 @@ export class BrowserService {
|
|
|
830
889
|
const mapKey = this.taskMapKey(conn, task) ?? taskName;
|
|
831
890
|
// Get domains from tabs before closing (for history)
|
|
832
891
|
const domains = new Set();
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
for
|
|
836
|
-
|
|
837
|
-
|
|
892
|
+
if (conn.backend === 'arc-native') {
|
|
893
|
+
// Resolve exact durable refs for history. Missing/moved tabs are not
|
|
894
|
+
// adopted and never become candidates for a best-effort close.
|
|
895
|
+
for (const ref of Object.values(this.requireArcTask(task).tabs)) {
|
|
896
|
+
const live = await resolveArcTab(ref);
|
|
897
|
+
if (live?.url) {
|
|
838
898
|
try {
|
|
839
|
-
const domain = new URL(
|
|
899
|
+
const domain = new URL(live.url).hostname.replace(/^www\./, '');
|
|
840
900
|
if (domain && domain !== 'blank')
|
|
841
901
|
domains.add(domain);
|
|
842
902
|
}
|
|
843
|
-
catch {
|
|
844
|
-
// invalid URL
|
|
845
|
-
}
|
|
903
|
+
catch { /* invalid URL */ }
|
|
846
904
|
}
|
|
847
905
|
}
|
|
906
|
+
await this.saveToHistory(task, Array.from(domains));
|
|
907
|
+
await this.closeArcNativeTabs(conn, task);
|
|
848
908
|
}
|
|
849
|
-
|
|
850
|
-
|
|
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);
|
|
851
923
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
924
|
+
else {
|
|
925
|
+
try {
|
|
926
|
+
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
927
|
+
for (const cdpId of Object.values(task.tabs)) {
|
|
928
|
+
const target = targetInfos.find((t) => t.targetId === cdpId);
|
|
929
|
+
if (target?.url) {
|
|
930
|
+
try {
|
|
931
|
+
const domain = new URL(target.url).hostname.replace(/^www\./, '');
|
|
932
|
+
if (domain && domain !== 'blank')
|
|
933
|
+
domains.add(domain);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
// invalid URL
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
// CDP not responding
|
|
943
|
+
}
|
|
944
|
+
// Save to history before closing
|
|
945
|
+
await this.saveToHistory(task, Array.from(domains));
|
|
946
|
+
// Close task's tabs (not the window - it's shared, and not a tab the
|
|
947
|
+
// task merely borrowed - that one was open before the task existed).
|
|
948
|
+
await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
|
|
949
|
+
// Tab already closed
|
|
950
|
+
})));
|
|
951
|
+
for (const cdpId of Object.values(task.tabs)) {
|
|
952
|
+
conn.sessionCache.delete(cdpId);
|
|
953
|
+
}
|
|
954
|
+
this.invalidateTargetCache(conn);
|
|
861
955
|
}
|
|
862
|
-
this.invalidateTargetCache(conn);
|
|
863
956
|
conn.tasks.delete(mapKey);
|
|
864
957
|
await this.saveTaskState(key, conn.tasks);
|
|
865
958
|
// Feed + usage rows are read by humans: bare profile name (RUSH-2709).
|
|
@@ -875,8 +968,15 @@ export class BrowserService {
|
|
|
875
968
|
});
|
|
876
969
|
}).catch(() => { });
|
|
877
970
|
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
878
|
-
conn.
|
|
879
|
-
|
|
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') {
|
|
977
|
+
conn.cdp.close();
|
|
978
|
+
killChrome(conn.pid);
|
|
979
|
+
}
|
|
880
980
|
conn.cleanup?.();
|
|
881
981
|
this.connections.delete(key);
|
|
882
982
|
clearProfileRuntime(key);
|
|
@@ -908,6 +1008,28 @@ export class BrowserService {
|
|
|
908
1008
|
const conn = this.connections.get(key);
|
|
909
1009
|
if (!conn)
|
|
910
1010
|
continue;
|
|
1011
|
+
// Native Arc connections: close owned tabs but NEVER kill the Arc process.
|
|
1012
|
+
// The CLI does not own the Arc process — the user started it.
|
|
1013
|
+
if (conn.backend === 'arc-native') {
|
|
1014
|
+
for (const task of conn.tasks.values()) {
|
|
1015
|
+
await this.closeArcNativeTabs(conn, task);
|
|
1016
|
+
}
|
|
1017
|
+
this.connections.delete(key);
|
|
1018
|
+
clearProfileRuntime(key);
|
|
1019
|
+
continue;
|
|
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
|
+
}
|
|
911
1033
|
conn.cdp.close();
|
|
912
1034
|
killChrome(conn.pid);
|
|
913
1035
|
conn.cleanup?.();
|
|
@@ -934,6 +1056,15 @@ export class BrowserService {
|
|
|
934
1056
|
}
|
|
935
1057
|
async navigateLocked(conn, task, url) {
|
|
936
1058
|
this.maybeUpdateLabelFromUrl(conn, task, url);
|
|
1059
|
+
// Native Arc backend (PHNX-2399): route through the native driver instead of CDP.
|
|
1060
|
+
if (conn.backend === 'arc-native') {
|
|
1061
|
+
return this.navigateArcNative(conn, task, url);
|
|
1062
|
+
}
|
|
1063
|
+
// Firefox BiDi backend (PHNX-4043).
|
|
1064
|
+
if (conn.backend === 'bidi') {
|
|
1065
|
+
return this.navigateFirefox(conn, task, url);
|
|
1066
|
+
}
|
|
1067
|
+
requireCdp(conn, 'navigate');
|
|
937
1068
|
// Same-task reopen (PHNX-2399): the URL is already live in a tab this task
|
|
938
1069
|
// owns → reload THAT tab in place and keep its id, before the create/reuse
|
|
939
1070
|
// paths below. Chrome and Arc alike; Electron drives one window and never
|
|
@@ -1035,6 +1166,13 @@ export class BrowserService {
|
|
|
1035
1166
|
return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.tabAddLocked(conn, task, url));
|
|
1036
1167
|
}
|
|
1037
1168
|
async tabAddLocked(conn, task, url) {
|
|
1169
|
+
if (conn.backend === 'arc-native') {
|
|
1170
|
+
return this.navigateArcNative(conn, task, url, true);
|
|
1171
|
+
}
|
|
1172
|
+
if (conn.backend === 'bidi') {
|
|
1173
|
+
return this.navigateFirefox(conn, task, url, true);
|
|
1174
|
+
}
|
|
1175
|
+
requireCdp(conn, 'createTab');
|
|
1038
1176
|
if (conn.electron) {
|
|
1039
1177
|
throw new Error('Electron apps do not support opening additional tabs');
|
|
1040
1178
|
}
|
|
@@ -1057,12 +1195,38 @@ export class BrowserService {
|
|
|
1057
1195
|
async tabFocus(taskId, tabHint) {
|
|
1058
1196
|
const { conn, task } = await this.findTask(taskId);
|
|
1059
1197
|
const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
|
|
1198
|
+
if (conn.backend === 'arc-native') {
|
|
1199
|
+
await this.runExclusive('arc-native:host-app', async () => {
|
|
1200
|
+
const ref = this.requireArcTask(task).tabs[resolvedTabId];
|
|
1201
|
+
if (!ref)
|
|
1202
|
+
throw new Error(`Tab ${resolvedTabId} not found`);
|
|
1203
|
+
await selectArcTab(ref);
|
|
1204
|
+
task.currentTabId = resolvedTabId;
|
|
1205
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1206
|
+
});
|
|
1207
|
+
return { tabId: resolvedTabId };
|
|
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
|
+
}
|
|
1060
1215
|
task.currentTabId = resolvedTabId;
|
|
1061
1216
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
1062
1217
|
return { tabId: resolvedTabId };
|
|
1063
1218
|
}
|
|
1064
1219
|
async tabList(taskId) {
|
|
1065
1220
|
const { conn, task } = await this.findTask(taskId);
|
|
1221
|
+
if (conn.backend === 'arc-native') {
|
|
1222
|
+
return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
|
|
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
|
+
}
|
|
1229
|
+
requireCdp(conn, 'enumerate');
|
|
1066
1230
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1067
1231
|
const tabs = [];
|
|
1068
1232
|
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
@@ -1089,6 +1253,13 @@ export class BrowserService {
|
|
|
1089
1253
|
if (byPrefix.length > 1) {
|
|
1090
1254
|
throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
|
|
1091
1255
|
}
|
|
1256
|
+
// Native Arc addresses owned tabs only by the task's stable short id. URL
|
|
1257
|
+
// and title are display data and must never become fallback identities.
|
|
1258
|
+
if (conn.backend === 'arc-native')
|
|
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`);
|
|
1092
1263
|
// URL substring match
|
|
1093
1264
|
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1094
1265
|
const matches = [];
|
|
@@ -1121,12 +1292,20 @@ export class BrowserService {
|
|
|
1121
1292
|
async tabs(taskId, profileRef) {
|
|
1122
1293
|
if (taskId) {
|
|
1123
1294
|
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1295
|
+
if (conn.backend === 'arc-native')
|
|
1296
|
+
return this.listArcTaskTabs(task);
|
|
1297
|
+
if (conn.backend === 'bidi')
|
|
1298
|
+
return this.listFirefoxTaskTabs(conn, task);
|
|
1124
1299
|
return this.getTabsForTask(conn.cdp, task);
|
|
1125
1300
|
}
|
|
1126
1301
|
const allTabs = [];
|
|
1127
1302
|
for (const [, conn] of this.connections) {
|
|
1128
1303
|
for (const [, task] of conn.tasks) {
|
|
1129
|
-
const tabs =
|
|
1304
|
+
const tabs = conn.backend === 'arc-native'
|
|
1305
|
+
? await this.listArcTaskTabs(task)
|
|
1306
|
+
: conn.backend === 'bidi'
|
|
1307
|
+
? await this.listFirefoxTaskTabs(conn, task)
|
|
1308
|
+
: await this.getTabsForTask(conn.cdp, task);
|
|
1130
1309
|
allTabs.push(...tabs);
|
|
1131
1310
|
}
|
|
1132
1311
|
}
|
|
@@ -1134,6 +1313,54 @@ export class BrowserService {
|
|
|
1134
1313
|
}
|
|
1135
1314
|
async tabClose(taskId, tabHint) {
|
|
1136
1315
|
const { conn, task } = await this.findTask(taskId);
|
|
1316
|
+
if (conn.backend === 'arc-native') {
|
|
1317
|
+
await this.runExclusive('arc-native:host-app', async () => {
|
|
1318
|
+
await this.reconcileArcCreateIntents(conn, task);
|
|
1319
|
+
const native = this.requireArcTask(task);
|
|
1320
|
+
const ids = tabHint === undefined
|
|
1321
|
+
? Object.keys(native.tabs)
|
|
1322
|
+
: [await this.resolveTabHint(conn, task, tabHint)];
|
|
1323
|
+
for (const shortId of ids) {
|
|
1324
|
+
const ref = native.tabs[shortId];
|
|
1325
|
+
if (ref)
|
|
1326
|
+
await closeArcTab(ref);
|
|
1327
|
+
delete native.tabs[shortId];
|
|
1328
|
+
delete task.tabs[shortId];
|
|
1329
|
+
delete task.refDescriptors?.[shortId];
|
|
1330
|
+
}
|
|
1331
|
+
if (!task.currentTabId || ids.includes(task.currentTabId)) {
|
|
1332
|
+
const remaining = Object.keys(native.tabs);
|
|
1333
|
+
task.currentTabId = remaining.at(-1);
|
|
1334
|
+
}
|
|
1335
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1336
|
+
});
|
|
1337
|
+
return;
|
|
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
|
+
}
|
|
1363
|
+
requireCdp(conn, 'closeTab');
|
|
1137
1364
|
if (tabHint !== undefined) {
|
|
1138
1365
|
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
1139
1366
|
const cdpId = task.tabs[shortId];
|
|
@@ -1170,6 +1397,14 @@ export class BrowserService {
|
|
|
1170
1397
|
async evaluate(taskId, tabHint, expression) {
|
|
1171
1398
|
const { conn, task } = await this.findTask(taskId);
|
|
1172
1399
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1400
|
+
// Native Arc backend (PHNX-2399): synchronous JS only, isolated world.
|
|
1401
|
+
if (conn.backend === 'arc-native') {
|
|
1402
|
+
return this.evaluateArcNative(conn, task, shortId, expression);
|
|
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
|
+
}
|
|
1173
1408
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1174
1409
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1175
1410
|
if (!target) {
|
|
@@ -1199,6 +1434,17 @@ export class BrowserService {
|
|
|
1199
1434
|
}
|
|
1200
1435
|
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
1201
1436
|
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1437
|
+
// Native Arc backend: screenshots cannot select/activate the user's tab.
|
|
1438
|
+
if (conn.backend === 'arc-native') {
|
|
1439
|
+
throw new ArcNativeCapabilityError('screenshot', 'Screenshot is unavailable for native Arc automation. ' +
|
|
1440
|
+
'Screenshots require activating the tab, which would disrupt user selection. ' +
|
|
1441
|
+
'This task still belongs to the Arc profile. ' +
|
|
1442
|
+
'Use a Chromium-family browser for screenshot capability.');
|
|
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
|
+
}
|
|
1202
1448
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1203
1449
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1204
1450
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1259,6 +1505,7 @@ export class BrowserService {
|
|
|
1259
1505
|
*/
|
|
1260
1506
|
async printToPdf(taskId, tabHint, outputPath) {
|
|
1261
1507
|
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1508
|
+
requireCdp(conn, 'pdf');
|
|
1262
1509
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1263
1510
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1264
1511
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1287,6 +1534,7 @@ export class BrowserService {
|
|
|
1287
1534
|
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
1288
1535
|
}
|
|
1289
1536
|
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1537
|
+
requireCdp(conn, 'screenshot');
|
|
1290
1538
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1291
1539
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1292
1540
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1488,6 +1736,9 @@ export class BrowserService {
|
|
|
1488
1736
|
return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
|
|
1489
1737
|
}
|
|
1490
1738
|
async recordStop(taskId, reason = 'manual') {
|
|
1739
|
+
const activeTask = await this.findTask(taskId).catch(() => undefined);
|
|
1740
|
+
if (activeTask)
|
|
1741
|
+
requireCdp(activeTask.conn, 'screenshot');
|
|
1491
1742
|
const rec = this.recordings.get(taskId);
|
|
1492
1743
|
if (!rec) {
|
|
1493
1744
|
throw new Error(`Task "${taskId}" is not currently recording`);
|
|
@@ -1570,6 +1821,13 @@ export class BrowserService {
|
|
|
1570
1821
|
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
1571
1822
|
}
|
|
1572
1823
|
async recordStatus(taskId) {
|
|
1824
|
+
// The idle reaper calls this while deciding whether a task is stale. Do
|
|
1825
|
+
// not route an already-live task through findTask(), which stamps it as
|
|
1826
|
+
// active and defeats idle detection. A cold daemon may still rehydrate for
|
|
1827
|
+
// an explicit user query.
|
|
1828
|
+
const live = this.findTaskByHandle(taskId) ?? await this.findTask(taskId).catch(() => undefined);
|
|
1829
|
+
if (live)
|
|
1830
|
+
requireCdp(live.conn, 'screenshot');
|
|
1573
1831
|
const rec = this.recordings.get(taskId);
|
|
1574
1832
|
if (!rec)
|
|
1575
1833
|
return { recording: false };
|
|
@@ -1578,6 +1836,21 @@ export class BrowserService {
|
|
|
1578
1836
|
async refs(taskId, tabHint, opts = {}) {
|
|
1579
1837
|
const { conn, task } = await this.findTask(taskId);
|
|
1580
1838
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1839
|
+
if (conn.backend === 'arc-native') {
|
|
1840
|
+
const result = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(opts)), opts);
|
|
1841
|
+
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1842
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
1843
|
+
return result;
|
|
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
|
+
}
|
|
1853
|
+
requireCdp(conn, 'enumerate');
|
|
1581
1854
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1582
1855
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1583
1856
|
if (!target)
|
|
@@ -1614,7 +1887,7 @@ export class BrowserService {
|
|
|
1614
1887
|
const stillMatches = fresh !== undefined &&
|
|
1615
1888
|
fresh.role === cached.role &&
|
|
1616
1889
|
fresh.name === cached.name &&
|
|
1617
|
-
fresh.backendNodeId !== undefined;
|
|
1890
|
+
(fresh.backendNodeId !== undefined || fresh.selector !== undefined);
|
|
1618
1891
|
if (stillMatches)
|
|
1619
1892
|
return { targetRef: ref };
|
|
1620
1893
|
const newRef = healRef(cached, nodeMap);
|
|
@@ -1632,6 +1905,31 @@ export class BrowserService {
|
|
|
1632
1905
|
async click(taskId, ref, tabHint) {
|
|
1633
1906
|
const { conn, task } = await this.findTask(taskId);
|
|
1634
1907
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1908
|
+
if (conn.backend === 'arc-native') {
|
|
1909
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1910
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1911
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1912
|
+
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1913
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
1914
|
+
if (!selector)
|
|
1915
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1916
|
+
await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
|
|
1917
|
+
return healed ? { healed } : {};
|
|
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
|
+
}
|
|
1932
|
+
requireCdp(conn, 'click');
|
|
1635
1933
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1636
1934
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1637
1935
|
if (!target)
|
|
@@ -1663,6 +1961,7 @@ export class BrowserService {
|
|
|
1663
1961
|
*/
|
|
1664
1962
|
async clickAt(taskId, x, y, tabHint) {
|
|
1665
1963
|
const { conn, task } = await this.findTask(taskId);
|
|
1964
|
+
requireCdp(conn, 'trustedInput');
|
|
1666
1965
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1667
1966
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1668
1967
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1674,6 +1973,29 @@ export class BrowserService {
|
|
|
1674
1973
|
async type(taskId, ref, text, tabHint, clear) {
|
|
1675
1974
|
const { conn, task } = await this.findTask(taskId);
|
|
1676
1975
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1976
|
+
if (conn.backend === 'arc-native') {
|
|
1977
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1978
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1979
|
+
const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1980
|
+
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1981
|
+
const selector = nodeMap.get(targetRef)?.selector;
|
|
1982
|
+
if (!selector)
|
|
1983
|
+
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1984
|
+
await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
1985
|
+
return;
|
|
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
|
+
}
|
|
1998
|
+
requireCdp(conn, 'type');
|
|
1677
1999
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1678
2000
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1679
2001
|
if (!target)
|
|
@@ -1701,6 +2023,7 @@ export class BrowserService {
|
|
|
1701
2023
|
}
|
|
1702
2024
|
async press(taskId, key, tabHint) {
|
|
1703
2025
|
const { conn, task } = await this.findTask(taskId);
|
|
2026
|
+
requireCdp(conn, 'trustedInput');
|
|
1704
2027
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1705
2028
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1706
2029
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1711,6 +2034,7 @@ export class BrowserService {
|
|
|
1711
2034
|
}
|
|
1712
2035
|
async hover(taskId, ref, tabHint) {
|
|
1713
2036
|
const { conn, task } = await this.findTask(taskId);
|
|
2037
|
+
requireCdp(conn, 'trustedInput');
|
|
1714
2038
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1715
2039
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1716
2040
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1724,6 +2048,17 @@ export class BrowserService {
|
|
|
1724
2048
|
async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
|
|
1725
2049
|
const { conn, task } = await this.findTask(taskId);
|
|
1726
2050
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2051
|
+
if (conn.backend === 'arc-native') {
|
|
2052
|
+
if (atX !== undefined || atY !== undefined)
|
|
2053
|
+
throw new ArcNativeCapabilityError('trustedInput');
|
|
2054
|
+
await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (conn.backend === 'bidi') {
|
|
2058
|
+
await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
requireCdp(conn, 'scroll');
|
|
1727
2062
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1728
2063
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1729
2064
|
if (!target)
|
|
@@ -1733,6 +2068,7 @@ export class BrowserService {
|
|
|
1733
2068
|
}
|
|
1734
2069
|
async upload(taskId, files, options) {
|
|
1735
2070
|
const { conn, task } = await this.findTask(taskId);
|
|
2071
|
+
requireCdp(conn, 'upload');
|
|
1736
2072
|
const shortId = options.tabHint
|
|
1737
2073
|
? await this.resolveTabHint(conn, task, options.tabHint)
|
|
1738
2074
|
: this.resolveCurrentTab(task);
|
|
@@ -1883,6 +2219,7 @@ export class BrowserService {
|
|
|
1883
2219
|
// ─── Viewport & Device Emulation ──────────────────────────────────────────────
|
|
1884
2220
|
async setViewport(taskId, width, height, options = {}) {
|
|
1885
2221
|
const { conn, task } = await this.findTask(taskId);
|
|
2222
|
+
requireCdp(conn, 'viewport');
|
|
1886
2223
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
1887
2224
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1888
2225
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1963,6 +2300,7 @@ export class BrowserService {
|
|
|
1963
2300
|
}
|
|
1964
2301
|
async getConsoleLogs(taskId, options = {}) {
|
|
1965
2302
|
const { conn, task } = await this.findTask(taskId);
|
|
2303
|
+
requireCdp(conn, 'consoleCapture');
|
|
1966
2304
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
1967
2305
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1968
2306
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1981,6 +2319,7 @@ export class BrowserService {
|
|
|
1981
2319
|
}
|
|
1982
2320
|
async getErrors(taskId, options = {}) {
|
|
1983
2321
|
const { conn, task } = await this.findTask(taskId);
|
|
2322
|
+
requireCdp(conn, 'consoleCapture');
|
|
1984
2323
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
1985
2324
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1986
2325
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2033,6 +2372,7 @@ export class BrowserService {
|
|
|
2033
2372
|
}
|
|
2034
2373
|
async getNetworkRequests(taskId, options = {}) {
|
|
2035
2374
|
const { conn, task } = await this.findTask(taskId);
|
|
2375
|
+
requireCdp(conn, 'networkCapture');
|
|
2036
2376
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2037
2377
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2038
2378
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2052,6 +2392,7 @@ export class BrowserService {
|
|
|
2052
2392
|
}
|
|
2053
2393
|
async getResponseBody(taskId, urlPattern, options = {}) {
|
|
2054
2394
|
const { conn, task } = await this.findTask(taskId);
|
|
2395
|
+
requireCdp(conn, 'networkCapture');
|
|
2055
2396
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2056
2397
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2057
2398
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2125,11 +2466,12 @@ export class BrowserService {
|
|
|
2125
2466
|
// ─── Wait Conditions ─────────────────────────────────────────────────────────
|
|
2126
2467
|
async wait(taskId, type, value, options = {}) {
|
|
2127
2468
|
const timeout = options.timeout ?? 30000;
|
|
2469
|
+
const { conn, task } = await this.findTask(taskId);
|
|
2470
|
+
requireCdp(conn, 'asyncEvaluate');
|
|
2128
2471
|
if (type === 'time') {
|
|
2129
2472
|
await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
|
|
2130
2473
|
return;
|
|
2131
2474
|
}
|
|
2132
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2133
2475
|
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2134
2476
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2135
2477
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2202,6 +2544,7 @@ export class BrowserService {
|
|
|
2202
2544
|
}
|
|
2203
2545
|
async setDownloadPath(taskId, downloadPath, tabHint) {
|
|
2204
2546
|
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
2547
|
+
requireCdp(conn, 'download');
|
|
2205
2548
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2206
2549
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2207
2550
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -2230,6 +2573,8 @@ export class BrowserService {
|
|
|
2230
2573
|
return resolvedPath;
|
|
2231
2574
|
}
|
|
2232
2575
|
async waitForDownload(taskId, timeout = 60000) {
|
|
2576
|
+
const { conn } = await this.findTask(taskId);
|
|
2577
|
+
requireCdp(conn, 'download');
|
|
2233
2578
|
const start = Date.now();
|
|
2234
2579
|
while (Date.now() - start < timeout) {
|
|
2235
2580
|
const dl = this.pendingDownloads.get(taskId);
|
|
@@ -2284,7 +2629,10 @@ export class BrowserService {
|
|
|
2284
2629
|
this.recordings.delete(taskId);
|
|
2285
2630
|
}
|
|
2286
2631
|
for (const [, conn] of this.connections) {
|
|
2287
|
-
conn.
|
|
2632
|
+
if (conn.backend === 'bidi')
|
|
2633
|
+
conn.bidi.close();
|
|
2634
|
+
else if (conn.backend !== 'arc-native')
|
|
2635
|
+
conn.cdp.close();
|
|
2288
2636
|
conn.cleanup?.();
|
|
2289
2637
|
}
|
|
2290
2638
|
this.connections.clear();
|
|
@@ -2310,6 +2658,7 @@ export class BrowserService {
|
|
|
2310
2658
|
await cdp.connect(wsUrl);
|
|
2311
2659
|
await this.enableDomains(cdp);
|
|
2312
2660
|
const connection = {
|
|
2661
|
+
backend: 'cdp',
|
|
2313
2662
|
cdp,
|
|
2314
2663
|
port,
|
|
2315
2664
|
pid,
|
|
@@ -2338,6 +2687,11 @@ export class BrowserService {
|
|
|
2338
2687
|
async connectProfile(effectiveProfile, target, key, opts = {}) {
|
|
2339
2688
|
const existingInfo = getRunningChromeInfo(key);
|
|
2340
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:');
|
|
2341
2695
|
if (tunnelled) {
|
|
2342
2696
|
// A leftover local chrome-data / pid file under this key is the
|
|
2343
2697
|
// pre-T2 logged-out browser on THIS box. Attaching localhost CDP
|
|
@@ -2348,7 +2702,7 @@ export class BrowserService {
|
|
|
2348
2702
|
clearProfileRuntime(key);
|
|
2349
2703
|
}
|
|
2350
2704
|
}
|
|
2351
|
-
else if (existingInfo) {
|
|
2705
|
+
else if (existingInfo && !nativeTransport) {
|
|
2352
2706
|
try {
|
|
2353
2707
|
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
|
|
2354
2708
|
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
@@ -2357,6 +2711,7 @@ export class BrowserService {
|
|
|
2357
2711
|
await this.enableDomains(cdp);
|
|
2358
2712
|
const tasks = this.loadTaskState(key);
|
|
2359
2713
|
return {
|
|
2714
|
+
backend: 'cdp',
|
|
2360
2715
|
cdp,
|
|
2361
2716
|
port: existingInfo.port,
|
|
2362
2717
|
pid: existingInfo.pid,
|
|
@@ -2389,6 +2744,7 @@ export class BrowserService {
|
|
|
2389
2744
|
const conn = await connectLocal(endpoint, profile, key);
|
|
2390
2745
|
await this.enableDomains(conn.cdp);
|
|
2391
2746
|
return {
|
|
2747
|
+
backend: 'cdp',
|
|
2392
2748
|
cdp: conn.cdp,
|
|
2393
2749
|
port: conn.port,
|
|
2394
2750
|
pid: conn.pid,
|
|
@@ -2405,6 +2761,7 @@ export class BrowserService {
|
|
|
2405
2761
|
});
|
|
2406
2762
|
await this.enableDomains(conn.cdp);
|
|
2407
2763
|
return {
|
|
2764
|
+
backend: 'cdp',
|
|
2408
2765
|
cdp: conn.cdp,
|
|
2409
2766
|
port: conn.port,
|
|
2410
2767
|
pid: conn.pid,
|
|
@@ -2427,6 +2784,7 @@ export class BrowserService {
|
|
|
2427
2784
|
}
|
|
2428
2785
|
await this.enableDomains(cdp);
|
|
2429
2786
|
return {
|
|
2787
|
+
backend: 'cdp',
|
|
2430
2788
|
cdp,
|
|
2431
2789
|
port: 0,
|
|
2432
2790
|
pid: 0,
|
|
@@ -2445,6 +2803,7 @@ export class BrowserService {
|
|
|
2445
2803
|
await cdp.connect(wsUrl);
|
|
2446
2804
|
await this.enableDomains(cdp);
|
|
2447
2805
|
return {
|
|
2806
|
+
backend: 'cdp',
|
|
2448
2807
|
cdp,
|
|
2449
2808
|
port,
|
|
2450
2809
|
pid: 0,
|
|
@@ -2455,6 +2814,65 @@ export class BrowserService {
|
|
|
2455
2814
|
sessionCache: new Map(),
|
|
2456
2815
|
};
|
|
2457
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
|
+
}
|
|
2850
|
+
// Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
|
|
2851
|
+
// Apple Events instead of CDP. No debugging port, no browser launch.
|
|
2852
|
+
if (url.protocol === 'arc-native:') {
|
|
2853
|
+
if (process.platform !== 'darwin') {
|
|
2854
|
+
throw new Error('Native Arc automation is only available on macOS');
|
|
2855
|
+
}
|
|
2856
|
+
if (!(await isArcRunning())) {
|
|
2857
|
+
throw new Error('Arc is not running. Start Arc normally, then retry.\n' +
|
|
2858
|
+
'Native Arc automation drives your existing browser — it does not launch one.');
|
|
2859
|
+
}
|
|
2860
|
+
if (!profile.arc) {
|
|
2861
|
+
throw new Error(`Native Arc profile "${profile.name}" has no stable native profile metadata. ` +
|
|
2862
|
+
`Re-select it with: agents browser use arc-<profile-id>`);
|
|
2863
|
+
}
|
|
2864
|
+
return {
|
|
2865
|
+
port: 0,
|
|
2866
|
+
pid: 0,
|
|
2867
|
+
electron: false,
|
|
2868
|
+
browserType: 'arc',
|
|
2869
|
+
targetFilter: profile.targetFilter,
|
|
2870
|
+
tasks: this.loadTaskState(key),
|
|
2871
|
+
sessionCache: new Map(),
|
|
2872
|
+
backend: 'arc-native',
|
|
2873
|
+
arcProfile: profile.arc,
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2458
2876
|
return null;
|
|
2459
2877
|
}
|
|
2460
2878
|
async enableDomains(cdp) {
|
|
@@ -2471,6 +2889,8 @@ export class BrowserService {
|
|
|
2471
2889
|
if (conn.browserType === 'arc') {
|
|
2472
2890
|
throw arcNotDrivableError(conn.profile);
|
|
2473
2891
|
}
|
|
2892
|
+
// Firefox opens tabs through the BiDi driver, never Target.createTarget.
|
|
2893
|
+
requireCdp(conn, 'createTab');
|
|
2474
2894
|
return (await conn.cdp.send('Target.createTarget', params));
|
|
2475
2895
|
}
|
|
2476
2896
|
async getOrCreateWindow(conn) {
|
|
@@ -2660,6 +3080,7 @@ export class BrowserService {
|
|
|
2660
3080
|
const matches = this.listTasksForCaller(caller);
|
|
2661
3081
|
if (matches.length === 1) {
|
|
2662
3082
|
const m = matches[0];
|
|
3083
|
+
await this.prepareTask(m.conn, m.task);
|
|
2663
3084
|
await this.touchTask(m.conn, m.task);
|
|
2664
3085
|
return { conn: m.conn, task: m.task, key: m.key, created: false };
|
|
2665
3086
|
}
|
|
@@ -2790,6 +3211,18 @@ export class BrowserService {
|
|
|
2790
3211
|
// `recordBrowserSession` call in `start` uses for the same reason.
|
|
2791
3212
|
}
|
|
2792
3213
|
}
|
|
3214
|
+
async prepareTask(conn, task) {
|
|
3215
|
+
if (conn.backend !== 'arc-native')
|
|
3216
|
+
return;
|
|
3217
|
+
await this.runExclusive('arc-native:host-app', async () => {
|
|
3218
|
+
await this.reconcileArcCreateIntents(conn, task);
|
|
3219
|
+
const native = this.requireArcTask(task);
|
|
3220
|
+
if (native.profileId !== conn.arcProfile.profileId) {
|
|
3221
|
+
throw new Error(`Arc task profile ${JSON.stringify(native.profileId)} does not match connected profile ` +
|
|
3222
|
+
`${JSON.stringify(conn.arcProfile.profileId)}.`);
|
|
3223
|
+
}
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
2793
3226
|
async findTask(taskId, profileRef) {
|
|
2794
3227
|
if (profileRef) {
|
|
2795
3228
|
// Accept bare profile names against composite connection keys.
|
|
@@ -2801,6 +3234,7 @@ export class BrowserService {
|
|
|
2801
3234
|
const conn = this.connections.get(key);
|
|
2802
3235
|
const task = this.lookupTaskOnConn(conn, taskId);
|
|
2803
3236
|
if (task) {
|
|
3237
|
+
await this.prepareTask(conn, task);
|
|
2804
3238
|
await this.touchTask(conn, task);
|
|
2805
3239
|
return { conn, task, key: conn.key ?? key };
|
|
2806
3240
|
}
|
|
@@ -2808,6 +3242,7 @@ export class BrowserService {
|
|
|
2808
3242
|
// RAM miss → rehydrate from disk for this profile.
|
|
2809
3243
|
const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
|
|
2810
3244
|
if (rehydrated) {
|
|
3245
|
+
await this.prepareTask(rehydrated.conn, rehydrated.task);
|
|
2811
3246
|
await this.touchTask(rehydrated.conn, rehydrated.task);
|
|
2812
3247
|
return rehydrated;
|
|
2813
3248
|
}
|
|
@@ -2816,6 +3251,7 @@ export class BrowserService {
|
|
|
2816
3251
|
for (const [key, conn] of this.connections) {
|
|
2817
3252
|
const task = this.lookupTaskOnConn(conn, taskId);
|
|
2818
3253
|
if (task) {
|
|
3254
|
+
await this.prepareTask(conn, task);
|
|
2819
3255
|
await this.touchTask(conn, task);
|
|
2820
3256
|
return { conn, task, key: conn.key ?? key };
|
|
2821
3257
|
}
|
|
@@ -2824,6 +3260,7 @@ export class BrowserService {
|
|
|
2824
3260
|
// meta.json still has the browser pid/port. Reconnect and adopt.
|
|
2825
3261
|
const rehydrated = await this.rehydrateTaskFromDisk(taskId);
|
|
2826
3262
|
if (rehydrated) {
|
|
3263
|
+
await this.prepareTask(rehydrated.conn, rehydrated.task);
|
|
2827
3264
|
await this.touchTask(rehydrated.conn, rehydrated.task);
|
|
2828
3265
|
return rehydrated;
|
|
2829
3266
|
}
|
|
@@ -2861,6 +3298,71 @@ export class BrowserService {
|
|
|
2861
3298
|
catch {
|
|
2862
3299
|
return null;
|
|
2863
3300
|
}
|
|
3301
|
+
// Native Arc endpoints (PHNX-2399): reconnect via the native driver if Arc
|
|
3302
|
+
// is still running. No port, no CDP — just verify Arc is alive and restore
|
|
3303
|
+
// the task state.
|
|
3304
|
+
if (resolved.target.startsWith('arc-native:')) {
|
|
3305
|
+
if (process.platform !== 'darwin' || !(await isArcRunning()))
|
|
3306
|
+
return null;
|
|
3307
|
+
const tasks = this.loadTaskState(key);
|
|
3308
|
+
for (const [k, t] of diskTasks) {
|
|
3309
|
+
if (!tasks.has(k))
|
|
3310
|
+
tasks.set(k, t);
|
|
3311
|
+
}
|
|
3312
|
+
if (!profile.arc)
|
|
3313
|
+
return null;
|
|
3314
|
+
return {
|
|
3315
|
+
port: 0,
|
|
3316
|
+
pid: 0,
|
|
3317
|
+
electron: false,
|
|
3318
|
+
browserType: 'arc',
|
|
3319
|
+
targetFilter: profile.targetFilter,
|
|
3320
|
+
key,
|
|
3321
|
+
profile: bare,
|
|
3322
|
+
tasks,
|
|
3323
|
+
sessionCache: new Map(),
|
|
3324
|
+
backend: 'arc-native',
|
|
3325
|
+
arcProfile: profile.arc,
|
|
3326
|
+
};
|
|
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
|
+
}
|
|
2864
3366
|
const existingInfo = getRunningChromeInfo(key);
|
|
2865
3367
|
const parsed = parseEndpointUrl(resolved.target);
|
|
2866
3368
|
const port = existingInfo?.port ?? parsed?.port;
|
|
@@ -2886,6 +3388,7 @@ export class BrowserService {
|
|
|
2886
3388
|
tasks.set(k, t);
|
|
2887
3389
|
}
|
|
2888
3390
|
return {
|
|
3391
|
+
backend: 'cdp',
|
|
2889
3392
|
cdp: conn.cdp,
|
|
2890
3393
|
port: conn.port,
|
|
2891
3394
|
pid: conn.pid,
|
|
@@ -2918,6 +3421,7 @@ export class BrowserService {
|
|
|
2918
3421
|
tasks.set(k, t);
|
|
2919
3422
|
}
|
|
2920
3423
|
const conn = {
|
|
3424
|
+
backend: 'cdp',
|
|
2921
3425
|
cdp,
|
|
2922
3426
|
port,
|
|
2923
3427
|
pid: existingInfo?.pid ?? 0,
|
|
@@ -2974,11 +3478,19 @@ export class BrowserService {
|
|
|
2974
3478
|
}
|
|
2975
3479
|
else {
|
|
2976
3480
|
// Shared/borrowed tunnel (or a local connection with no tunnel): close
|
|
2977
|
-
// only our own
|
|
2978
|
-
|
|
2979
|
-
|
|
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') {
|
|
3489
|
+
try {
|
|
3490
|
+
conn.cdp.close();
|
|
3491
|
+
}
|
|
3492
|
+
catch { /* best effort */ }
|
|
2980
3493
|
}
|
|
2981
|
-
catch { /* best effort */ }
|
|
2982
3494
|
}
|
|
2983
3495
|
return existing;
|
|
2984
3496
|
}
|
|
@@ -3019,7 +3531,8 @@ export class BrowserService {
|
|
|
3019
3531
|
if (registered !== conn)
|
|
3020
3532
|
continue;
|
|
3021
3533
|
try {
|
|
3022
|
-
|
|
3534
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3535
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3023
3536
|
}
|
|
3024
3537
|
catch {
|
|
3025
3538
|
// Non-fatal for rehydrate.
|
|
@@ -3080,7 +3593,8 @@ export class BrowserService {
|
|
|
3080
3593
|
conn = this.registerRehydratedConnection(key, attached);
|
|
3081
3594
|
if (conn === attached) {
|
|
3082
3595
|
try {
|
|
3083
|
-
|
|
3596
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3597
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3084
3598
|
}
|
|
3085
3599
|
catch {
|
|
3086
3600
|
// Non-fatal.
|
|
@@ -3117,6 +3631,80 @@ export class BrowserService {
|
|
|
3117
3631
|
const conn = this.connections.get(key);
|
|
3118
3632
|
if (!conn)
|
|
3119
3633
|
return null;
|
|
3634
|
+
if (conn.backend === 'arc-native') {
|
|
3635
|
+
const tasks = [];
|
|
3636
|
+
for (const task of conn.tasks.values()) {
|
|
3637
|
+
const tabs = await this.listArcTaskTabs(task);
|
|
3638
|
+
const domains = tabs.flatMap((tab) => {
|
|
3639
|
+
try {
|
|
3640
|
+
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
3641
|
+
return domain && domain !== 'blank' ? [domain] : [];
|
|
3642
|
+
}
|
|
3643
|
+
catch {
|
|
3644
|
+
return [];
|
|
3645
|
+
}
|
|
3646
|
+
});
|
|
3647
|
+
tasks.push({
|
|
3648
|
+
id: task.id,
|
|
3649
|
+
name: task.name,
|
|
3650
|
+
label: task.label ?? task.name,
|
|
3651
|
+
tabCount: Object.keys(task.tabs).length,
|
|
3652
|
+
currentTabId: task.currentTabId,
|
|
3653
|
+
createdAt: task.createdAt,
|
|
3654
|
+
tabs: tabs.length ? tabs : undefined,
|
|
3655
|
+
domains: [...new Set(domains)],
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
const parsed = parseConnectionKey(key);
|
|
3659
|
+
return {
|
|
3660
|
+
name: conn.profile ?? parsed.profile,
|
|
3661
|
+
endpoint: parsed.endpoint,
|
|
3662
|
+
key,
|
|
3663
|
+
running: await isArcRunning(),
|
|
3664
|
+
port: 0,
|
|
3665
|
+
pid: 0,
|
|
3666
|
+
tasks,
|
|
3667
|
+
};
|
|
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
|
+
}
|
|
3120
3708
|
// Fetch all targets once for efficiency
|
|
3121
3709
|
let targets = [];
|
|
3122
3710
|
try {
|
|
@@ -3339,10 +3927,18 @@ export class BrowserService {
|
|
|
3339
3927
|
return undefined;
|
|
3340
3928
|
if (await isConnHealthy(conn))
|
|
3341
3929
|
return conn;
|
|
3342
|
-
|
|
3343
|
-
|
|
3930
|
+
if (conn.backend === 'bidi') {
|
|
3931
|
+
try {
|
|
3932
|
+
conn.bidi.close();
|
|
3933
|
+
}
|
|
3934
|
+
catch { /* already closed */ }
|
|
3935
|
+
}
|
|
3936
|
+
else if (conn.backend !== 'arc-native') {
|
|
3937
|
+
try {
|
|
3938
|
+
conn.cdp.close();
|
|
3939
|
+
}
|
|
3940
|
+
catch { /* already closed */ }
|
|
3344
3941
|
}
|
|
3345
|
-
catch { /* already closed */ }
|
|
3346
3942
|
conn.cleanup?.();
|
|
3347
3943
|
this.connections.delete(key);
|
|
3348
3944
|
return undefined;
|
|
@@ -3373,13 +3969,410 @@ export class BrowserService {
|
|
|
3373
3969
|
}
|
|
3374
3970
|
return undefined;
|
|
3375
3971
|
}
|
|
3972
|
+
// ---------------------------------------------------------------------------
|
|
3973
|
+
// Native Arc backend helpers (PHNX-2399)
|
|
3974
|
+
// ---------------------------------------------------------------------------
|
|
3975
|
+
/**
|
|
3976
|
+
* Bind a task to the profile's Space in the visible Arc window that holds it.
|
|
3977
|
+
* The Space is the profile (PHNX-2399); only its live window id is looked up.
|
|
3978
|
+
*/
|
|
3979
|
+
async resolveArcSpace(profile) {
|
|
3980
|
+
const live = (await enumerateArcSpaces()).filter((space) => space.spaceId === profile.spaceId);
|
|
3981
|
+
if (live.length !== 1) {
|
|
3982
|
+
throw new Error(live.length === 0
|
|
3983
|
+
? `Arc Space ${JSON.stringify(profile.spaceTitle)} (${profile.spaceId}) is not open in a visible Arc window.`
|
|
3984
|
+
: `Arc Space ${JSON.stringify(profile.spaceTitle)} is open in more than one window; refusing an ambiguous native address.`);
|
|
3985
|
+
}
|
|
3986
|
+
return {
|
|
3987
|
+
windowId: live[0].windowId,
|
|
3988
|
+
spaceId: live[0].spaceId,
|
|
3989
|
+
spaceTitle: profile.spaceTitle,
|
|
3990
|
+
};
|
|
3991
|
+
}
|
|
3992
|
+
requireArcTask(task) {
|
|
3993
|
+
const native = task.arcNative;
|
|
3994
|
+
if (!native || typeof native.profileId !== 'string' || typeof native.windowId !== 'string' ||
|
|
3995
|
+
typeof native.spaceId !== 'string' || !native.tabs || typeof native.tabs !== 'object') {
|
|
3996
|
+
throw new Error(`Task ${JSON.stringify(task.name)} has no valid durable Arc identity.`);
|
|
3997
|
+
}
|
|
3998
|
+
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
3999
|
+
if (!ref || typeof ref.windowId !== 'string' || typeof ref.spaceId !== 'string' ||
|
|
4000
|
+
typeof ref.tabId !== 'string' || ref.windowId !== native.windowId || ref.spaceId !== native.spaceId) {
|
|
4001
|
+
throw new Error(`Owned Arc tab ${JSON.stringify(shortId)} has no stable id in its original window/Space.`);
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
for (const [shortId, intent] of Object.entries(native.createIntents ?? {})) {
|
|
4005
|
+
if (!intent || intent.tabId !== shortId || typeof intent.markerUrl !== 'string' ||
|
|
4006
|
+
!isArcCreateMarker(intent.markerUrl) ||
|
|
4007
|
+
typeof intent.targetUrl !== 'string' || typeof intent.createdAt !== 'number') {
|
|
4008
|
+
throw new Error(`Arc creation intent ${JSON.stringify(shortId)} is invalid; refusing recovery.`);
|
|
4009
|
+
}
|
|
4010
|
+
if (intent.ref && (intent.ref.windowId !== native.windowId || intent.ref.spaceId !== native.spaceId ||
|
|
4011
|
+
typeof intent.ref.tabId !== 'string')) {
|
|
4012
|
+
throw new Error(`Arc creation intent ${JSON.stringify(shortId)} left its original window/Space.`);
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
return native;
|
|
4016
|
+
}
|
|
4017
|
+
async reconcileArcCreateIntents(conn, task) {
|
|
4018
|
+
const native = this.requireArcTask(task);
|
|
4019
|
+
for (const intent of Object.values(native.createIntents ?? {})) {
|
|
4020
|
+
let ref = intent.ref;
|
|
4021
|
+
if (ref) {
|
|
4022
|
+
const live = await resolveArcTab(ref);
|
|
4023
|
+
if (!live) {
|
|
4024
|
+
throw new Error(`Owned Arc tab ${intent.tabId} left its original window/Space while creation was incomplete; refusing to adopt it elsewhere.`);
|
|
4025
|
+
}
|
|
4026
|
+
if (live.url === intent.targetUrl) {
|
|
4027
|
+
native.tabs[intent.tabId] = ref;
|
|
4028
|
+
task.tabs[intent.tabId] = ref.tabId;
|
|
4029
|
+
if (intent.previousTabId) {
|
|
4030
|
+
await restoreArcSelection(ref, intent.previousTabId);
|
|
4031
|
+
}
|
|
4032
|
+
delete native.createIntents?.[intent.tabId];
|
|
4033
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4034
|
+
continue;
|
|
4035
|
+
}
|
|
4036
|
+
if (live.url !== intent.markerUrl) {
|
|
4037
|
+
throw new Error(`Owned Arc tab ${intent.tabId} changed outside this task during creation; refusing to navigate it.`);
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
else {
|
|
4041
|
+
const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
|
|
4042
|
+
if (!originalSpace) {
|
|
4043
|
+
throw new Error('The original Arc window/Space for this task is no longer present.');
|
|
4044
|
+
}
|
|
4045
|
+
const markerMatches = originalSpace.tabs.filter((tab) => tab.url === intent.markerUrl);
|
|
4046
|
+
if (markerMatches.length > 1) {
|
|
4047
|
+
throw new Error(`Arc creation marker ${JSON.stringify(intent.markerUrl)} is not unique in the original Space.`);
|
|
4048
|
+
}
|
|
4049
|
+
if (markerMatches[0]) {
|
|
4050
|
+
ref = markerMatches[0];
|
|
4051
|
+
}
|
|
4052
|
+
else {
|
|
4053
|
+
try {
|
|
4054
|
+
ref = await createArcTab({ windowId: native.windowId, spaceId: native.spaceId }, intent.markerUrl);
|
|
4055
|
+
}
|
|
4056
|
+
catch (error) {
|
|
4057
|
+
// Arc selects the Space it was asked to create in even when the
|
|
4058
|
+
// creation itself fails. Put the owner back on their tab before
|
|
4059
|
+
// surfacing the error, so a failed agent verb never leaves them
|
|
4060
|
+
// staring at the wrong Space.
|
|
4061
|
+
if (intent.previousTabId) {
|
|
4062
|
+
await selectWindowTab(native.windowId, intent.previousTabId).catch(() => undefined);
|
|
4063
|
+
}
|
|
4064
|
+
throw error;
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
intent.ref = ref;
|
|
4068
|
+
native.tabs[intent.tabId] = ref;
|
|
4069
|
+
task.tabs[intent.tabId] = ref.tabId;
|
|
4070
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4071
|
+
}
|
|
4072
|
+
await navigateArcTab(ref, intent.targetUrl);
|
|
4073
|
+
if (intent.previousTabId) {
|
|
4074
|
+
await restoreArcSelection(ref, intent.previousTabId);
|
|
4075
|
+
}
|
|
4076
|
+
delete native.createIntents?.[intent.tabId];
|
|
4077
|
+
task.currentTabId = intent.tabId;
|
|
4078
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
async createArcOwnedTab(conn, task, url) {
|
|
4082
|
+
const native = this.requireArcTask(task);
|
|
4083
|
+
const tabId = generateShortId();
|
|
4084
|
+
const markerUrl = arcCreateMarker();
|
|
4085
|
+
const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
|
|
4086
|
+
if (!originalSpace)
|
|
4087
|
+
throw new Error('The original Arc window/Space for this task is no longer present.');
|
|
4088
|
+
native.createIntents ??= {};
|
|
4089
|
+
native.createIntents[tabId] = {
|
|
4090
|
+
tabId,
|
|
4091
|
+
markerUrl,
|
|
4092
|
+
targetUrl: url,
|
|
4093
|
+
createdAt: Date.now(),
|
|
4094
|
+
previousTabId: originalSpace.activeTabId,
|
|
4095
|
+
};
|
|
4096
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4097
|
+
await this.reconcileArcCreateIntents(conn, task);
|
|
4098
|
+
return tabId;
|
|
4099
|
+
}
|
|
4100
|
+
async navigateArcNative(conn, task, url, addTab = false) {
|
|
4101
|
+
return this.runExclusive('arc-native:host-app', async () => {
|
|
4102
|
+
await this.reconcileArcCreateIntents(conn, task);
|
|
4103
|
+
const native = this.requireArcTask(task);
|
|
4104
|
+
if (!addTab) {
|
|
4105
|
+
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4106
|
+
const live = await resolveArcTab(ref);
|
|
4107
|
+
if (live && canonicalTabUrl(live.url) === canonicalTabUrl(url)) {
|
|
4108
|
+
await navigateArcTab(ref, url);
|
|
4109
|
+
task.currentTabId = shortId;
|
|
4110
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
4111
|
+
return { tabId: shortId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
const currentId = task.currentTabId;
|
|
4116
|
+
if (!addTab && currentId) {
|
|
4117
|
+
const ref = native.tabs[currentId];
|
|
4118
|
+
if (!ref || !(await resolveArcTab(ref))) {
|
|
4119
|
+
throw new Error(`Owned Arc tab ${currentId} is missing from its original window/Space; refusing to adopt a moved tab.`);
|
|
4120
|
+
}
|
|
4121
|
+
await navigateArcTab(ref, url);
|
|
4122
|
+
return { tabId: currentId, url, created: false, refreshed: false };
|
|
4123
|
+
}
|
|
4124
|
+
const tabId = await this.createArcOwnedTab(conn, task, url);
|
|
4125
|
+
return { tabId, url, created: true, refreshed: false };
|
|
4126
|
+
});
|
|
4127
|
+
}
|
|
4128
|
+
/**
|
|
4129
|
+
* Execute JavaScript in a native Arc tab. Sync JS only, isolated world —
|
|
4130
|
+
* async/promise evaluation fails honestly with an AppleScript error.
|
|
4131
|
+
*/
|
|
4132
|
+
async evaluateArcNative(conn, task, shortId, expression) {
|
|
4133
|
+
const ref = this.requireArcTask(task).tabs[shortId];
|
|
4134
|
+
if (!ref || !(await resolveArcTab(ref))) {
|
|
4135
|
+
throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space.`);
|
|
4136
|
+
}
|
|
4137
|
+
return executeJavaScript(ref, expression);
|
|
4138
|
+
}
|
|
4139
|
+
async listArcTaskTabs(task) {
|
|
4140
|
+
const native = this.requireArcTask(task);
|
|
4141
|
+
const tabs = [];
|
|
4142
|
+
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4143
|
+
const live = await resolveArcTab(ref);
|
|
4144
|
+
if (!live) {
|
|
4145
|
+
throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
|
|
4146
|
+
}
|
|
4147
|
+
tabs.push({
|
|
4148
|
+
id: shortId,
|
|
4149
|
+
url: live.url,
|
|
4150
|
+
title: live.title,
|
|
4151
|
+
task: task.name,
|
|
4152
|
+
current: shortId === task.currentTabId,
|
|
4153
|
+
});
|
|
4154
|
+
}
|
|
4155
|
+
return tabs;
|
|
4156
|
+
}
|
|
4157
|
+
/**
|
|
4158
|
+
* Close native Arc tabs owned by a task. Only closes tabs the task created
|
|
4159
|
+
* (not borrowed ones). Never kills Arc. Identifies tabs only by stable ids in
|
|
4160
|
+
* the original window and Space.
|
|
4161
|
+
*/
|
|
4162
|
+
async closeArcNativeTabs(conn, task) {
|
|
4163
|
+
await this.runExclusive('arc-native:host-app', async () => {
|
|
4164
|
+
await this.reconcileArcCreateIntents(conn, task);
|
|
4165
|
+
const native = this.requireArcTask(task);
|
|
4166
|
+
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4167
|
+
await closeArcTab(ref);
|
|
4168
|
+
delete native.tabs[shortId];
|
|
4169
|
+
delete task.tabs[shortId];
|
|
4170
|
+
delete task.refDescriptors?.[shortId];
|
|
4171
|
+
}
|
|
4172
|
+
task.currentTabId = undefined;
|
|
4173
|
+
});
|
|
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
|
+
}
|
|
3376
4366
|
/** Connect a profile at `target`, register it under `key`, and arm downloads. */
|
|
3377
4367
|
async openConnection(profile, target, key, profileName, opts = {}) {
|
|
3378
4368
|
const conn = await this.connectProfile(profile, target, key, opts);
|
|
3379
4369
|
conn.key = key;
|
|
3380
4370
|
conn.profile = profileName;
|
|
3381
4371
|
this.connections.set(key, conn);
|
|
3382
|
-
|
|
4372
|
+
// Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
|
|
4373
|
+
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
4374
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
4375
|
+
}
|
|
3383
4376
|
return conn;
|
|
3384
4377
|
}
|
|
3385
4378
|
/**
|
|
@@ -3402,6 +4395,9 @@ export class BrowserService {
|
|
|
3402
4395
|
endpointName: opts.endpointName,
|
|
3403
4396
|
probe: opts.probe,
|
|
3404
4397
|
});
|
|
4398
|
+
if (!routed.local && routed.commandDispatch) {
|
|
4399
|
+
throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
|
|
4400
|
+
}
|
|
3405
4401
|
const key = routed.key;
|
|
3406
4402
|
const effectiveProfile = routed.profile;
|
|
3407
4403
|
const reused = await this.reuseProfileConnection(profileName, key, routed.local);
|
|
@@ -3411,6 +4407,13 @@ export class BrowserService {
|
|
|
3411
4407
|
adoptLegacyRuntimeIfLocal(routed.local, profileName, key, getBrowserRuntimeDir());
|
|
3412
4408
|
conn = await this.openConnection(effectiveProfile, routed.target, key, profileName, { persistRemote: !routed.local });
|
|
3413
4409
|
}
|
|
4410
|
+
if (conn.backend === 'arc-native') {
|
|
4411
|
+
throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
|
|
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
|
+
}
|
|
3414
4417
|
// createPageTarget refuses Arc with an actionable error rather than crashing it.
|
|
3415
4418
|
const created = await this.createPageTarget(conn, { url });
|
|
3416
4419
|
this.invalidateTargetCache(conn);
|