tuna-agent 0.1.1 → 0.1.2
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/dist/browser/actions/download.d.ts +16 -0
- package/dist/browser/actions/download.js +39 -0
- package/dist/browser/actions/emulation.d.ts +53 -0
- package/dist/browser/actions/emulation.js +103 -0
- package/dist/browser/actions/evaluate.d.ts +29 -0
- package/dist/browser/actions/evaluate.js +92 -0
- package/dist/browser/actions/interaction.d.ts +79 -0
- package/dist/browser/actions/interaction.js +210 -0
- package/dist/browser/actions/keyboard.d.ts +6 -0
- package/dist/browser/actions/keyboard.js +9 -0
- package/dist/browser/actions/navigation.d.ts +40 -0
- package/dist/browser/actions/navigation.js +92 -0
- package/dist/browser/actions/wait.d.ts +12 -0
- package/dist/browser/actions/wait.js +33 -0
- package/dist/browser/browser.d.ts +722 -0
- package/dist/browser/browser.js +1066 -0
- package/dist/browser/capture/activity.d.ts +22 -0
- package/dist/browser/capture/activity.js +39 -0
- package/dist/browser/capture/pdf.d.ts +6 -0
- package/dist/browser/capture/pdf.js +6 -0
- package/dist/browser/capture/response.d.ts +8 -0
- package/dist/browser/capture/response.js +28 -0
- package/dist/browser/capture/screenshot.d.ts +30 -0
- package/dist/browser/capture/screenshot.js +72 -0
- package/dist/browser/capture/trace.d.ts +13 -0
- package/dist/browser/capture/trace.js +19 -0
- package/dist/browser/chrome-launcher.d.ts +8 -0
- package/dist/browser/chrome-launcher.js +543 -0
- package/dist/browser/connection.d.ts +42 -0
- package/dist/browser/connection.js +359 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/security.d.ts +51 -0
- package/dist/browser/security.js +357 -0
- package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
- package/dist/browser/snapshot/ai-snapshot.js +47 -0
- package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
- package/dist/browser/snapshot/aria-snapshot.js +121 -0
- package/dist/browser/snapshot/ref-map.d.ts +31 -0
- package/dist/browser/snapshot/ref-map.js +250 -0
- package/dist/browser/storage/index.d.ts +36 -0
- package/dist/browser/storage/index.js +65 -0
- package/dist/browser/types.d.ts +429 -0
- package/dist/browser/types.js +2 -0
- package/dist/daemon/extension-handlers.d.ts +63 -0
- package/dist/daemon/extension-handlers.js +630 -0
- package/dist/daemon/index.js +78 -19
- package/dist/daemon/ws-client.d.ts +16 -0
- package/dist/daemon/ws-client.js +45 -0
- package/dist/mcp/browser-server.d.ts +11 -0
- package/dist/mcp/browser-server.js +467 -0
- package/dist/mcp/knowledge-server.js +43 -18
- package/dist/mcp/setup.js +10 -0
- package/dist/utils/claude-cli.js +18 -9
- package/package.json +2 -1
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
6
|
+
// ── Executable Detection ──
|
|
7
|
+
const CHROMIUM_BUNDLE_IDS = new Set([
|
|
8
|
+
'com.google.Chrome',
|
|
9
|
+
'com.google.Chrome.beta',
|
|
10
|
+
'com.google.Chrome.canary',
|
|
11
|
+
'com.google.Chrome.dev',
|
|
12
|
+
'com.brave.Browser',
|
|
13
|
+
'com.brave.Browser.beta',
|
|
14
|
+
'com.brave.Browser.nightly',
|
|
15
|
+
'com.microsoft.Edge',
|
|
16
|
+
'com.microsoft.EdgeBeta',
|
|
17
|
+
'com.microsoft.EdgeDev',
|
|
18
|
+
'com.microsoft.EdgeCanary',
|
|
19
|
+
'org.chromium.Chromium',
|
|
20
|
+
'com.vivaldi.Vivaldi',
|
|
21
|
+
'com.operasoftware.Opera',
|
|
22
|
+
'com.operasoftware.OperaGX',
|
|
23
|
+
'com.yandex.desktop.yandex-browser',
|
|
24
|
+
'company.thebrowser.Browser',
|
|
25
|
+
]);
|
|
26
|
+
const CHROMIUM_DESKTOP_IDS = new Set([
|
|
27
|
+
'google-chrome.desktop',
|
|
28
|
+
'google-chrome-beta.desktop',
|
|
29
|
+
'google-chrome-unstable.desktop',
|
|
30
|
+
'brave-browser.desktop',
|
|
31
|
+
'microsoft-edge.desktop',
|
|
32
|
+
'microsoft-edge-beta.desktop',
|
|
33
|
+
'microsoft-edge-dev.desktop',
|
|
34
|
+
'microsoft-edge-canary.desktop',
|
|
35
|
+
'chromium.desktop',
|
|
36
|
+
'chromium-browser.desktop',
|
|
37
|
+
'vivaldi.desktop',
|
|
38
|
+
'vivaldi-stable.desktop',
|
|
39
|
+
'opera.desktop',
|
|
40
|
+
'opera-gx.desktop',
|
|
41
|
+
'yandex-browser.desktop',
|
|
42
|
+
'org.chromium.Chromium.desktop',
|
|
43
|
+
]);
|
|
44
|
+
const CHROMIUM_EXE_NAMES = new Set([
|
|
45
|
+
'chrome.exe', 'msedge.exe', 'brave.exe', 'brave-browser.exe', 'chromium.exe',
|
|
46
|
+
'vivaldi.exe', 'opera.exe', 'launcher.exe', 'yandex.exe', 'yandexbrowser.exe',
|
|
47
|
+
'google chrome', 'google chrome canary', 'brave browser', 'microsoft edge',
|
|
48
|
+
'chromium', 'chrome', 'brave', 'msedge', 'brave-browser',
|
|
49
|
+
'google-chrome', 'google-chrome-stable', 'google-chrome-beta', 'google-chrome-unstable',
|
|
50
|
+
'microsoft-edge', 'microsoft-edge-beta', 'microsoft-edge-dev', 'microsoft-edge-canary',
|
|
51
|
+
'chromium-browser', 'vivaldi', 'vivaldi-stable', 'opera', 'opera-stable', 'opera-gx',
|
|
52
|
+
'yandex-browser',
|
|
53
|
+
]);
|
|
54
|
+
function fileExists(filePath) {
|
|
55
|
+
try {
|
|
56
|
+
return fs.existsSync(filePath);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function execText(command, args, timeoutMs = 1200) {
|
|
63
|
+
try {
|
|
64
|
+
const output = execFileSync(command, args, {
|
|
65
|
+
timeout: timeoutMs,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
maxBuffer: 1024 * 1024,
|
|
68
|
+
});
|
|
69
|
+
return String(output ?? '').trim() || null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function inferKindFromIdentifier(identifier) {
|
|
76
|
+
const id = identifier.toLowerCase();
|
|
77
|
+
if (id.includes('brave'))
|
|
78
|
+
return 'brave';
|
|
79
|
+
if (id.includes('edge'))
|
|
80
|
+
return 'edge';
|
|
81
|
+
if (id.includes('chromium'))
|
|
82
|
+
return 'chromium';
|
|
83
|
+
if (id.includes('canary'))
|
|
84
|
+
return 'canary';
|
|
85
|
+
if (id.includes('opera') || id.includes('vivaldi') || id.includes('yandex') || id.includes('thebrowser'))
|
|
86
|
+
return 'chromium';
|
|
87
|
+
return 'chrome';
|
|
88
|
+
}
|
|
89
|
+
function inferKindFromExeName(name) {
|
|
90
|
+
const lower = name.toLowerCase();
|
|
91
|
+
if (lower.includes('brave'))
|
|
92
|
+
return 'brave';
|
|
93
|
+
if (lower.includes('edge') || lower.includes('msedge'))
|
|
94
|
+
return 'edge';
|
|
95
|
+
if (lower.includes('chromium'))
|
|
96
|
+
return 'chromium';
|
|
97
|
+
if (lower.includes('canary') || lower.includes('sxs'))
|
|
98
|
+
return 'canary';
|
|
99
|
+
if (lower.includes('opera') || lower.includes('vivaldi') || lower.includes('yandex'))
|
|
100
|
+
return 'chromium';
|
|
101
|
+
return 'chrome';
|
|
102
|
+
}
|
|
103
|
+
function findFirstExe(candidates) {
|
|
104
|
+
for (const c of candidates)
|
|
105
|
+
if (fileExists(c.path))
|
|
106
|
+
return c;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
// ── Mac Detection ──
|
|
110
|
+
function detectDefaultBrowserBundleIdMac() {
|
|
111
|
+
const plistPath = path.join(os.homedir(), 'Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist');
|
|
112
|
+
if (!fileExists(plistPath))
|
|
113
|
+
return null;
|
|
114
|
+
const handlersRaw = execText('/usr/bin/plutil', ['-extract', 'LSHandlers', 'json', '-o', '-', '--', plistPath], 2000);
|
|
115
|
+
if (!handlersRaw)
|
|
116
|
+
return null;
|
|
117
|
+
let handlers;
|
|
118
|
+
try {
|
|
119
|
+
handlers = JSON.parse(handlersRaw);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(handlers))
|
|
125
|
+
return null;
|
|
126
|
+
const resolveScheme = (scheme) => {
|
|
127
|
+
let candidate = null;
|
|
128
|
+
for (const entry of handlers) {
|
|
129
|
+
if (!entry || typeof entry !== 'object')
|
|
130
|
+
continue;
|
|
131
|
+
if (entry.LSHandlerURLScheme !== scheme)
|
|
132
|
+
continue;
|
|
133
|
+
const role = (typeof entry.LSHandlerRoleAll === 'string' && entry.LSHandlerRoleAll) ||
|
|
134
|
+
(typeof entry.LSHandlerRoleViewer === 'string' && entry.LSHandlerRoleViewer) || null;
|
|
135
|
+
if (role)
|
|
136
|
+
candidate = role;
|
|
137
|
+
}
|
|
138
|
+
return candidate;
|
|
139
|
+
};
|
|
140
|
+
return resolveScheme('http') ?? resolveScheme('https');
|
|
141
|
+
}
|
|
142
|
+
function detectDefaultChromiumMac() {
|
|
143
|
+
const bundleId = detectDefaultBrowserBundleIdMac();
|
|
144
|
+
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId))
|
|
145
|
+
return null;
|
|
146
|
+
const appPathRaw = execText('/usr/bin/osascript', ['-e', `POSIX path of (path to application id "${bundleId}")`]);
|
|
147
|
+
if (!appPathRaw)
|
|
148
|
+
return null;
|
|
149
|
+
const appPath = appPathRaw.trim().replace(/\/$/, '');
|
|
150
|
+
const exeName = execText('/usr/bin/defaults', ['read', path.join(appPath, 'Contents', 'Info'), 'CFBundleExecutable']);
|
|
151
|
+
if (!exeName)
|
|
152
|
+
return null;
|
|
153
|
+
const exePath = path.join(appPath, 'Contents', 'MacOS', exeName.trim());
|
|
154
|
+
if (!fileExists(exePath))
|
|
155
|
+
return null;
|
|
156
|
+
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
|
|
157
|
+
}
|
|
158
|
+
function findChromeMac() {
|
|
159
|
+
return findFirstExe([
|
|
160
|
+
{ kind: 'chrome', path: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' },
|
|
161
|
+
{ kind: 'chrome', path: path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome') },
|
|
162
|
+
{ kind: 'brave', path: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' },
|
|
163
|
+
{ kind: 'brave', path: path.join(os.homedir(), 'Applications/Brave Browser.app/Contents/MacOS/Brave Browser') },
|
|
164
|
+
{ kind: 'edge', path: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' },
|
|
165
|
+
{ kind: 'edge', path: path.join(os.homedir(), 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge') },
|
|
166
|
+
{ kind: 'chromium', path: '/Applications/Chromium.app/Contents/MacOS/Chromium' },
|
|
167
|
+
{ kind: 'chromium', path: path.join(os.homedir(), 'Applications/Chromium.app/Contents/MacOS/Chromium') },
|
|
168
|
+
{ kind: 'canary', path: '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary' },
|
|
169
|
+
{ kind: 'canary', path: path.join(os.homedir(), 'Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary') },
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
// ── Linux Detection ──
|
|
173
|
+
function detectDefaultChromiumLinux() {
|
|
174
|
+
const desktopId = execText('xdg-settings', ['get', 'default-web-browser']) ||
|
|
175
|
+
execText('xdg-mime', ['query', 'default', 'x-scheme-handler/http']);
|
|
176
|
+
if (!desktopId)
|
|
177
|
+
return null;
|
|
178
|
+
const trimmed = desktopId.trim();
|
|
179
|
+
if (!CHROMIUM_DESKTOP_IDS.has(trimmed))
|
|
180
|
+
return null;
|
|
181
|
+
const searchDirs = [
|
|
182
|
+
path.join(os.homedir(), '.local', 'share', 'applications'),
|
|
183
|
+
'/usr/local/share/applications',
|
|
184
|
+
'/usr/share/applications',
|
|
185
|
+
'/var/lib/snapd/desktop/applications',
|
|
186
|
+
];
|
|
187
|
+
let desktopPath = null;
|
|
188
|
+
for (const dir of searchDirs) {
|
|
189
|
+
const candidate = path.join(dir, trimmed);
|
|
190
|
+
if (fileExists(candidate)) {
|
|
191
|
+
desktopPath = candidate;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!desktopPath)
|
|
196
|
+
return null;
|
|
197
|
+
let execLine = null;
|
|
198
|
+
try {
|
|
199
|
+
const lines = fs.readFileSync(desktopPath, 'utf8').split(/\r?\n/);
|
|
200
|
+
for (const line of lines)
|
|
201
|
+
if (line.startsWith('Exec=')) {
|
|
202
|
+
execLine = line.slice(5).trim();
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
if (!execLine)
|
|
208
|
+
return null;
|
|
209
|
+
const tokens = execLine.split(/\s+/);
|
|
210
|
+
let command = null;
|
|
211
|
+
for (const token of tokens) {
|
|
212
|
+
if (!token || token === 'env' || (token.includes('=') && !token.startsWith('/')))
|
|
213
|
+
continue;
|
|
214
|
+
command = token.replace(/^["']|["']$/g, '');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!command)
|
|
218
|
+
return null;
|
|
219
|
+
const resolved = command.startsWith('/') ? command : (execText('which', [command], 800)?.trim() ?? null);
|
|
220
|
+
if (!resolved)
|
|
221
|
+
return null;
|
|
222
|
+
const exeName = path.posix.basename(resolved).toLowerCase();
|
|
223
|
+
if (!CHROMIUM_EXE_NAMES.has(exeName))
|
|
224
|
+
return null;
|
|
225
|
+
return { kind: inferKindFromExeName(exeName), path: resolved };
|
|
226
|
+
}
|
|
227
|
+
function findChromeLinux() {
|
|
228
|
+
return findFirstExe([
|
|
229
|
+
{ kind: 'chrome', path: '/usr/bin/google-chrome' },
|
|
230
|
+
{ kind: 'chrome', path: '/usr/bin/google-chrome-stable' },
|
|
231
|
+
{ kind: 'chrome', path: '/usr/bin/chrome' },
|
|
232
|
+
{ kind: 'brave', path: '/usr/bin/brave-browser' },
|
|
233
|
+
{ kind: 'brave', path: '/usr/bin/brave-browser-stable' },
|
|
234
|
+
{ kind: 'brave', path: '/usr/bin/brave' },
|
|
235
|
+
{ kind: 'brave', path: '/snap/bin/brave' },
|
|
236
|
+
{ kind: 'edge', path: '/usr/bin/microsoft-edge' },
|
|
237
|
+
{ kind: 'edge', path: '/usr/bin/microsoft-edge-stable' },
|
|
238
|
+
{ kind: 'chromium', path: '/usr/bin/chromium' },
|
|
239
|
+
{ kind: 'chromium', path: '/usr/bin/chromium-browser' },
|
|
240
|
+
{ kind: 'chromium', path: '/snap/bin/chromium' },
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
// ── Windows Detection ──
|
|
244
|
+
function findChromeWindows() {
|
|
245
|
+
const localAppData = process.env.LOCALAPPDATA ?? '';
|
|
246
|
+
const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files';
|
|
247
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
|
|
248
|
+
const j = path.win32.join;
|
|
249
|
+
const candidates = [];
|
|
250
|
+
if (localAppData) {
|
|
251
|
+
candidates.push({ kind: 'chrome', path: j(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe') });
|
|
252
|
+
candidates.push({ kind: 'brave', path: j(localAppData, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe') });
|
|
253
|
+
candidates.push({ kind: 'edge', path: j(localAppData, 'Microsoft', 'Edge', 'Application', 'msedge.exe') });
|
|
254
|
+
candidates.push({ kind: 'chromium', path: j(localAppData, 'Chromium', 'Application', 'chrome.exe') });
|
|
255
|
+
candidates.push({ kind: 'canary', path: j(localAppData, 'Google', 'Chrome SxS', 'Application', 'chrome.exe') });
|
|
256
|
+
}
|
|
257
|
+
candidates.push({ kind: 'chrome', path: j(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe') });
|
|
258
|
+
candidates.push({ kind: 'chrome', path: j(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe') });
|
|
259
|
+
candidates.push({ kind: 'brave', path: j(programFiles, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe') });
|
|
260
|
+
candidates.push({ kind: 'brave', path: j(programFilesX86, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe') });
|
|
261
|
+
candidates.push({ kind: 'edge', path: j(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe') });
|
|
262
|
+
candidates.push({ kind: 'edge', path: j(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe') });
|
|
263
|
+
return findFirstExe(candidates);
|
|
264
|
+
}
|
|
265
|
+
// ── Resolve Executable ──
|
|
266
|
+
export function resolveBrowserExecutable(opts) {
|
|
267
|
+
if (opts?.executablePath) {
|
|
268
|
+
if (!fileExists(opts.executablePath))
|
|
269
|
+
throw new Error(`executablePath not found: ${opts.executablePath}`);
|
|
270
|
+
return { kind: 'custom', path: opts.executablePath };
|
|
271
|
+
}
|
|
272
|
+
const platform = process.platform;
|
|
273
|
+
// Try default browser first
|
|
274
|
+
if (platform === 'darwin')
|
|
275
|
+
return detectDefaultChromiumMac() ?? findChromeMac();
|
|
276
|
+
if (platform === 'linux')
|
|
277
|
+
return detectDefaultChromiumLinux() ?? findChromeLinux();
|
|
278
|
+
if (platform === 'win32')
|
|
279
|
+
return findChromeWindows();
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
// ── Port Check ──
|
|
283
|
+
async function ensurePortAvailable(port) {
|
|
284
|
+
await new Promise((resolve, reject) => {
|
|
285
|
+
const tester = net.createServer()
|
|
286
|
+
.once('error', (err) => {
|
|
287
|
+
if (err.code === 'EADDRINUSE')
|
|
288
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
289
|
+
else
|
|
290
|
+
reject(err);
|
|
291
|
+
})
|
|
292
|
+
.once('listening', () => { tester.close(() => resolve()); })
|
|
293
|
+
.listen(port);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// ── Profile Decoration ──
|
|
297
|
+
function safeReadJson(filePath) {
|
|
298
|
+
try {
|
|
299
|
+
if (!fs.existsSync(filePath))
|
|
300
|
+
return null;
|
|
301
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
302
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
303
|
+
return null;
|
|
304
|
+
return parsed;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function safeWriteJson(filePath, data) {
|
|
311
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
312
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
313
|
+
}
|
|
314
|
+
function setDeep(obj, keys, value) {
|
|
315
|
+
let node = obj;
|
|
316
|
+
for (const key of keys.slice(0, -1)) {
|
|
317
|
+
const next = node[key];
|
|
318
|
+
if (typeof next !== 'object' || next === null || Array.isArray(next))
|
|
319
|
+
node[key] = {};
|
|
320
|
+
node = node[key];
|
|
321
|
+
}
|
|
322
|
+
node[keys[keys.length - 1]] = value;
|
|
323
|
+
}
|
|
324
|
+
function parseHexRgbToSignedArgbInt(hex) {
|
|
325
|
+
const cleaned = hex.trim().replace(/^#/, '');
|
|
326
|
+
if (!/^[0-9a-fA-F]{6}$/.test(cleaned))
|
|
327
|
+
return null;
|
|
328
|
+
const argbUnsigned = (255 << 24) | Number.parseInt(cleaned, 16);
|
|
329
|
+
return argbUnsigned > 2147483647 ? argbUnsigned - 4294967296 : argbUnsigned;
|
|
330
|
+
}
|
|
331
|
+
function decorateProfile(userDataDir, name, color) {
|
|
332
|
+
const colorInt = parseHexRgbToSignedArgbInt(color);
|
|
333
|
+
const localStatePath = path.join(userDataDir, 'Local State');
|
|
334
|
+
const preferencesPath = path.join(userDataDir, 'Default', 'Preferences');
|
|
335
|
+
const localState = safeReadJson(localStatePath) ?? {};
|
|
336
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'name'], name);
|
|
337
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'shortcut_name'], name);
|
|
338
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'user_name'], name);
|
|
339
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'profile_color'], color);
|
|
340
|
+
if (colorInt != null) {
|
|
341
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'profile_color_seed'], colorInt);
|
|
342
|
+
setDeep(localState, ['profile', 'info_cache', 'Default', 'profile_highlight_color'], colorInt);
|
|
343
|
+
}
|
|
344
|
+
safeWriteJson(localStatePath, localState);
|
|
345
|
+
const prefs = safeReadJson(preferencesPath) ?? {};
|
|
346
|
+
setDeep(prefs, ['profile', 'name'], name);
|
|
347
|
+
setDeep(prefs, ['profile', 'profile_color'], color);
|
|
348
|
+
if (colorInt != null) {
|
|
349
|
+
setDeep(prefs, ['autogenerated', 'theme', 'color'], colorInt);
|
|
350
|
+
setDeep(prefs, ['browser', 'theme', 'user_color2'], colorInt);
|
|
351
|
+
}
|
|
352
|
+
safeWriteJson(preferencesPath, prefs);
|
|
353
|
+
}
|
|
354
|
+
function ensureCleanExit(userDataDir) {
|
|
355
|
+
const preferencesPath = path.join(userDataDir, 'Default', 'Preferences');
|
|
356
|
+
const prefs = safeReadJson(preferencesPath) ?? {};
|
|
357
|
+
setDeep(prefs, ['exit_type'], 'Normal');
|
|
358
|
+
setDeep(prefs, ['exited_cleanly'], true);
|
|
359
|
+
safeWriteJson(preferencesPath, prefs);
|
|
360
|
+
}
|
|
361
|
+
// ── Launch Chrome ──
|
|
362
|
+
const DEFAULT_CDP_PORT = 9222;
|
|
363
|
+
const DEFAULT_PROFILE_NAME = 'browserclaw';
|
|
364
|
+
const DEFAULT_PROFILE_COLOR = '#FF4500';
|
|
365
|
+
function resolveUserDataDir(profileName) {
|
|
366
|
+
const configDir = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
|
|
367
|
+
return path.join(configDir, 'browserclaw', 'profiles', profileName, 'user-data');
|
|
368
|
+
}
|
|
369
|
+
export async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
370
|
+
const ctrl = new AbortController();
|
|
371
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
372
|
+
try {
|
|
373
|
+
const headers = {};
|
|
374
|
+
if (authToken)
|
|
375
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
376
|
+
const res = await fetch(`${cdpUrl.replace(/\/+$/, '')}/json/version`, { signal: ctrl.signal, headers });
|
|
377
|
+
if (!res.ok)
|
|
378
|
+
return false;
|
|
379
|
+
const data = await res.json();
|
|
380
|
+
return data != null && typeof data === 'object';
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
clearTimeout(t);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
export async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
|
|
390
|
+
const ctrl = new AbortController();
|
|
391
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
392
|
+
try {
|
|
393
|
+
const headers = {};
|
|
394
|
+
if (authToken)
|
|
395
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
396
|
+
const res = await fetch(`${cdpUrl.replace(/\/+$/, '')}/json/version`, { signal: ctrl.signal, headers });
|
|
397
|
+
if (!res.ok)
|
|
398
|
+
return null;
|
|
399
|
+
const data = await res.json();
|
|
400
|
+
if (!data || typeof data !== 'object')
|
|
401
|
+
return null;
|
|
402
|
+
return String(data?.webSocketDebuggerUrl ?? '').trim() || null;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
clearTimeout(t);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
export async function launchChrome(opts = {}) {
|
|
412
|
+
const cdpPort = opts.cdpPort ?? DEFAULT_CDP_PORT;
|
|
413
|
+
await ensurePortAvailable(cdpPort);
|
|
414
|
+
const exe = resolveBrowserExecutable({ executablePath: opts.executablePath });
|
|
415
|
+
if (!exe)
|
|
416
|
+
throw new Error('No supported browser found (Chrome/Brave/Edge/Chromium). Install one or provide executablePath.');
|
|
417
|
+
const profileName = opts.profileName ?? DEFAULT_PROFILE_NAME;
|
|
418
|
+
const userDataDir = opts.userDataDir ?? resolveUserDataDir(profileName);
|
|
419
|
+
// Detect if user provided their own user-data-dir (existing Chrome profile via symlink)
|
|
420
|
+
const isExistingProfile = !!opts.userDataDir;
|
|
421
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
422
|
+
const spawnChrome = () => {
|
|
423
|
+
const args = [
|
|
424
|
+
`--remote-debugging-port=${cdpPort}`,
|
|
425
|
+
`--user-data-dir=${userDataDir}`,
|
|
426
|
+
'--no-first-run',
|
|
427
|
+
'--no-default-browser-check',
|
|
428
|
+
];
|
|
429
|
+
// Only add extra flags for browserclaw-managed profiles (not existing user profiles)
|
|
430
|
+
if (!isExistingProfile) {
|
|
431
|
+
args.push('--disable-sync', '--disable-background-networking', '--disable-component-update', '--disable-features=Translate,MediaRouter', '--disable-session-crashed-bubble', '--hide-crash-restore-bubble');
|
|
432
|
+
}
|
|
433
|
+
// Stealth: hide automation detection (skip for existing profiles — sites like X.com detect this)
|
|
434
|
+
if (!isExistingProfile) {
|
|
435
|
+
args.push('--disable-blink-features=AutomationControlled');
|
|
436
|
+
}
|
|
437
|
+
// Only use basic password store on Linux (macOS needs Keychain for cookie decryption)
|
|
438
|
+
if (process.platform === 'linux')
|
|
439
|
+
args.push('--password-store=basic');
|
|
440
|
+
if (opts.headless) {
|
|
441
|
+
args.push('--headless=new', '--disable-gpu');
|
|
442
|
+
}
|
|
443
|
+
if (opts.noSandbox) {
|
|
444
|
+
args.push('--no-sandbox', '--disable-setuid-sandbox');
|
|
445
|
+
}
|
|
446
|
+
if (process.platform === 'linux')
|
|
447
|
+
args.push('--disable-dev-shm-usage');
|
|
448
|
+
const extraArgs = Array.isArray(opts.chromeArgs)
|
|
449
|
+
? opts.chromeArgs.filter((a) => typeof a === 'string' && a.trim().length > 0)
|
|
450
|
+
: [];
|
|
451
|
+
if (extraArgs.length)
|
|
452
|
+
args.push(...extraArgs);
|
|
453
|
+
args.push('about:blank');
|
|
454
|
+
return spawn(exe.path, args, {
|
|
455
|
+
stdio: 'pipe',
|
|
456
|
+
env: { ...process.env, HOME: os.homedir() },
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
const startedAt = Date.now();
|
|
460
|
+
const localStatePath = path.join(userDataDir, 'Local State');
|
|
461
|
+
const preferencesPath = path.join(userDataDir, 'Default', 'Preferences');
|
|
462
|
+
// Skip bootstrap/decoration for existing Chrome profiles (user-provided user-data-dir)
|
|
463
|
+
if (!isExistingProfile && (!fileExists(localStatePath) || !fileExists(preferencesPath))) {
|
|
464
|
+
const bootstrap = spawnChrome();
|
|
465
|
+
const deadline = Date.now() + 10000;
|
|
466
|
+
while (Date.now() < deadline) {
|
|
467
|
+
if (fileExists(localStatePath) && fileExists(preferencesPath))
|
|
468
|
+
break;
|
|
469
|
+
await new Promise(r => setTimeout(r, 100));
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
bootstrap.kill('SIGTERM');
|
|
473
|
+
}
|
|
474
|
+
catch { }
|
|
475
|
+
const exitDeadline = Date.now() + 5000;
|
|
476
|
+
while (Date.now() < exitDeadline) {
|
|
477
|
+
if (bootstrap.exitCode != null)
|
|
478
|
+
break;
|
|
479
|
+
await new Promise(r => setTimeout(r, 50));
|
|
480
|
+
}
|
|
481
|
+
if (bootstrap.exitCode == null) {
|
|
482
|
+
try {
|
|
483
|
+
bootstrap.kill('SIGKILL');
|
|
484
|
+
}
|
|
485
|
+
catch { }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Skip decoration for existing Chrome profiles to avoid modifying user's profile
|
|
489
|
+
if (!isExistingProfile) {
|
|
490
|
+
try {
|
|
491
|
+
decorateProfile(userDataDir, profileName, opts.profileColor ?? DEFAULT_PROFILE_COLOR);
|
|
492
|
+
}
|
|
493
|
+
catch { }
|
|
494
|
+
try {
|
|
495
|
+
ensureCleanExit(userDataDir);
|
|
496
|
+
}
|
|
497
|
+
catch { }
|
|
498
|
+
}
|
|
499
|
+
// Launch for real
|
|
500
|
+
const proc = spawnChrome();
|
|
501
|
+
const cdpUrl = `http://127.0.0.1:${cdpPort}`;
|
|
502
|
+
// Wait for Chrome to be ready
|
|
503
|
+
const readyDeadline = Date.now() + 15000;
|
|
504
|
+
while (Date.now() < readyDeadline) {
|
|
505
|
+
if (await isChromeReachable(cdpUrl, 500))
|
|
506
|
+
break;
|
|
507
|
+
await new Promise(r => setTimeout(r, 200));
|
|
508
|
+
}
|
|
509
|
+
if (!await isChromeReachable(cdpUrl, 500)) {
|
|
510
|
+
try {
|
|
511
|
+
proc.kill('SIGKILL');
|
|
512
|
+
}
|
|
513
|
+
catch { }
|
|
514
|
+
throw new Error(`Failed to start Chrome CDP on port ${cdpPort}. Chrome may not have started correctly.`);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
pid: proc.pid ?? -1,
|
|
518
|
+
exe,
|
|
519
|
+
userDataDir,
|
|
520
|
+
cdpPort,
|
|
521
|
+
startedAt,
|
|
522
|
+
proc,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
export async function stopChrome(running, timeoutMs = 2500) {
|
|
526
|
+
const proc = running.proc;
|
|
527
|
+
if (proc.exitCode != null)
|
|
528
|
+
return;
|
|
529
|
+
try {
|
|
530
|
+
proc.kill('SIGTERM');
|
|
531
|
+
}
|
|
532
|
+
catch { }
|
|
533
|
+
const start = Date.now();
|
|
534
|
+
while (Date.now() - start < timeoutMs) {
|
|
535
|
+
if (proc.exitCode != null)
|
|
536
|
+
return;
|
|
537
|
+
await new Promise(r => setTimeout(r, 100));
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
proc.kill('SIGKILL');
|
|
541
|
+
}
|
|
542
|
+
catch { }
|
|
543
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Browser, Page } from 'playwright-core';
|
|
2
|
+
import type { PageState, RoleRefs } from './types.js';
|
|
3
|
+
/** Page extended with Playwright's private `_snapshotForAI` method. */
|
|
4
|
+
export type PageWithAI = Page & {
|
|
5
|
+
_snapshotForAI?: (opts: {
|
|
6
|
+
timeout: number;
|
|
7
|
+
track: string;
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
full?: string;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
export declare function ensurePageState(page: Page): PageState;
|
|
13
|
+
export declare function setStealthEnabled(enabled: boolean): void;
|
|
14
|
+
export declare function storeRoleRefsForTarget(opts: {
|
|
15
|
+
page: Page;
|
|
16
|
+
cdpUrl: string;
|
|
17
|
+
targetId?: string;
|
|
18
|
+
refs: RoleRefs;
|
|
19
|
+
frameSelector?: string;
|
|
20
|
+
mode?: 'role' | 'aria';
|
|
21
|
+
}): void;
|
|
22
|
+
export declare function restoreRoleRefsForTarget(opts: {
|
|
23
|
+
cdpUrl: string;
|
|
24
|
+
targetId?: string;
|
|
25
|
+
page: Page;
|
|
26
|
+
}): void;
|
|
27
|
+
export declare function connectBrowser(cdpUrl: string, authToken?: string): Promise<{
|
|
28
|
+
browser: Browser;
|
|
29
|
+
cdpUrl: string;
|
|
30
|
+
authToken?: string;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function disconnectBrowser(): Promise<void>;
|
|
33
|
+
export declare function getAllPages(browser: Browser): Promise<Page[]>;
|
|
34
|
+
export declare function pageTargetId(page: Page): Promise<string | null>;
|
|
35
|
+
export declare function findPageByTargetId(browser: Browser, targetId: string, cdpUrl?: string): Promise<Page | null>;
|
|
36
|
+
export declare function getPageForTargetId(opts: {
|
|
37
|
+
cdpUrl: string;
|
|
38
|
+
targetId?: string;
|
|
39
|
+
}): Promise<Page>;
|
|
40
|
+
export declare function refLocator(page: Page, ref: string): import("playwright-core").Locator;
|
|
41
|
+
export declare function toAIFriendlyError(error: unknown, selector: string): Error;
|
|
42
|
+
export declare function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number, maxMs?: number): number;
|