@phnx-labs/agents-cli 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -39
- package/README.md +6 -6
- package/dist/commands/alias.js +2 -2
- package/dist/commands/browser-picker.d.ts +21 -0
- package/dist/commands/browser-picker.js +114 -0
- package/dist/commands/browser.js +546 -75
- package/dist/commands/commands.js +72 -22
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/fork.js +2 -2
- package/dist/commands/hooks.js +71 -26
- package/dist/commands/mcp.js +81 -39
- package/dist/commands/plugins.js +48 -15
- package/dist/commands/prune.js +23 -1
- package/dist/commands/pull.js +3 -3
- package/dist/commands/repo.js +1 -1
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.js +37 -1
- package/dist/commands/sessions.js +62 -19
- package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
- package/dist/commands/{init.js → setup.js} +22 -21
- package/dist/commands/skills.js +60 -19
- package/dist/commands/subagents.js +41 -13
- package/dist/commands/utils.d.ts +16 -0
- package/dist/commands/utils.js +32 -0
- package/dist/commands/view.js +61 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -20
- package/dist/lib/agents.js +2 -2
- package/dist/lib/auto-pull-worker.js +2 -3
- package/dist/lib/auto-pull.js +2 -2
- package/dist/lib/browser/cdp.d.ts +7 -1
- package/dist/lib/browser/cdp.js +29 -1
- package/dist/lib/browser/chrome.js +5 -2
- package/dist/lib/browser/devices.d.ts +4 -0
- package/dist/lib/browser/devices.js +27 -0
- package/dist/lib/browser/drivers/local.js +9 -4
- package/dist/lib/browser/drivers/ssh.js +9 -2
- package/dist/lib/browser/ipc.js +144 -23
- package/dist/lib/browser/profiles.d.ts +5 -2
- package/dist/lib/browser/profiles.js +77 -37
- package/dist/lib/browser/service.d.ts +81 -13
- package/dist/lib/browser/service.js +738 -131
- package/dist/lib/browser/types.d.ts +81 -3
- package/dist/lib/browser/types.js +16 -0
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/cloud/store.js +2 -2
- package/dist/lib/commands.d.ts +1 -0
- package/dist/lib/commands.js +6 -2
- package/dist/lib/daemon.js +2 -3
- package/dist/lib/doctor-diff.js +4 -4
- package/dist/lib/events.js +2 -2
- package/dist/lib/hooks.d.ts +11 -7
- package/dist/lib/hooks.js +125 -49
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +1178 -21
- package/dist/lib/models.js +2 -2
- package/dist/lib/permissions.d.ts +8 -8
- package/dist/lib/permissions.js +8 -8
- package/dist/lib/plugins.d.ts +30 -1
- package/dist/lib/plugins.js +75 -3
- package/dist/lib/pty-server.js +9 -10
- package/dist/lib/resources/hooks.d.ts +5 -1
- package/dist/lib/resources/hooks.js +21 -4
- package/dist/lib/rotate.js +3 -4
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +92 -6
- package/dist/lib/session/cloud.js +2 -2
- package/dist/lib/session/db.js +8 -3
- package/dist/lib/session/discover.js +30 -15
- package/dist/lib/session/team-filter.js +2 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +6 -6
- package/dist/lib/skills.js +6 -2
- package/dist/lib/state.d.ts +86 -14
- package/dist/lib/state.js +150 -23
- package/dist/lib/subagents.d.ts +28 -0
- package/dist/lib/subagents.js +98 -1
- package/dist/lib/sync-manifest.d.ts +1 -1
- package/dist/lib/sync-manifest.js +3 -3
- package/dist/lib/teams/persistence.js +15 -5
- package/dist/lib/teams/registry.js +2 -2
- package/dist/lib/types.d.ts +32 -3
- package/dist/lib/types.js +3 -3
- package/dist/lib/usage.js +2 -2
- package/dist/lib/versions.js +20 -21
- package/package.json +1 -1
- package/scripts/postinstall.js +1 -1
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { CDPClient, discoverBrowserWsUrl } from './cdp.js';
|
|
4
|
-
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir } from './profiles.js';
|
|
3
|
+
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
|
|
4
|
+
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, } from './profiles.js';
|
|
5
5
|
import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
|
|
6
6
|
import { connectLocal } from './drivers/local.js';
|
|
7
7
|
import { connectSSH } from './drivers/ssh.js';
|
|
8
|
-
import { generateTaskId,
|
|
8
|
+
import { generateTaskId, generateShortId, generateFunName, } from './types.js';
|
|
9
9
|
import { getRefs, resolveRefToCoords } from './refs.js';
|
|
10
10
|
import { clickAtCoords, hoverAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
11
11
|
import { emit } from '../events.js';
|
|
12
12
|
export class BrowserService {
|
|
13
13
|
connections = new Map();
|
|
14
14
|
forkingProfiles = new Set();
|
|
15
|
-
|
|
15
|
+
// Per-task storage for console, errors, network, downloads
|
|
16
|
+
consoleLogs = new Map();
|
|
17
|
+
pageErrors = new Map();
|
|
18
|
+
networkRequests = new Map();
|
|
19
|
+
pendingDownloads = new Map();
|
|
20
|
+
enabledSessions = new Map(); // sessionId -> enabled domains
|
|
21
|
+
async start(profileName, opts = {}) {
|
|
16
22
|
const profile = await getProfile(profileName);
|
|
17
23
|
if (!profile) {
|
|
18
24
|
throw new Error(`Profile "${profileName}" not found`);
|
|
19
25
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
throw new Error(`Invalid task ID "${finalTaskId}". Must be lowercase alphanumeric with hyphens.`);
|
|
23
|
-
}
|
|
26
|
+
const taskName = opts.taskName || generateFunName();
|
|
27
|
+
const taskId = generateTaskId();
|
|
24
28
|
let conn = this.connections.get(profileName);
|
|
25
29
|
let effectiveProfileName = profileName;
|
|
26
30
|
if (conn && conn.electron && conn.tasks.size > 0) {
|
|
@@ -53,46 +57,84 @@ export class BrowserService {
|
|
|
53
57
|
conn = await this.connectProfile(profile);
|
|
54
58
|
this.connections.set(profileName, conn);
|
|
55
59
|
}
|
|
56
|
-
if (conn.tasks.has(finalTaskId)) {
|
|
57
|
-
const task = conn.tasks.get(finalTaskId);
|
|
58
|
-
return { task: finalTaskId, windowTargetId: task.windowTargetId };
|
|
59
|
-
}
|
|
60
|
-
const { windowTargetId } = await this.createTaskWindow(conn, finalTaskId);
|
|
61
60
|
const task = {
|
|
62
|
-
id:
|
|
61
|
+
id: taskId,
|
|
62
|
+
name: taskName,
|
|
63
63
|
profile: effectiveProfileName,
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
tabs: {},
|
|
65
|
+
currentTabId: undefined,
|
|
66
66
|
createdAt: Date.now(),
|
|
67
67
|
pid: conn.pid,
|
|
68
68
|
};
|
|
69
|
-
|
|
69
|
+
// For Electron, get the existing window as the tab
|
|
70
|
+
if (conn.electron) {
|
|
71
|
+
const windowId = await this.getOrCreateWindow(conn);
|
|
72
|
+
if (windowId) {
|
|
73
|
+
const shortId = generateShortId();
|
|
74
|
+
task.tabs[shortId] = windowId;
|
|
75
|
+
task.currentTabId = shortId;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
conn.tasks.set(taskName, task);
|
|
70
79
|
await this.saveTaskState(effectiveProfileName, conn.tasks);
|
|
71
|
-
emit('browser.launch', { profile: effectiveProfileName, task:
|
|
72
|
-
|
|
80
|
+
emit('browser.launch', { profile: effectiveProfileName, task: taskName, pid: conn.pid });
|
|
81
|
+
// If URL provided, create tab directly (no about:blank)
|
|
82
|
+
let tabId;
|
|
83
|
+
if (opts.url && !conn.electron) {
|
|
84
|
+
const result = (await conn.cdp.send('Target.createTarget', {
|
|
85
|
+
url: opts.url,
|
|
86
|
+
}));
|
|
87
|
+
const shortId = generateShortId();
|
|
88
|
+
task.tabs[shortId] = result.targetId;
|
|
89
|
+
task.currentTabId = shortId;
|
|
90
|
+
this.invalidateTargetCache(conn);
|
|
91
|
+
await this.saveTaskState(effectiveProfileName, conn.tasks);
|
|
92
|
+
tabId = shortId;
|
|
93
|
+
}
|
|
94
|
+
else if (opts.url && conn.electron) {
|
|
95
|
+
const result = await this.navigate(taskName, opts.url, effectiveProfileName);
|
|
96
|
+
tabId = result.tabId;
|
|
97
|
+
}
|
|
98
|
+
return { task: taskId, name: taskName, tabId };
|
|
73
99
|
}
|
|
74
|
-
async stop(
|
|
100
|
+
async stop(taskName) {
|
|
75
101
|
for (const [profileName, conn] of this.connections) {
|
|
76
|
-
const task = conn.tasks.get(
|
|
102
|
+
const task = conn.tasks.get(taskName);
|
|
77
103
|
if (task) {
|
|
78
|
-
|
|
104
|
+
// Get domains from tabs before closing (for history)
|
|
105
|
+
const domains = new Set();
|
|
106
|
+
try {
|
|
107
|
+
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
108
|
+
for (const cdpId of Object.values(task.tabs)) {
|
|
109
|
+
const target = targetInfos.find((t) => t.targetId === cdpId);
|
|
110
|
+
if (target?.url) {
|
|
111
|
+
try {
|
|
112
|
+
const domain = new URL(target.url).hostname.replace(/^www\./, '');
|
|
113
|
+
if (domain && domain !== 'blank')
|
|
114
|
+
domains.add(domain);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// invalid URL
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// CDP not responding
|
|
124
|
+
}
|
|
125
|
+
// Save to history before closing
|
|
126
|
+
await this.saveToHistory(task, Array.from(domains));
|
|
127
|
+
// Close task's tabs (not the window - it's shared)
|
|
128
|
+
await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
|
|
79
129
|
// Tab already closed
|
|
80
130
|
})));
|
|
81
|
-
for (const
|
|
82
|
-
conn.sessionCache.delete(
|
|
131
|
+
for (const cdpId of Object.values(task.tabs)) {
|
|
132
|
+
conn.sessionCache.delete(cdpId);
|
|
83
133
|
}
|
|
84
134
|
this.invalidateTargetCache(conn);
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
await conn.cdp.send('Target.closeTarget', { targetId: task.windowTargetId });
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
// Window already closed
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
conn.tasks.delete(taskId);
|
|
135
|
+
conn.tasks.delete(taskName);
|
|
94
136
|
await this.saveTaskState(profileName, conn.tasks);
|
|
95
|
-
emit('browser.close', { profile: profileName, task:
|
|
137
|
+
emit('browser.close', { profile: profileName, task: taskName });
|
|
96
138
|
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
97
139
|
conn.cdp.close();
|
|
98
140
|
killChrome(conn.pid);
|
|
@@ -103,6 +145,9 @@ export class BrowserService {
|
|
|
103
145
|
}
|
|
104
146
|
return { ok: false };
|
|
105
147
|
}
|
|
148
|
+
async done(taskName) {
|
|
149
|
+
return this.stop(taskName);
|
|
150
|
+
}
|
|
106
151
|
async stopProfile(profileName) {
|
|
107
152
|
const conn = this.connections.get(profileName);
|
|
108
153
|
if (conn) {
|
|
@@ -124,27 +169,118 @@ export class BrowserService {
|
|
|
124
169
|
}
|
|
125
170
|
async navigate(taskId, url, profileName) {
|
|
126
171
|
const { conn, task } = await this.findTask(taskId, profileName);
|
|
172
|
+
// If we have a current tab, navigate in it (reuse)
|
|
173
|
+
const currentShortId = task.currentTabId;
|
|
174
|
+
if (currentShortId && task.tabs[currentShortId]) {
|
|
175
|
+
const cdpTargetId = task.tabs[currentShortId];
|
|
176
|
+
const sessionId = await this.getSessionId(conn, cdpTargetId);
|
|
177
|
+
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
178
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
179
|
+
return { tabId: currentShortId, url, created: false };
|
|
180
|
+
}
|
|
181
|
+
// No current tab - create one
|
|
127
182
|
if (conn.electron) {
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
183
|
+
const cdpTargetId = conn.windowId;
|
|
184
|
+
if (!cdpTargetId) {
|
|
130
185
|
throw new Error('No existing tab to navigate in Electron app');
|
|
131
186
|
}
|
|
132
|
-
const
|
|
187
|
+
const shortId = generateShortId();
|
|
188
|
+
const sessionId = await this.getSessionId(conn, cdpTargetId);
|
|
133
189
|
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
190
|
+
task.tabs[shortId] = cdpTargetId;
|
|
191
|
+
task.currentTabId = shortId;
|
|
137
192
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
138
|
-
return { tabId, url };
|
|
193
|
+
return { tabId: shortId, url, created: true };
|
|
139
194
|
}
|
|
195
|
+
// Chrome: create new tab
|
|
140
196
|
const result = (await conn.cdp.send('Target.createTarget', {
|
|
141
197
|
url,
|
|
142
198
|
}));
|
|
143
|
-
const
|
|
144
|
-
task.
|
|
199
|
+
const shortId = generateShortId();
|
|
200
|
+
task.tabs[shortId] = result.targetId;
|
|
201
|
+
task.currentTabId = shortId;
|
|
145
202
|
this.invalidateTargetCache(conn);
|
|
146
203
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
147
|
-
return { tabId, url };
|
|
204
|
+
return { tabId: shortId, url, created: true };
|
|
205
|
+
}
|
|
206
|
+
async tabAdd(taskId, url, profileName) {
|
|
207
|
+
const { conn, task } = await this.findTask(taskId, profileName);
|
|
208
|
+
if (conn.electron) {
|
|
209
|
+
throw new Error('Electron apps do not support opening additional tabs');
|
|
210
|
+
}
|
|
211
|
+
const result = (await conn.cdp.send('Target.createTarget', {
|
|
212
|
+
url,
|
|
213
|
+
}));
|
|
214
|
+
const shortId = generateShortId();
|
|
215
|
+
task.tabs[shortId] = result.targetId;
|
|
216
|
+
task.currentTabId = shortId; // new tab becomes current
|
|
217
|
+
this.invalidateTargetCache(conn);
|
|
218
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
219
|
+
return { tabId: shortId, url };
|
|
220
|
+
}
|
|
221
|
+
async tabFocus(taskId, tabHint) {
|
|
222
|
+
const { conn, task } = await this.findTask(taskId);
|
|
223
|
+
const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
|
|
224
|
+
task.currentTabId = resolvedTabId;
|
|
225
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
226
|
+
return { tabId: resolvedTabId };
|
|
227
|
+
}
|
|
228
|
+
async tabList(taskId) {
|
|
229
|
+
const { conn, task } = await this.findTask(taskId);
|
|
230
|
+
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
231
|
+
const tabs = [];
|
|
232
|
+
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
233
|
+
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
234
|
+
if (target) {
|
|
235
|
+
tabs.push({
|
|
236
|
+
id: shortId,
|
|
237
|
+
url: target.url,
|
|
238
|
+
title: target.title,
|
|
239
|
+
current: shortId === task.currentTabId,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return tabs;
|
|
244
|
+
}
|
|
245
|
+
async resolveTabHint(conn, task, hint) {
|
|
246
|
+
// Exact match
|
|
247
|
+
if (task.tabs[hint])
|
|
248
|
+
return hint;
|
|
249
|
+
// Prefix match
|
|
250
|
+
const byPrefix = Object.keys(task.tabs).filter((id) => id.startsWith(hint));
|
|
251
|
+
if (byPrefix.length === 1)
|
|
252
|
+
return byPrefix[0];
|
|
253
|
+
if (byPrefix.length > 1) {
|
|
254
|
+
throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
|
|
255
|
+
}
|
|
256
|
+
// URL substring match
|
|
257
|
+
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
258
|
+
const matches = [];
|
|
259
|
+
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
260
|
+
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
261
|
+
if (target && target.url.includes(hint)) {
|
|
262
|
+
matches.push(shortId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (matches.length === 1)
|
|
266
|
+
return matches[0];
|
|
267
|
+
if (matches.length > 1) {
|
|
268
|
+
throw new Error(`Ambiguous tab hint "${hint}" — matches ${matches.length} tabs by URL`);
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`Tab "${hint}" not found`);
|
|
271
|
+
}
|
|
272
|
+
resolveCurrentTab(task) {
|
|
273
|
+
const tabIds = Object.keys(task.tabs);
|
|
274
|
+
const id = task.currentTabId ?? tabIds[tabIds.length - 1];
|
|
275
|
+
if (!id)
|
|
276
|
+
throw new Error('No tabs open for this task');
|
|
277
|
+
return id;
|
|
278
|
+
}
|
|
279
|
+
getCdpTargetId(task, shortId) {
|
|
280
|
+
const cdpId = task.tabs[shortId];
|
|
281
|
+
if (!cdpId)
|
|
282
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
283
|
+
return cdpId;
|
|
148
284
|
}
|
|
149
285
|
async tabs(taskId, profileName) {
|
|
150
286
|
if (taskId) {
|
|
@@ -160,42 +296,53 @@ export class BrowserService {
|
|
|
160
296
|
}
|
|
161
297
|
return allTabs;
|
|
162
298
|
}
|
|
163
|
-
async
|
|
299
|
+
async tabClose(taskId, tabHint) {
|
|
164
300
|
const { conn, task } = await this.findTask(taskId);
|
|
165
|
-
if (
|
|
166
|
-
await
|
|
167
|
-
|
|
168
|
-
|
|
301
|
+
if (tabHint !== undefined) {
|
|
302
|
+
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
303
|
+
const cdpId = task.tabs[shortId];
|
|
304
|
+
if (cdpId) {
|
|
305
|
+
await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
|
|
306
|
+
conn.sessionCache.delete(cdpId);
|
|
307
|
+
delete task.tabs[shortId];
|
|
308
|
+
// Update currentTabId if we closed the current tab
|
|
309
|
+
if (task.currentTabId === shortId) {
|
|
310
|
+
const remaining = Object.keys(task.tabs);
|
|
311
|
+
task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
169
314
|
}
|
|
170
315
|
else {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
316
|
+
// Close all tabs
|
|
317
|
+
await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
|
|
318
|
+
for (const cdpId of Object.values(task.tabs)) {
|
|
319
|
+
conn.sessionCache.delete(cdpId);
|
|
174
320
|
}
|
|
175
|
-
task.
|
|
321
|
+
task.tabs = {};
|
|
322
|
+
task.currentTabId = undefined;
|
|
176
323
|
}
|
|
177
324
|
this.invalidateTargetCache(conn);
|
|
178
325
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
179
326
|
}
|
|
180
|
-
async evaluate(taskId,
|
|
181
|
-
const { conn } = await this.findTask(taskId);
|
|
182
|
-
const
|
|
327
|
+
async evaluate(taskId, tabHint, expression) {
|
|
328
|
+
const { conn, task } = await this.findTask(taskId);
|
|
329
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
330
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
331
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
183
332
|
if (!target) {
|
|
184
|
-
throw new Error(`Tab ${
|
|
333
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
185
334
|
}
|
|
186
335
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
187
336
|
const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId));
|
|
188
337
|
return result.result.value;
|
|
189
338
|
}
|
|
190
|
-
async screenshot(taskId,
|
|
339
|
+
async screenshot(taskId, tabHint, outputPath) {
|
|
191
340
|
const { conn, task } = await this.findTask(taskId);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
const target = await this.getTarget(conn, targetTabId);
|
|
341
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
342
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
343
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
197
344
|
if (!target) {
|
|
198
|
-
throw new Error(`Tab ${
|
|
345
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
199
346
|
}
|
|
200
347
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
201
348
|
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
|
|
@@ -209,39 +356,42 @@ export class BrowserService {
|
|
|
209
356
|
quality -= 10;
|
|
210
357
|
}
|
|
211
358
|
}
|
|
212
|
-
const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions',
|
|
359
|
+
const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
|
|
213
360
|
const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.jpg`);
|
|
214
361
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
215
362
|
await fs.promises.writeFile(finalPath, buffer);
|
|
216
363
|
return finalPath;
|
|
217
364
|
}
|
|
218
365
|
refsCache = new Map();
|
|
219
|
-
async refs(taskId,
|
|
366
|
+
async refs(taskId, tabHint, opts = {}) {
|
|
220
367
|
const { conn, task } = await this.findTask(taskId);
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const target = await this.getTarget(conn, targetTabId);
|
|
368
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
369
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
370
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
225
371
|
if (!target)
|
|
226
|
-
throw new Error(`Tab ${
|
|
372
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
227
373
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
228
374
|
return getRefs(conn.cdp, sessionId, opts);
|
|
229
375
|
}
|
|
230
|
-
async click(taskId,
|
|
231
|
-
const { conn } = await this.findTask(taskId);
|
|
232
|
-
const
|
|
376
|
+
async click(taskId, ref, tabHint) {
|
|
377
|
+
const { conn, task } = await this.findTask(taskId);
|
|
378
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
379
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
380
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
233
381
|
if (!target)
|
|
234
|
-
throw new Error(`Tab ${
|
|
382
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
235
383
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
236
384
|
const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
|
|
237
385
|
const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
|
|
238
386
|
await clickAtCoords(conn.cdp, sessionId, x, y);
|
|
239
387
|
}
|
|
240
|
-
async type(taskId,
|
|
241
|
-
const { conn } = await this.findTask(taskId);
|
|
242
|
-
const
|
|
388
|
+
async type(taskId, ref, text, tabHint) {
|
|
389
|
+
const { conn, task } = await this.findTask(taskId);
|
|
390
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
391
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
392
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
243
393
|
if (!target)
|
|
244
|
-
throw new Error(`Tab ${
|
|
394
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
245
395
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
246
396
|
const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
|
|
247
397
|
const node = nodeMap.get(ref);
|
|
@@ -252,40 +402,384 @@ export class BrowserService {
|
|
|
252
402
|
}
|
|
253
403
|
await typeText(conn.cdp, sessionId, text);
|
|
254
404
|
}
|
|
255
|
-
async press(taskId,
|
|
256
|
-
const { conn } = await this.findTask(taskId);
|
|
257
|
-
const
|
|
405
|
+
async press(taskId, key, tabHint) {
|
|
406
|
+
const { conn, task } = await this.findTask(taskId);
|
|
407
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
408
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
409
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
258
410
|
if (!target)
|
|
259
|
-
throw new Error(`Tab ${
|
|
411
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
260
412
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
261
413
|
await pressKey(conn.cdp, sessionId, key);
|
|
262
414
|
}
|
|
263
|
-
async hover(taskId,
|
|
264
|
-
const { conn } = await this.findTask(taskId);
|
|
265
|
-
const
|
|
415
|
+
async hover(taskId, ref, tabHint) {
|
|
416
|
+
const { conn, task } = await this.findTask(taskId);
|
|
417
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
418
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
419
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
266
420
|
if (!target)
|
|
267
|
-
throw new Error(`Tab ${
|
|
421
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
268
422
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
269
423
|
const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
|
|
270
424
|
const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
|
|
271
425
|
await hoverAtCoords(conn.cdp, sessionId, x, y);
|
|
272
426
|
}
|
|
273
427
|
async status(profileName) {
|
|
428
|
+
const seen = new Set();
|
|
274
429
|
const statuses = [];
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
430
|
+
const candidates = profileName ? [profileName] : Array.from(this.connections.keys());
|
|
431
|
+
for (const name of candidates) {
|
|
432
|
+
const status = await this.getProfileStatus(name);
|
|
433
|
+
if (status) {
|
|
278
434
|
statuses.push(status);
|
|
435
|
+
seen.add(name);
|
|
436
|
+
}
|
|
279
437
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
438
|
+
if (!profileName) {
|
|
439
|
+
const profiles = await listProfiles();
|
|
440
|
+
for (const profile of profiles) {
|
|
441
|
+
if (seen.has(profile.name))
|
|
442
|
+
continue;
|
|
443
|
+
const reconciled = await this.reconcileFromDisk(profile.name);
|
|
444
|
+
if (reconciled)
|
|
445
|
+
statuses.push(reconciled);
|
|
285
446
|
}
|
|
286
447
|
}
|
|
448
|
+
else if (!seen.has(profileName)) {
|
|
449
|
+
const reconciled = await this.reconcileFromDisk(profileName);
|
|
450
|
+
if (reconciled)
|
|
451
|
+
statuses.push(reconciled);
|
|
452
|
+
}
|
|
287
453
|
return statuses;
|
|
288
454
|
}
|
|
455
|
+
async reconcileFromDisk(profileName) {
|
|
456
|
+
const info = getRunningChromeInfo(profileName);
|
|
457
|
+
if (!info)
|
|
458
|
+
return null;
|
|
459
|
+
const profile = await getProfile(profileName);
|
|
460
|
+
const tasks = this.loadTaskState(profileName);
|
|
461
|
+
const taskStatuses = [];
|
|
462
|
+
for (const [, task] of tasks) {
|
|
463
|
+
taskStatuses.push({
|
|
464
|
+
id: task.id,
|
|
465
|
+
name: task.name,
|
|
466
|
+
tabCount: Object.keys(task.tabs).length,
|
|
467
|
+
currentTabId: task.currentTabId,
|
|
468
|
+
createdAt: task.createdAt,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
472
|
+
return {
|
|
473
|
+
name: profileName,
|
|
474
|
+
running: true,
|
|
475
|
+
port: info.port,
|
|
476
|
+
pid: info.pid,
|
|
477
|
+
configuredPort: configuredPort !== info.port ? configuredPort : undefined,
|
|
478
|
+
tasks: taskStatuses,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
// ─── Viewport & Device Emulation ──────────────────────────────────────────────
|
|
482
|
+
async setViewport(taskId, width, height, options = {}) {
|
|
483
|
+
const { conn, task } = await this.findTask(taskId);
|
|
484
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
485
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
486
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
487
|
+
if (!target)
|
|
488
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
489
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
490
|
+
await conn.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
491
|
+
width,
|
|
492
|
+
height,
|
|
493
|
+
deviceScaleFactor: options.deviceScaleFactor ?? 1,
|
|
494
|
+
mobile: options.mobile ?? false,
|
|
495
|
+
}, sessionId);
|
|
496
|
+
}
|
|
497
|
+
async setDevice(taskId, deviceName, tabHint) {
|
|
498
|
+
const { getDevice } = await import('./devices.js');
|
|
499
|
+
const device = getDevice(deviceName);
|
|
500
|
+
if (!device) {
|
|
501
|
+
const { listDevices } = await import('./devices.js');
|
|
502
|
+
throw new Error(`Unknown device "${deviceName}". Available: ${listDevices().join(', ')}`);
|
|
503
|
+
}
|
|
504
|
+
await this.setViewport(taskId, device.width, device.height, {
|
|
505
|
+
mobile: device.mobile,
|
|
506
|
+
deviceScaleFactor: device.deviceScaleFactor,
|
|
507
|
+
tabHint,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// ─── Console & Errors ────────────────────────────────────────────────────────
|
|
511
|
+
async enableRuntimeForSession(conn, sessionId) {
|
|
512
|
+
const key = `${sessionId}:Runtime`;
|
|
513
|
+
if (this.enabledSessions.get(sessionId)?.has('Runtime'))
|
|
514
|
+
return;
|
|
515
|
+
await conn.cdp.send('Runtime.enable', {}, sessionId);
|
|
516
|
+
if (!this.enabledSessions.has(sessionId)) {
|
|
517
|
+
this.enabledSessions.set(sessionId, new Set());
|
|
518
|
+
}
|
|
519
|
+
this.enabledSessions.get(sessionId).add('Runtime');
|
|
520
|
+
conn.cdp.on('Runtime.consoleAPICalled', (params) => {
|
|
521
|
+
if (params.sessionId !== sessionId)
|
|
522
|
+
return;
|
|
523
|
+
const taskId = this.findTaskBySession(conn, sessionId);
|
|
524
|
+
if (!taskId)
|
|
525
|
+
return;
|
|
526
|
+
const entry = {
|
|
527
|
+
level: params.type === 'warning' ? 'warn' : params.type,
|
|
528
|
+
text: params.args?.map((a) => a.value ?? a.description ?? '').join(' ') || '',
|
|
529
|
+
timestamp: Date.now(),
|
|
530
|
+
url: params.stackTrace?.callFrames?.[0]?.url,
|
|
531
|
+
line: params.stackTrace?.callFrames?.[0]?.lineNumber,
|
|
532
|
+
};
|
|
533
|
+
if (!this.consoleLogs.has(taskId))
|
|
534
|
+
this.consoleLogs.set(taskId, []);
|
|
535
|
+
const logs = this.consoleLogs.get(taskId);
|
|
536
|
+
logs.push(entry);
|
|
537
|
+
if (logs.length > 1000)
|
|
538
|
+
logs.shift();
|
|
539
|
+
});
|
|
540
|
+
conn.cdp.on('Runtime.exceptionThrown', (params) => {
|
|
541
|
+
if (params.sessionId !== sessionId)
|
|
542
|
+
return;
|
|
543
|
+
const taskId = this.findTaskBySession(conn, sessionId);
|
|
544
|
+
if (!taskId)
|
|
545
|
+
return;
|
|
546
|
+
const ex = params.exceptionDetails;
|
|
547
|
+
const entry = {
|
|
548
|
+
message: ex.exception?.description || ex.text || 'Unknown error',
|
|
549
|
+
stack: ex.stackTrace?.callFrames?.map((f) => ` at ${f.functionName || '<anonymous>'} (${f.url}:${f.lineNumber})`).join('\n'),
|
|
550
|
+
timestamp: Date.now(),
|
|
551
|
+
url: ex.url,
|
|
552
|
+
line: ex.lineNumber,
|
|
553
|
+
};
|
|
554
|
+
if (!this.pageErrors.has(taskId))
|
|
555
|
+
this.pageErrors.set(taskId, []);
|
|
556
|
+
const errors = this.pageErrors.get(taskId);
|
|
557
|
+
errors.push(entry);
|
|
558
|
+
if (errors.length > 500)
|
|
559
|
+
errors.shift();
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async getConsoleLogs(taskId, options = {}) {
|
|
563
|
+
const { conn, task } = await this.findTask(taskId);
|
|
564
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
565
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
566
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
567
|
+
if (!target)
|
|
568
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
569
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
570
|
+
await this.enableRuntimeForSession(conn, sessionId);
|
|
571
|
+
let logs = this.consoleLogs.get(taskId) || [];
|
|
572
|
+
if (options.level) {
|
|
573
|
+
logs = logs.filter((l) => l.level === options.level);
|
|
574
|
+
}
|
|
575
|
+
if (options.clear) {
|
|
576
|
+
this.consoleLogs.set(taskId, []);
|
|
577
|
+
}
|
|
578
|
+
return logs;
|
|
579
|
+
}
|
|
580
|
+
async getErrors(taskId, options = {}) {
|
|
581
|
+
const { conn, task } = await this.findTask(taskId);
|
|
582
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
583
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
584
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
585
|
+
if (!target)
|
|
586
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
587
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
588
|
+
await this.enableRuntimeForSession(conn, sessionId);
|
|
589
|
+
const errors = this.pageErrors.get(taskId) || [];
|
|
590
|
+
if (options.clear) {
|
|
591
|
+
this.pageErrors.set(taskId, []);
|
|
592
|
+
}
|
|
593
|
+
return errors;
|
|
594
|
+
}
|
|
595
|
+
// ─── Network Requests ────────────────────────────────────────────────────────
|
|
596
|
+
async enableNetworkForSession(conn, sessionId, taskId) {
|
|
597
|
+
if (this.enabledSessions.get(sessionId)?.has('Network'))
|
|
598
|
+
return;
|
|
599
|
+
await conn.cdp.send('Network.enable', {}, sessionId);
|
|
600
|
+
if (!this.enabledSessions.has(sessionId)) {
|
|
601
|
+
this.enabledSessions.set(sessionId, new Set());
|
|
602
|
+
}
|
|
603
|
+
this.enabledSessions.get(sessionId).add('Network');
|
|
604
|
+
const requestMap = new Map();
|
|
605
|
+
conn.cdp.on('Network.requestWillBeSent', (params) => {
|
|
606
|
+
if (params.sessionId !== sessionId)
|
|
607
|
+
return;
|
|
608
|
+
const req = {
|
|
609
|
+
id: params.requestId,
|
|
610
|
+
url: params.request.url,
|
|
611
|
+
method: params.request.method,
|
|
612
|
+
timestamp: Date.now(),
|
|
613
|
+
};
|
|
614
|
+
requestMap.set(params.requestId, req);
|
|
615
|
+
if (!this.networkRequests.has(taskId))
|
|
616
|
+
this.networkRequests.set(taskId, []);
|
|
617
|
+
const reqs = this.networkRequests.get(taskId);
|
|
618
|
+
reqs.push(req);
|
|
619
|
+
if (reqs.length > 500)
|
|
620
|
+
reqs.shift();
|
|
621
|
+
});
|
|
622
|
+
conn.cdp.on('Network.responseReceived', (params) => {
|
|
623
|
+
if (params.sessionId !== sessionId)
|
|
624
|
+
return;
|
|
625
|
+
const req = requestMap.get(params.requestId);
|
|
626
|
+
if (req) {
|
|
627
|
+
req.status = params.response.status;
|
|
628
|
+
req.mimeType = params.response.mimeType;
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
async getNetworkRequests(taskId, options = {}) {
|
|
633
|
+
const { conn, task } = await this.findTask(taskId);
|
|
634
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
635
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
636
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
637
|
+
if (!target)
|
|
638
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
639
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
640
|
+
await this.enableNetworkForSession(conn, sessionId, taskId);
|
|
641
|
+
let requests = this.networkRequests.get(taskId) || [];
|
|
642
|
+
if (options.filter) {
|
|
643
|
+
const f = options.filter.toLowerCase();
|
|
644
|
+
requests = requests.filter((r) => r.url.toLowerCase().includes(f));
|
|
645
|
+
}
|
|
646
|
+
if (options.clear) {
|
|
647
|
+
this.networkRequests.set(taskId, []);
|
|
648
|
+
}
|
|
649
|
+
return requests;
|
|
650
|
+
}
|
|
651
|
+
async getResponseBody(taskId, urlPattern, options = {}) {
|
|
652
|
+
const { conn, task } = await this.findTask(taskId);
|
|
653
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
654
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
655
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
656
|
+
if (!target)
|
|
657
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
658
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
659
|
+
await this.enableNetworkForSession(conn, sessionId, taskId);
|
|
660
|
+
const timeout = options.timeout ?? 30000;
|
|
661
|
+
const maxChars = options.maxChars ?? 200000;
|
|
662
|
+
const start = Date.now();
|
|
663
|
+
const pattern = urlPattern.includes('*')
|
|
664
|
+
? new RegExp(urlPattern.replace(/\*/g, '.*'), 'i')
|
|
665
|
+
: null;
|
|
666
|
+
while (Date.now() - start < timeout) {
|
|
667
|
+
const requests = this.networkRequests.get(taskId) || [];
|
|
668
|
+
const match = requests.find((r) => pattern ? pattern.test(r.url) : r.url.includes(urlPattern));
|
|
669
|
+
if (match && match.status) {
|
|
670
|
+
try {
|
|
671
|
+
const { body, base64Encoded } = (await conn.cdp.send('Network.getResponseBody', { requestId: match.id }, sessionId));
|
|
672
|
+
const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf-8') : body;
|
|
673
|
+
return text.slice(0, maxChars);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// Request may have been evicted, continue waiting
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
680
|
+
}
|
|
681
|
+
throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
|
|
682
|
+
}
|
|
683
|
+
// ─── Wait Conditions ─────────────────────────────────────────────────────────
|
|
684
|
+
async wait(taskId, type, value, options = {}) {
|
|
685
|
+
const timeout = options.timeout ?? 30000;
|
|
686
|
+
if (type === 'time') {
|
|
687
|
+
await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const { conn, task } = await this.findTask(taskId);
|
|
691
|
+
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
692
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
693
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
694
|
+
if (!target)
|
|
695
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
696
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
697
|
+
const start = Date.now();
|
|
698
|
+
while (Date.now() - start < timeout) {
|
|
699
|
+
let condition = false;
|
|
700
|
+
if (type === 'selector') {
|
|
701
|
+
const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!document.querySelector(${JSON.stringify(value)})`, returnByValue: true }, sessionId));
|
|
702
|
+
condition = result.result.value === true;
|
|
703
|
+
}
|
|
704
|
+
else if (type === 'url') {
|
|
705
|
+
const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId));
|
|
706
|
+
const pattern = value.includes('*')
|
|
707
|
+
? new RegExp(value.replace(/\*/g, '.*'), 'i')
|
|
708
|
+
: null;
|
|
709
|
+
condition = pattern ? pattern.test(result.result.value) : result.result.value.includes(value);
|
|
710
|
+
}
|
|
711
|
+
else if (type === 'function') {
|
|
712
|
+
const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!(${value})`, returnByValue: true }, sessionId));
|
|
713
|
+
condition = result.result.value === true;
|
|
714
|
+
}
|
|
715
|
+
else if (type === 'load') {
|
|
716
|
+
const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }, sessionId));
|
|
717
|
+
if (value === 'domcontentloaded') {
|
|
718
|
+
condition = result.result.value !== 'loading';
|
|
719
|
+
}
|
|
720
|
+
else if (value === 'load' || value === 'complete') {
|
|
721
|
+
condition = result.result.value === 'complete';
|
|
722
|
+
}
|
|
723
|
+
else if (value === 'networkidle') {
|
|
724
|
+
// Simplified: check if document is complete
|
|
725
|
+
condition = result.result.value === 'complete';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (condition)
|
|
729
|
+
return;
|
|
730
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
731
|
+
}
|
|
732
|
+
throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
|
|
733
|
+
}
|
|
734
|
+
// ─── Downloads ───────────────────────────────────────────────────────────────
|
|
735
|
+
async setDownloadPath(taskId, downloadPath, tabHint) {
|
|
736
|
+
const { conn, task } = await this.findTask(taskId);
|
|
737
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
738
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
739
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
740
|
+
if (!target)
|
|
741
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
742
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
743
|
+
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
744
|
+
behavior: 'allow',
|
|
745
|
+
downloadPath,
|
|
746
|
+
eventsEnabled: true,
|
|
747
|
+
}, sessionId);
|
|
748
|
+
this.pendingDownloads.set(taskId, { path: downloadPath, completed: false });
|
|
749
|
+
conn.cdp.on('Browser.downloadProgress', (params) => {
|
|
750
|
+
if (params.state === 'completed') {
|
|
751
|
+
const dl = this.pendingDownloads.get(taskId);
|
|
752
|
+
if (dl) {
|
|
753
|
+
dl.completed = true;
|
|
754
|
+
dl.filename = params.suggestedFilename;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
async waitForDownload(taskId, timeout = 60000) {
|
|
760
|
+
const start = Date.now();
|
|
761
|
+
while (Date.now() - start < timeout) {
|
|
762
|
+
const dl = this.pendingDownloads.get(taskId);
|
|
763
|
+
if (dl?.completed) {
|
|
764
|
+
const fullPath = dl.filename ? `${dl.path}/${dl.filename}` : dl.path;
|
|
765
|
+
this.pendingDownloads.delete(taskId);
|
|
766
|
+
return fullPath;
|
|
767
|
+
}
|
|
768
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
769
|
+
}
|
|
770
|
+
throw new Error(`Download not completed within ${timeout}ms`);
|
|
771
|
+
}
|
|
772
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
773
|
+
findTaskBySession(conn, sessionId) {
|
|
774
|
+
for (const [taskId, task] of conn.tasks) {
|
|
775
|
+
for (const tabId of Object.values(task.tabs)) {
|
|
776
|
+
if (conn.sessionCache.get(tabId) === sessionId) {
|
|
777
|
+
return taskId;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
289
783
|
async shutdown() {
|
|
290
784
|
for (const [, conn] of this.connections) {
|
|
291
785
|
conn.cdp.close();
|
|
@@ -307,7 +801,8 @@ export class BrowserService {
|
|
|
307
801
|
}
|
|
308
802
|
const forkName = `${profile.name}.${forkNum}`;
|
|
309
803
|
const port = allocatePort();
|
|
310
|
-
const
|
|
804
|
+
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
805
|
+
const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
|
|
311
806
|
const cdp = new CDPClient();
|
|
312
807
|
await cdp.connect(wsUrl);
|
|
313
808
|
await this.enableDomains(cdp);
|
|
@@ -326,7 +821,8 @@ export class BrowserService {
|
|
|
326
821
|
async connectProfile(profile) {
|
|
327
822
|
const existingInfo = getRunningChromeInfo(profile.name);
|
|
328
823
|
if (existingInfo) {
|
|
329
|
-
const wsUrl = await discoverBrowserWsUrl(existingInfo.port);
|
|
824
|
+
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
|
|
825
|
+
verifyBrowserIdentity(browser, profile.browser, existingInfo.port);
|
|
330
826
|
const cdp = new CDPClient();
|
|
331
827
|
await cdp.connect(wsUrl);
|
|
332
828
|
await this.enableDomains(cdp);
|
|
@@ -346,7 +842,10 @@ export class BrowserService {
|
|
|
346
842
|
if (conn)
|
|
347
843
|
return conn;
|
|
348
844
|
}
|
|
349
|
-
catch {
|
|
845
|
+
catch (err) {
|
|
846
|
+
if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
350
849
|
// Try next endpoint
|
|
351
850
|
}
|
|
352
851
|
}
|
|
@@ -393,7 +892,8 @@ export class BrowserService {
|
|
|
393
892
|
}
|
|
394
893
|
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
395
894
|
const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
|
|
396
|
-
const wsUrl = await discoverBrowserWsUrl(port, url.hostname);
|
|
895
|
+
const { wsUrl, browser } = await discoverBrowserWsUrl(port, url.hostname);
|
|
896
|
+
verifyBrowserIdentity(browser, profile.browser, port, url.hostname);
|
|
397
897
|
const cdp = new CDPClient();
|
|
398
898
|
await cdp.connect(wsUrl);
|
|
399
899
|
await this.enableDomains(cdp);
|
|
@@ -411,30 +911,30 @@ export class BrowserService {
|
|
|
411
911
|
async enableDomains(cdp) {
|
|
412
912
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
413
913
|
}
|
|
414
|
-
async
|
|
415
|
-
|
|
914
|
+
async getOrCreateWindow(conn) {
|
|
915
|
+
// Already have a window for this profile?
|
|
916
|
+
if (conn.windowId) {
|
|
917
|
+
// Verify it still exists via CDP
|
|
416
918
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return { windowTargetId: pageTarget.targetId };
|
|
420
|
-
}
|
|
421
|
-
// No existing page - try to create one (works on some Electron apps)
|
|
422
|
-
try {
|
|
423
|
-
const result = (await conn.cdp.send('Target.createTarget', {
|
|
424
|
-
url: 'about:blank',
|
|
425
|
-
newWindow: true,
|
|
426
|
-
}));
|
|
427
|
-
return { windowTargetId: result.targetId };
|
|
428
|
-
}
|
|
429
|
-
catch {
|
|
430
|
-
throw new Error('No page targets found and unable to create new window');
|
|
919
|
+
if (targetInfos.some((t) => t.targetId === conn.windowId && t.type === 'page')) {
|
|
920
|
+
return conn.windowId;
|
|
431
921
|
}
|
|
922
|
+
// Window was closed, fall through to find/create new
|
|
432
923
|
}
|
|
924
|
+
// Check if browser already has a page target we can use
|
|
925
|
+
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
926
|
+
const existing = targetInfos.find((t) => t.type === 'page');
|
|
927
|
+
if (existing) {
|
|
928
|
+
conn.windowId = existing.targetId;
|
|
929
|
+
return existing.targetId;
|
|
930
|
+
}
|
|
931
|
+
// First ever use - create window
|
|
433
932
|
const result = (await conn.cdp.send('Target.createTarget', {
|
|
434
933
|
url: 'about:blank',
|
|
435
934
|
newWindow: true,
|
|
436
935
|
}));
|
|
437
|
-
|
|
936
|
+
conn.windowId = result.targetId;
|
|
937
|
+
return result.targetId;
|
|
438
938
|
}
|
|
439
939
|
async findTask(taskId, profileName) {
|
|
440
940
|
if (profileName) {
|
|
@@ -458,37 +958,74 @@ export class BrowserService {
|
|
|
458
958
|
}
|
|
459
959
|
async getTabsForTask(cdp, task) {
|
|
460
960
|
const targets = (await cdp.send('Target.getTargets'));
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const target = targets.targetInfos.find((t) => t.targetId ===
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
961
|
+
const tabs = [];
|
|
962
|
+
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
963
|
+
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
964
|
+
if (target) {
|
|
965
|
+
tabs.push({
|
|
966
|
+
id: shortId,
|
|
967
|
+
url: target.url,
|
|
968
|
+
title: target.title,
|
|
969
|
+
task: task.name,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return tabs;
|
|
474
974
|
}
|
|
475
975
|
async getProfileStatus(profileName) {
|
|
476
976
|
const conn = this.connections.get(profileName);
|
|
477
977
|
if (!conn)
|
|
478
978
|
return null;
|
|
979
|
+
// Fetch all targets once for efficiency
|
|
980
|
+
let targets = [];
|
|
981
|
+
try {
|
|
982
|
+
const result = (await conn.cdp.send('Target.getTargets'));
|
|
983
|
+
targets = result.targetInfos;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
// CDP not responding, fall back to metadata only
|
|
987
|
+
}
|
|
479
988
|
const tasks = [];
|
|
480
989
|
for (const [, task] of conn.tasks) {
|
|
990
|
+
const tabs = [];
|
|
991
|
+
const domainSet = new Set();
|
|
992
|
+
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
993
|
+
const target = targets.find((t) => t.targetId === cdpId);
|
|
994
|
+
if (target) {
|
|
995
|
+
tabs.push({
|
|
996
|
+
id: shortId,
|
|
997
|
+
url: target.url,
|
|
998
|
+
title: target.title,
|
|
999
|
+
current: shortId === task.currentTabId,
|
|
1000
|
+
});
|
|
1001
|
+
try {
|
|
1002
|
+
const domain = new URL(target.url).hostname.replace(/^www\./, '');
|
|
1003
|
+
if (domain && domain !== 'blank')
|
|
1004
|
+
domainSet.add(domain);
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
// invalid URL
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
481
1011
|
tasks.push({
|
|
482
1012
|
id: task.id,
|
|
483
|
-
|
|
1013
|
+
name: task.name,
|
|
1014
|
+
tabCount: Object.keys(task.tabs).length,
|
|
1015
|
+
currentTabId: task.currentTabId,
|
|
484
1016
|
createdAt: task.createdAt,
|
|
1017
|
+
tabs: tabs.length > 0 ? tabs : undefined,
|
|
1018
|
+
domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
|
|
485
1019
|
});
|
|
486
1020
|
}
|
|
1021
|
+
const profile = await getProfile(profileName);
|
|
1022
|
+
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
487
1023
|
return {
|
|
488
1024
|
name: profileName,
|
|
489
1025
|
running: true,
|
|
490
1026
|
port: conn.port,
|
|
491
1027
|
pid: conn.pid,
|
|
1028
|
+
configuredPort: configuredPort !== conn.port ? configuredPort : undefined,
|
|
492
1029
|
tasks,
|
|
493
1030
|
};
|
|
494
1031
|
}
|
|
@@ -528,6 +1065,76 @@ export class BrowserService {
|
|
|
528
1065
|
return new Map();
|
|
529
1066
|
}
|
|
530
1067
|
const state = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
|
531
|
-
|
|
1068
|
+
const tasks = new Map();
|
|
1069
|
+
let needsMigration = false;
|
|
1070
|
+
for (const [key, raw] of Object.entries(state)) {
|
|
1071
|
+
const task = raw;
|
|
1072
|
+
// Migrate old format (tabIds array) to new format (tabs object)
|
|
1073
|
+
if (Array.isArray(task.tabIds) && !task.tabs) {
|
|
1074
|
+
needsMigration = true;
|
|
1075
|
+
const tabs = {};
|
|
1076
|
+
for (const cdpId of task.tabIds) {
|
|
1077
|
+
const shortId = generateShortId();
|
|
1078
|
+
tabs[shortId] = cdpId;
|
|
1079
|
+
}
|
|
1080
|
+
const tabIds = Object.keys(tabs);
|
|
1081
|
+
tasks.set(key, {
|
|
1082
|
+
id: task.id,
|
|
1083
|
+
name: task.name || key,
|
|
1084
|
+
profile: task.profile,
|
|
1085
|
+
tabs,
|
|
1086
|
+
currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
|
|
1087
|
+
createdAt: task.createdAt,
|
|
1088
|
+
pid: task.pid,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
tasks.set(key, task);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// Save migrated data back to disk
|
|
1096
|
+
if (needsMigration) {
|
|
1097
|
+
const migratedState = Object.fromEntries(tasks);
|
|
1098
|
+
fs.writeFileSync(tasksFile, JSON.stringify(migratedState, null, 2));
|
|
1099
|
+
}
|
|
1100
|
+
return tasks;
|
|
1101
|
+
}
|
|
1102
|
+
async saveToHistory(task, domains) {
|
|
1103
|
+
const historyDir = getBrowserRuntimeDir();
|
|
1104
|
+
await fs.promises.mkdir(historyDir, { recursive: true });
|
|
1105
|
+
const historyFile = path.join(historyDir, 'history.json');
|
|
1106
|
+
let history = [];
|
|
1107
|
+
if (fs.existsSync(historyFile)) {
|
|
1108
|
+
try {
|
|
1109
|
+
history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
// Corrupted file, start fresh
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
history.unshift({
|
|
1116
|
+
id: task.id,
|
|
1117
|
+
name: task.name,
|
|
1118
|
+
profile: task.profile,
|
|
1119
|
+
createdAt: task.createdAt,
|
|
1120
|
+
endedAt: Date.now(),
|
|
1121
|
+
domains,
|
|
1122
|
+
tabCount: Object.keys(task.tabs).length,
|
|
1123
|
+
});
|
|
1124
|
+
// Keep only last 50 entries
|
|
1125
|
+
history = history.slice(0, 50);
|
|
1126
|
+
await fs.promises.writeFile(historyFile, JSON.stringify(history, null, 2));
|
|
1127
|
+
}
|
|
1128
|
+
async getHistory(limit = 10) {
|
|
1129
|
+
const historyFile = path.join(getBrowserRuntimeDir(), 'history.json');
|
|
1130
|
+
if (!fs.existsSync(historyFile))
|
|
1131
|
+
return [];
|
|
1132
|
+
try {
|
|
1133
|
+
const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
1134
|
+
return history.slice(0, limit);
|
|
1135
|
+
}
|
|
1136
|
+
catch {
|
|
1137
|
+
return [];
|
|
1138
|
+
}
|
|
532
1139
|
}
|
|
533
1140
|
}
|