figranium 0.12.0 → 0.12.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/headful.js CHANGED
@@ -1,569 +1,584 @@
1
- const { chromium } = require('./stealth-chromium');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const { getProxySelection } = require('./proxy-rotation');
5
- const { selectUserAgent } = require('./user-agent-settings');
6
- const { validateUrl, setupNavigationProtection } = require('./url-utils');
7
- const { parseBooleanFlag } = require('./common-utils');
8
- const { Mutex } = require('./src/server/utils');
9
-
10
- const HEADFUL_PROFILE_DIR = path.join(__dirname, 'data', 'browser-profile-headful');
11
- const HEADFUL_STATE_PATH = path.join(__dirname, 'data', 'headful-storage-state.json');
12
-
13
- const headfulMutex = new Mutex();
14
-
15
- async function saveHeadfulStorageState(context) {
16
- if (!context) return;
17
- try {
18
- const state = await context.storageState();
19
- const now = Date.now() / 1000;
20
- const cookies = (state.cookies || []).filter(c => !c.expires || c.expires === -1 || c.expires > now);
21
- if (cookies.length === 0) return;
22
- await fs.promises.mkdir(path.join(__dirname, 'data'), { recursive: true });
23
- await fs.promises.writeFile(HEADFUL_STATE_PATH, JSON.stringify({ cookies }, null, 2));
24
- console.log(`[HEADFUL] Saved ${cookies.length} cookies to headful-storage-state.json`);
25
- } catch (e) {
26
- console.error('[HEADFUL] Failed to save storage state:', e.message);
27
- }
28
- }
29
-
30
- const EventEmitter = require('events');
31
- const headfulEventEmitter = new EventEmitter();
32
-
33
- let activeSession = null;
34
-
35
- const teardownActiveSession = async () => {
36
- if (!activeSession) return;
37
- try {
38
- if (activeSession.interval) clearInterval(activeSession.interval);
39
- } catch { }
40
- if (activeSession.context && !activeSession.statelessExecution) {
41
- await saveHeadfulStorageState(activeSession.context);
42
- }
43
- try {
44
- if (activeSession.browser) {
45
- await activeSession.browser.close();
46
- } else if (activeSession.context) {
47
- await activeSession.context.close();
48
- }
49
- } catch { }
50
- activeSession = null;
51
- };
52
-
53
- async function runHeadful(data, options = {}) {
54
- const { res } = options;
55
- if (activeSession) {
56
- await teardownActiveSession();
57
- }
58
-
59
- const url = data.url || 'https://www.google.com';
60
-
61
- await validateUrl(url);
62
-
63
- const rotateProxiesRaw = data.rotateProxies;
64
- const rotateProxies = String(rotateProxiesRaw).toLowerCase() === 'true' || rotateProxiesRaw === true;
65
- const statelessExecutionRaw = data.statelessExecution;
66
- const statelessExecution = parseBooleanFlag(statelessExecutionRaw);
67
-
68
- const inspectModeEnabled = !!(data.targetActionId);
69
-
70
- activeSession = { status: 'starting', startedAt: Date.now(), inspectModeEnabled };
71
-
72
- const selectedUA = await selectUserAgent(false);
73
-
74
- let browser;
75
- let context;
76
- let page;
77
- let navigated = false;
78
-
79
- try {
80
- if (data.targetActionId && data.taskSnapshot) {
81
- const { runAgent } = require('./src/agent');
82
- try {
83
- const reqScope = { ...data.taskSnapshot, variables: data.variables || data.taskVariables || {}, statelessExecution: true, disableRecording: true };
84
- if (data.url) reqScope.url = data.url;
85
-
86
- const result = await runAgent(reqScope, {
87
- headless: false,
88
- handoffContext: true,
89
- stopAtActionId: data.targetActionId
90
- });
91
- if (result && result._handoff) {
92
- browser = result._handoff.browser;
93
- context = result._handoff.context;
94
- page = result._handoff.page;
95
- navigated = true;
96
- }
97
- } catch (e) {
98
- console.error("Agent handoff failed:", e);
99
- }
100
- }
101
-
102
- if (!browser) {
103
- const selection = getProxySelection(rotateProxies);
104
- const hasProxy = !!selection.proxy;
105
-
106
- const args = [
107
- '--no-sandbox',
108
- '--disable-setuid-sandbox',
109
- '--disable-dev-shm-usage',
110
- '--disable-gpu',
111
- '--window-size=1920,1080',
112
- '--window-position=0,0',
113
- '--start-maximized',
114
- '--dns-prefetch-disable',
115
- '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
116
- ];
117
- if (!hasProxy) {
118
- args.push(
119
- '--enable-features=DnsOverHttps',
120
- '--dns-over-https-mode=secure',
121
- '--dns-over-https-templates=https://cloudflare-dns.com/dns-query'
122
- );
123
- }
124
-
125
- const contextOptions = {
126
- viewport: null,
127
- userAgent: selectedUA,
128
- locale: 'en-US',
129
- timezoneId: 'America/New_York',
130
- permissions: ['clipboard-read', 'clipboard-write'],
131
- ...(selection.proxy ? { proxy: selection.proxy } : {})
132
- };
133
-
134
- if (statelessExecution) {
135
- browser = await chromium.launch({ headless: false, args, ...(selection.proxy ? { proxy: selection.proxy } : {}) });
136
- context = await browser.newContext(contextOptions);
137
- } else {
138
- await fs.promises.mkdir(HEADFUL_PROFILE_DIR, { recursive: true });
139
- // Remove stale lock files left by a previous container/process to prevent launch failure
140
- for (const lockFile of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
141
- try { await fs.promises.unlink(path.join(HEADFUL_PROFILE_DIR, lockFile)); } catch { }
142
- }
143
- context = await chromium.launchPersistentContext(HEADFUL_PROFILE_DIR, { headless: false, args, ...contextOptions });
144
- browser = context.browser();
145
- }
146
- }
147
-
148
- const inspectInitFn = () => {
149
- Object.defineProperty(window, 'open', { writable: true, configurable: true, value: () => null });
150
- const handleLinkClick = (event) => {
151
- const path = event.composedPath ? event.composedPath() : [];
152
- const anchor = path.find(el => el.tagName === 'A');
153
- if (anchor && anchor.target === '_blank') {
154
- event.preventDefault();
155
- return;
156
- }
157
- if (event.type === 'auxclick' && event.button === 1 && anchor) {
158
- event.preventDefault();
159
- }
160
- };
161
- document.addEventListener('click', handleLinkClick, true);
162
- document.addEventListener('auxclick', handleLinkClick, true);
163
-
164
- window.__figraniumInspectInit = () => {
165
- if (window._figraniumInspectHandler) return;
166
-
167
- const overlay = document.createElement('div');
168
- overlay.id = 'figranium-inspect-overlay';
169
- overlay.style.position = 'fixed';
170
- overlay.style.pointerEvents = 'none';
171
- overlay.style.zIndex = '2147483646';
172
- overlay.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
173
- overlay.style.border = '1px solid rgb(96, 165, 250)';
174
- overlay.style.boxSizing = 'border-box';
175
- overlay.style.transition = 'all 0.1s ease';
176
- overlay.style.display = 'none';
177
- document.body.appendChild(overlay);
178
-
179
- const tooltip = document.createElement('div');
180
- tooltip.id = 'figranium-inspect-tooltip';
181
- tooltip.style.position = 'fixed';
182
- tooltip.style.pointerEvents = 'none';
183
- tooltip.style.zIndex = '2147483647';
184
- tooltip.style.backgroundColor = '#1e293b';
185
- tooltip.style.color = '#f8fafc';
186
- tooltip.style.padding = '4px 8px';
187
- tooltip.style.borderRadius = '4px';
188
- tooltip.style.fontSize = '12px';
189
- tooltip.style.fontFamily = 'monospace';
190
- tooltip.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)';
191
- tooltip.style.display = 'none';
192
- tooltip.style.whiteSpace = 'nowrap';
193
- tooltip.style.lineHeight = '1.4';
194
- document.body.appendChild(tooltip);
195
-
196
- window._figraniumGetSelectors = (el) => {
197
- const isRandomId = (id) => {
198
- if (!id) return true;
199
- // Long numbers, UUIDs, explicit long strings
200
- if (/\d{4,}/.test(id) || /^[0-9a-f]{8}-/i.test(id) || id.length > 30 || /[0-9]{3,}/.test(id)) return true;
201
- // Google-style obfuscated classes (e.g. gLFyf, APjFqb) mixed-case letters that don't follow camelCase/PascalCase (with common acronyms allowed)
202
- if (/^[a-zA-Z]{4,8}$/.test(id) && /[A-Z]/.test(id) && /[a-z]/.test(id)) {
203
- const acr = '(?:UI|UX|ID|DB|IO|IP|OS|QA|AI|ML|API|URL|CSS|DOM|RGB|SVG|XML|SQL|SDK|CLI|SSH|DNS|TCP|UDP|HTTP|JSON|HTML)';
204
- const validCamelCase = new RegExp('^(?:' + acr + '|[A-Z]?[a-z]+)(?:' + acr + '|[A-Z][a-z]+)*$');
205
- if (!validCamelCase.test(id)) return true;
206
- }
207
- // Short mixed-case alphanumeric with digits (e.g. A7sPV, tX61Ub, gL3fY)
208
- if (id.length <= 10 && /^[a-zA-Z0-9]+$/.test(id) && /[A-Z]/.test(id) && /[a-z]/.test(id) && /[0-9]/.test(id)) return true;
209
- // Styled-components or CSS modules with hashes like css-1n7jcv, style_module__1xyz
210
- if (/^css-[a-zA-Z0-9]+/.test(id) || /^sc-[a-zA-Z0-9]+/.test(id) || /_[a-zA-Z0-9]{5,}$/.test(id) || /-[a-zA-Z0-9]{5,}$/.test(id)) return true;
211
- // Tailwind arbitrary values or very complex utility classes
212
- if (id.includes('[') || id.includes(']')) return true;
213
- return false;
214
- };
215
- const tag = el.tagName ? el.tagName.toLowerCase() : '';
216
- if (!tag || tag === 'html' || tag === 'body') return [tag];
217
-
218
- const selectors = new Set();
219
-
220
- const isUnique = (sel) => {
221
- try {
222
- const nodes = document.querySelectorAll(sel);
223
- return nodes.length === 1 && nodes[0] === el;
224
- } catch (e) { return false; }
225
- };
226
-
227
- const addIfUnique = (sel) => {
228
- if (isUnique(sel)) selectors.add(sel);
229
- };
230
-
231
- // 1. Name & placeholder (highest priority — most human-readable)
232
- const topAttrs = ['name', 'placeholder'];
233
- for (const attr of topAttrs) {
234
- const val = el.getAttribute(attr);
235
- if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
236
- addIfUnique(`[${attr}="${val}"]`);
237
- addIfUnique(`${tag}[${attr}="${val}"]`);
238
- }
239
- }
240
-
241
- // 2. Text content (:has-text — very readable)
242
- if ((tag === 'button' || tag === 'a' || tag === 'span' || tag === 'div' || tag === 'label' || tag === 'li' || tag === 'p' || tag === 'h1' || tag === 'h2' || tag === 'h3') && el.textContent) {
243
- const text = el.textContent.trim().substring(0, 40);
244
- if (text && !text.includes('\n') && !text.includes('"') && text.length > 1) {
245
- const allTags = Array.from(document.querySelectorAll(tag));
246
- const matches = allTags.filter(t => t.textContent.trim() === text);
247
- if (matches.length === 1 && matches[0] === el) {
248
- selectors.add(`${tag}:has-text("${text}")`);
249
- }
250
- }
251
- }
252
-
253
- // 3. Other semantic attributes
254
- const semanticAttrs = ['aria-label', 'title', 'alt'];
255
- for (const attr of semanticAttrs) {
256
- const val = el.getAttribute(attr);
257
- if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
258
- addIfUnique(`[${attr}="${val}"]`);
259
- addIfUnique(`${tag}[${attr}="${val}"]`);
260
- }
261
- }
262
-
263
- // 4. Data attributes
264
- const dataAttrs = ['data-testid', 'data-test-id', 'data-qa', 'data-cy'];
265
- for (const attr of dataAttrs) {
266
- const val = el.getAttribute(attr);
267
- if (val) {
268
- addIfUnique(`[${attr}="${val}"]`);
269
- addIfUnique(`${tag}[${attr}="${val}"]`);
270
- }
271
- }
272
-
273
- // 5. IDs
274
- const id = el.id;
275
- if (id && !isRandomId(id)) {
276
- addIfUnique(`#${id}`);
277
- addIfUnique(`${tag}#${id}`);
278
- }
279
-
280
- // 6. Other basic attributes
281
- const otherAttrs = ['type', 'value', 'href', 'src'];
282
- for (const attr of otherAttrs) {
283
- const val = el.getAttribute(attr);
284
- if (val && val.length < 50 && !val.includes('"') && !val.includes('\n') && !val.startsWith('data:')) {
285
- addIfUnique(`${tag}[${attr}="${val}"]`);
286
- }
287
- }
288
-
289
- // 7. Classes
290
- const classes = el.className && typeof el.className === 'string' ?
291
- el.className.trim().split(/\s+/).filter(c => c && !isRandomId(c)) : [];
292
- const classStr = classes.length > 0 ? '.' + classes.join('.') : '';
293
-
294
- if (classStr) {
295
- addIfUnique(`${tag}${classStr}`);
296
- if (classes.length === 1) addIfUnique(`${classStr}`);
297
- if (classes.length > 1) {
298
- for (let c of classes) addIfUnique(`${tag}.${c}`);
299
- }
300
- }
301
-
302
- addIfUnique(tag);
303
-
304
- // 7. Structural
305
- if (el.parentElement) {
306
- const siblings = Array.from(el.parentElement.children).filter(c => c.tagName === el.tagName);
307
- const index = siblings.indexOf(el) + 1;
308
- addIfUnique(`${tag}:nth-of-type(${index})`);
309
- if (classStr) addIfUnique(`${tag}${classStr}:nth-of-type(${index})`);
310
- }
311
-
312
- // 8. Combinations with parents (Basic Path generation fallback)
313
- if (selectors.size < 3) {
314
- let path = '';
315
- let current = el;
316
- while (current && current !== document.body && current !== document.documentElement) {
317
- let step = current.tagName.toLowerCase();
318
-
319
- // add id if good
320
- if (current.id && !isRandomId(current.id)) {
321
- step += `#${current.id}`;
322
- } else {
323
- // Add nth-of-type if no id and has siblings of same tag
324
- if (current.parentElement) {
325
- const sibs = Array.from(current.parentElement.children).filter(c => c.tagName === current.tagName);
326
- if (sibs.length > 1) step += `:nth-of-type(${sibs.indexOf(current) + 1})`;
327
- }
328
- }
329
-
330
- path = path ? `${step} > ${path}` : step;
331
- if (isUnique(path)) {
332
- selectors.add(path);
333
- break; // Stop as soon as we found a unique path
334
- }
335
-
336
- // Try ID anchor
337
- if (current.id && !isRandomId(current.id) && isUnique(`#${current.id}`)) {
338
- break; // We anchored on a unique ID
339
- }
340
-
341
- current = current.parentElement;
342
- }
343
- }
344
-
345
- return Array.from(selectors).slice(0, 5);
346
- };
347
-
348
- window._figraniumInspectHandler = (e) => {
349
- const element = e.composedPath ? e.composedPath()[0] : e.target;
350
- if (!element || element === document || element === document.body) {
351
- overlay.style.display = 'none';
352
- tooltip.style.display = 'none';
353
- return;
354
- }
355
-
356
- const rect = element.getBoundingClientRect();
357
- overlay.style.display = 'block';
358
- overlay.style.top = rect.top + 'px';
359
- overlay.style.left = rect.left + 'px';
360
- overlay.style.width = rect.width + 'px';
361
- overlay.style.height = rect.height + 'px';
362
-
363
- const selectors = window._figraniumGetSelectors(element);
364
- tooltip.style.display = 'block';
365
- tooltip.innerHTML = selectors.map((s, i) => i === 0 ? `<strong>${s}</strong>` : `<span style="opacity:0.7">${s}</span>`).join('<br/>');
366
-
367
- let tipTop = e.clientY + 15;
368
- let tipLeft = e.clientX + 15;
369
-
370
- const tooltipRect = tooltip.getBoundingClientRect();
371
- if (tipLeft + tooltipRect.width > window.innerWidth) {
372
- tipLeft = e.clientX - tooltipRect.width - 15;
373
- }
374
- if (tipTop + tooltipRect.height > window.innerHeight) {
375
- tipTop = e.clientY - tooltipRect.height - 15;
376
- }
377
-
378
- tooltip.style.top = tipTop + 'px';
379
- tooltip.style.left = tipLeft + 'px';
380
- };
381
-
382
- window._figraniumInspectClickHandler = async (e) => {
383
- if (!window._figraniumInspectHandler) return;
384
- e.preventDefault();
385
- e.stopPropagation();
386
- e.stopImmediatePropagation();
387
-
388
- const element = e.composedPath ? e.composedPath()[0] : e.target;
389
- const selectors = window._figraniumGetSelectors(element);
390
- const bestSelector = selectors[0] || '';
391
-
392
- // Push to backend via Playwright binding
393
- if (window.__figraniumOnElementSelected && selectors.length > 0) {
394
- try {
395
- await window.__figraniumOnElementSelected(JSON.stringify(selectors));
396
- } catch (err) { }
397
- }
398
-
399
- try {
400
- if (bestSelector && navigator.clipboard && navigator.clipboard.writeText) {
401
- await navigator.clipboard.writeText(bestSelector);
402
- }
403
- } catch (err) { }
404
- };
405
-
406
- document.addEventListener('mousemove', window._figraniumInspectHandler, true);
407
- document.addEventListener('click', window._figraniumInspectClickHandler, true);
408
- };
409
-
410
- window.__figraniumInspectDestroy = () => {
411
- const overlay = document.getElementById('figranium-inspect-overlay');
412
- if (overlay) overlay.remove();
413
- const tooltip = document.getElementById('figranium-inspect-tooltip');
414
- if (tooltip) tooltip.remove();
415
- if (window._figraniumInspectHandler) {
416
- document.removeEventListener('mousemove', window._figraniumInspectHandler, true);
417
- delete window._figraniumInspectHandler;
418
- }
419
- if (window._figraniumInspectClickHandler) {
420
- document.removeEventListener('click', window._figraniumInspectClickHandler, true);
421
- delete window._figraniumInspectClickHandler;
422
- }
423
- };
424
-
425
- window.addEventListener('DOMContentLoaded', async () => {
426
- if (window.__figraniumIsInspectEnabled) {
427
- const enabled = await window.__figraniumIsInspectEnabled();
428
- if (enabled) {
429
- window.__figraniumInspectInit();
430
- }
431
- }
432
- });
433
- };
434
-
435
- await setupNavigationProtection(context);
436
- await context.addInitScript(inspectInitFn);
437
-
438
- await context.exposeBinding('__figraniumIsInspectEnabled', () => {
439
- return activeSession ? !!activeSession.inspectModeEnabled : false;
440
- });
441
-
442
- await context.exposeBinding('__figraniumOnElementSelected', (source, selector) => {
443
- headfulEventEmitter.emit('selectorSelected', selector);
444
- });
445
-
446
- if (!page) {
447
- // Persistent context auto-creates a blank page; reuse it or open a new one
448
- const existingPages = context.pages();
449
- page = existingPages.length > 0 ? existingPages[0] : await context.newPage();
450
- try {
451
- const cdp = await context.newCDPSession(page);
452
- const { windowId } = await cdp.send('Browser.getWindowForTarget');
453
- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
454
- } catch (e) { }
455
- } else {
456
- try { await page.evaluate(inspectInitFn); } catch (e) { }
457
- try {
458
- await page.evaluate(() => {
459
- if (window.__figraniumInspectInit) window.__figraniumInspectInit();
460
- });
461
- } catch (e) { }
462
- }
463
-
464
- const closeIfExtra = async (extraPage) => {
465
- if (!extraPage || extraPage === page) return;
466
- try { await extraPage.close(); } catch { }
467
- };
468
-
469
- context.on('page', closeIfExtra);
470
- page.on('popup', async (popup) => {
471
- try { popup.close().catch(() => { }); } catch { }
472
- await closeIfExtra(popup);
473
- });
474
-
475
- if (!navigated && url) {
476
- await page.goto(url).catch(() => { });
477
- }
478
-
479
- const syncInterval = statelessExecution ? null : setInterval(() => {
480
- if (activeSession && activeSession.context) {
481
- saveHeadfulStorageState(activeSession.context).catch(() => {});
482
- }
483
- }, 30000);
484
- activeSession = { browser, context, page, status: 'running', startedAt: activeSession.startedAt, inspectModeEnabled: activeSession.inspectModeEnabled, statelessExecution, interval: syncInterval };
485
-
486
- page.on('close', async () => { });
487
-
488
- const responseData = {
489
- message: 'Headful session started.',
490
- userAgentUsed: selectedUA
491
- };
492
-
493
- if (res) {
494
- res.json(responseData);
495
- }
496
-
497
- if (browser) {
498
- await new Promise((resolve) => browser.once('disconnected', resolve));
499
- } else {
500
- // Persistent context: context.browser() returns null; wait for context close instead
501
- await new Promise((resolve) => context.once('close', resolve));
502
- }
503
- if (syncInterval) clearInterval(syncInterval);
504
- if (!statelessExecution && context) {
505
- await saveHeadfulStorageState(context).catch(() => {});
506
- }
507
- activeSession = null;
508
- return responseData;
509
- } catch (error) {
510
- if (browser) await browser.close();
511
- else if (context) await context.close().catch(() => {});
512
- activeSession = null;
513
- throw error;
514
- }
515
- }
516
-
517
- async function handleHeadful(req, res) {
518
- await headfulMutex.lock();
519
- try {
520
- const data = { ...req.body, ...req.query };
521
- await runHeadful(data, { res });
522
- } catch (error) {
523
- const message = String(error && error.message ? error.message : error);
524
- const displayUnavailable = /missing x server|\$display|platform failed to initialize/i.test(message);
525
- if (!res.headersSent && displayUnavailable) {
526
- return res.status(409).json({ error: 'HEADFUL_DISPLAY_UNAVAILABLE', details: message });
527
- }
528
- if (!res.headersSent) {
529
- res.status(500).json({ error: 'Failed to start headful session', details: message });
530
- }
531
- } finally {
532
- headfulMutex.unlock();
533
- }
534
- }
535
-
536
- async function stopHeadful(req, res) {
537
- if (!activeSession) {
538
- return res.status(200).json({ message: 'No active headful session.' });
539
- }
540
-
541
- await teardownActiveSession();
542
- if (res) res.json({ message: 'Headful session stopped.' });
543
- }
544
-
545
- async function toggleInspectMode(req, res) {
546
- if (!activeSession || !activeSession.context) {
547
- return res.status(400).json({ error: 'No active headful session.' });
548
- }
549
- const enabled = req.body.enabled === true || req.body.enabled === 'true';
550
- activeSession.inspectModeEnabled = enabled;
551
-
552
- try {
553
- const pages = activeSession.context.pages();
554
- for (const page of pages) {
555
- await page.evaluate((enabled) => {
556
- if (enabled) {
557
- if (window.__figraniumInspectInit) window.__figraniumInspectInit();
558
- } else {
559
- if (window.__figraniumInspectDestroy) window.__figraniumInspectDestroy();
560
- }
561
- }, enabled);
562
- }
563
- res.json({ message: `Inspect mode ${enabled ? 'enabled' : 'disabled'}` });
564
- } catch (error) {
565
- res.status(500).json({ error: 'Failed to toggle inspect mode', details: String(error) });
566
- }
567
- }
568
-
569
- module.exports = { runHeadful, handleHeadful, stopHeadful, toggleInspectMode, headfulEventEmitter };
1
+ const { chromium } = require('./stealth-chromium');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getProxySelection } = require('./proxy-rotation');
5
+ const { selectUserAgent } = require('./user-agent-settings');
6
+ const { validateUrl, setupNavigationProtection } = require('./url-utils');
7
+ const { parseBooleanFlag } = require('./common-utils');
8
+ const { Mutex } = require('./src/server/utils');
9
+
10
+ const HEADFUL_PROFILE_DIR = path.join(__dirname, 'data', 'browser-profile-headful');
11
+ const HEADFUL_STATE_PATH = path.join(__dirname, 'data', 'headful-storage-state.json');
12
+
13
+ const headfulMutex = new Mutex();
14
+
15
+ async function saveHeadfulStorageState(context) {
16
+ if (!context) return;
17
+ try {
18
+ const state = await context.storageState();
19
+ const now = Date.now() / 1000;
20
+ const cookies = (state.cookies || []).filter(c => !c.expires || c.expires === -1 || c.expires > now);
21
+ if (cookies.length === 0) return;
22
+ await fs.promises.mkdir(path.join(__dirname, 'data'), { recursive: true });
23
+ await fs.promises.writeFile(HEADFUL_STATE_PATH, JSON.stringify({ cookies }, null, 2));
24
+ console.log(`[HEADFUL] Saved ${cookies.length} cookies to headful-storage-state.json`);
25
+ } catch (e) {
26
+ console.error('[HEADFUL] Failed to save storage state:', e.message);
27
+ }
28
+ }
29
+
30
+ const EventEmitter = require('events');
31
+ const headfulEventEmitter = new EventEmitter();
32
+
33
+ let activeSession = null;
34
+
35
+ function setActiveHeadfulPage(nextPage) {
36
+ if (!activeSession || !nextPage || nextPage.isClosed()) return;
37
+ activeSession.page = nextPage;
38
+ }
39
+
40
+ const teardownActiveSession = async () => {
41
+ if (!activeSession) return;
42
+ try {
43
+ if (activeSession.interval) clearInterval(activeSession.interval);
44
+ } catch { }
45
+ if (activeSession.context && !activeSession.statelessExecution) {
46
+ await saveHeadfulStorageState(activeSession.context);
47
+ }
48
+ try {
49
+ if (activeSession.browser) {
50
+ await activeSession.browser.close();
51
+ } else if (activeSession.context) {
52
+ await activeSession.context.close();
53
+ }
54
+ } catch { }
55
+ activeSession = null;
56
+ };
57
+
58
+ async function runHeadful(data, options = {}) {
59
+ const { res } = options;
60
+ if (activeSession) {
61
+ await teardownActiveSession();
62
+ }
63
+
64
+ const url = data.url || 'https://www.google.com';
65
+
66
+ await validateUrl(url);
67
+
68
+ const rotateProxiesRaw = data.rotateProxies;
69
+ const rotateProxies = String(rotateProxiesRaw).toLowerCase() === 'true' || rotateProxiesRaw === true;
70
+ const statelessExecutionRaw = data.statelessExecution;
71
+ const statelessExecution = parseBooleanFlag(statelessExecutionRaw);
72
+
73
+ const inspectModeEnabled = !!(data.targetActionId);
74
+
75
+ activeSession = { status: 'starting', startedAt: Date.now(), inspectModeEnabled };
76
+
77
+ const selectedUA = await selectUserAgent(false);
78
+
79
+ let browser;
80
+ let context;
81
+ let page;
82
+ let navigated = false;
83
+
84
+ try {
85
+ if (data.targetActionId && data.taskSnapshot) {
86
+ const { runAgent } = require('./src/agent');
87
+ try {
88
+ const reqScope = { ...data.taskSnapshot, variables: data.variables || data.taskVariables || {}, statelessExecution: true, disableRecording: true };
89
+ if (data.url) reqScope.url = data.url;
90
+
91
+ const result = await runAgent(reqScope, {
92
+ headless: false,
93
+ handoffContext: true,
94
+ stopAtActionId: data.targetActionId
95
+ });
96
+ if (result && result._handoff) {
97
+ browser = result._handoff.browser;
98
+ context = result._handoff.context;
99
+ page = result._handoff.page;
100
+ navigated = true;
101
+ }
102
+ } catch (e) {
103
+ console.error("Agent handoff failed:", e);
104
+ }
105
+ }
106
+
107
+ if (!browser) {
108
+ const selection = getProxySelection(rotateProxies);
109
+ const hasProxy = !!selection.proxy;
110
+
111
+ const args = [
112
+ '--no-sandbox',
113
+ '--disable-setuid-sandbox',
114
+ '--disable-dev-shm-usage',
115
+ '--disable-gpu',
116
+ '--window-size=1920,1080',
117
+ '--window-position=0,0',
118
+ '--start-maximized',
119
+ '--dns-prefetch-disable',
120
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
121
+ ];
122
+ if (!hasProxy) {
123
+ args.push(
124
+ '--enable-features=DnsOverHttps',
125
+ '--dns-over-https-mode=secure',
126
+ '--dns-over-https-templates=https://cloudflare-dns.com/dns-query'
127
+ );
128
+ }
129
+
130
+ const contextOptions = {
131
+ viewport: null,
132
+ userAgent: selectedUA,
133
+ locale: 'en-US',
134
+ timezoneId: 'America/New_York',
135
+ permissions: ['clipboard-read', 'clipboard-write'],
136
+ ...(selection.proxy ? { proxy: selection.proxy } : {})
137
+ };
138
+
139
+ if (statelessExecution) {
140
+ browser = await chromium.launch({ headless: false, args, ...(selection.proxy ? { proxy: selection.proxy } : {}) });
141
+ context = await browser.newContext(contextOptions);
142
+ } else {
143
+ await fs.promises.mkdir(HEADFUL_PROFILE_DIR, { recursive: true });
144
+ // Remove stale lock files left by a previous container/process to prevent launch failure
145
+ for (const lockFile of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
146
+ try { await fs.promises.unlink(path.join(HEADFUL_PROFILE_DIR, lockFile)); } catch { }
147
+ }
148
+ context = await chromium.launchPersistentContext(HEADFUL_PROFILE_DIR, { headless: false, args, ...contextOptions });
149
+ browser = context.browser();
150
+ }
151
+ }
152
+
153
+ const inspectInitFn = () => {
154
+ Object.defineProperty(window, 'open', { writable: true, configurable: true, value: () => null });
155
+ const handleLinkClick = (event) => {
156
+ const path = event.composedPath ? event.composedPath() : [];
157
+ const anchor = path.find(el => el.tagName === 'A');
158
+ if (anchor && anchor.target === '_blank') {
159
+ event.preventDefault();
160
+ return;
161
+ }
162
+ if (event.type === 'auxclick' && event.button === 1 && anchor) {
163
+ event.preventDefault();
164
+ }
165
+ };
166
+ document.addEventListener('click', handleLinkClick, true);
167
+ document.addEventListener('auxclick', handleLinkClick, true);
168
+
169
+ window.__figraniumInspectInit = () => {
170
+ if (window._figraniumInspectHandler) return;
171
+
172
+ const overlay = document.createElement('div');
173
+ overlay.id = 'figranium-inspect-overlay';
174
+ overlay.style.position = 'fixed';
175
+ overlay.style.pointerEvents = 'none';
176
+ overlay.style.zIndex = '2147483646';
177
+ overlay.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
178
+ overlay.style.border = '1px solid rgb(96, 165, 250)';
179
+ overlay.style.boxSizing = 'border-box';
180
+ overlay.style.transition = 'all 0.1s ease';
181
+ overlay.style.display = 'none';
182
+ document.body.appendChild(overlay);
183
+
184
+ const tooltip = document.createElement('div');
185
+ tooltip.id = 'figranium-inspect-tooltip';
186
+ tooltip.style.position = 'fixed';
187
+ tooltip.style.pointerEvents = 'none';
188
+ tooltip.style.zIndex = '2147483647';
189
+ tooltip.style.backgroundColor = '#1e293b';
190
+ tooltip.style.color = '#f8fafc';
191
+ tooltip.style.padding = '4px 8px';
192
+ tooltip.style.borderRadius = '4px';
193
+ tooltip.style.fontSize = '12px';
194
+ tooltip.style.fontFamily = 'monospace';
195
+ tooltip.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)';
196
+ tooltip.style.display = 'none';
197
+ tooltip.style.whiteSpace = 'nowrap';
198
+ tooltip.style.lineHeight = '1.4';
199
+ document.body.appendChild(tooltip);
200
+
201
+ window._figraniumGetSelectors = (el) => {
202
+ const isRandomId = (id) => {
203
+ if (!id) return true;
204
+ // Long numbers, UUIDs, explicit long strings
205
+ if (/\d{4,}/.test(id) || /^[0-9a-f]{8}-/i.test(id) || id.length > 30 || /[0-9]{3,}/.test(id)) return true;
206
+ // Google-style obfuscated classes (e.g. gLFyf, APjFqb) — mixed-case letters that don't follow camelCase/PascalCase (with common acronyms allowed)
207
+ if (/^[a-zA-Z]{4,8}$/.test(id) && /[A-Z]/.test(id) && /[a-z]/.test(id)) {
208
+ const acr = '(?:UI|UX|ID|DB|IO|IP|OS|QA|AI|ML|API|URL|CSS|DOM|RGB|SVG|XML|SQL|SDK|CLI|SSH|DNS|TCP|UDP|HTTP|JSON|HTML)';
209
+ const validCamelCase = new RegExp('^(?:' + acr + '|[A-Z]?[a-z]+)(?:' + acr + '|[A-Z][a-z]+)*$');
210
+ if (!validCamelCase.test(id)) return true;
211
+ }
212
+ // Short mixed-case alphanumeric with digits (e.g. A7sPV, tX61Ub, gL3fY)
213
+ if (id.length <= 10 && /^[a-zA-Z0-9]+$/.test(id) && /[A-Z]/.test(id) && /[a-z]/.test(id) && /[0-9]/.test(id)) return true;
214
+ // Styled-components or CSS modules with hashes like css-1n7jcv, style_module__1xyz
215
+ if (/^css-[a-zA-Z0-9]+/.test(id) || /^sc-[a-zA-Z0-9]+/.test(id) || /_[a-zA-Z0-9]{5,}$/.test(id) || /-[a-zA-Z0-9]{5,}$/.test(id)) return true;
216
+ // Tailwind arbitrary values or very complex utility classes
217
+ if (id.includes('[') || id.includes(']')) return true;
218
+ return false;
219
+ };
220
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
221
+ if (!tag || tag === 'html' || tag === 'body') return [tag];
222
+
223
+ const selectors = new Set();
224
+
225
+ const isUnique = (sel) => {
226
+ try {
227
+ const nodes = document.querySelectorAll(sel);
228
+ return nodes.length === 1 && nodes[0] === el;
229
+ } catch (e) { return false; }
230
+ };
231
+
232
+ const addIfUnique = (sel) => {
233
+ if (isUnique(sel)) selectors.add(sel);
234
+ };
235
+
236
+ // 1. Name & placeholder (highest priority — most human-readable)
237
+ const topAttrs = ['name', 'placeholder'];
238
+ for (const attr of topAttrs) {
239
+ const val = el.getAttribute(attr);
240
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
241
+ addIfUnique(`[${attr}="${val}"]`);
242
+ addIfUnique(`${tag}[${attr}="${val}"]`);
243
+ }
244
+ }
245
+
246
+ // 2. Text content (:has-text very readable)
247
+ if ((tag === 'button' || tag === 'a' || tag === 'span' || tag === 'div' || tag === 'label' || tag === 'li' || tag === 'p' || tag === 'h1' || tag === 'h2' || tag === 'h3') && el.textContent) {
248
+ const text = el.textContent.trim().substring(0, 40);
249
+ if (text && !text.includes('\n') && !text.includes('"') && text.length > 1) {
250
+ const allTags = Array.from(document.querySelectorAll(tag));
251
+ const matches = allTags.filter(t => t.textContent.trim() === text);
252
+ if (matches.length === 1 && matches[0] === el) {
253
+ selectors.add(`${tag}:has-text("${text}")`);
254
+ }
255
+ }
256
+ }
257
+
258
+ // 3. Other semantic attributes
259
+ const semanticAttrs = ['aria-label', 'title', 'alt'];
260
+ for (const attr of semanticAttrs) {
261
+ const val = el.getAttribute(attr);
262
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
263
+ addIfUnique(`[${attr}="${val}"]`);
264
+ addIfUnique(`${tag}[${attr}="${val}"]`);
265
+ }
266
+ }
267
+
268
+ // 4. Data attributes
269
+ const dataAttrs = ['data-testid', 'data-test-id', 'data-qa', 'data-cy'];
270
+ for (const attr of dataAttrs) {
271
+ const val = el.getAttribute(attr);
272
+ if (val) {
273
+ addIfUnique(`[${attr}="${val}"]`);
274
+ addIfUnique(`${tag}[${attr}="${val}"]`);
275
+ }
276
+ }
277
+
278
+ // 5. IDs
279
+ const id = el.id;
280
+ if (id && !isRandomId(id)) {
281
+ addIfUnique(`#${id}`);
282
+ addIfUnique(`${tag}#${id}`);
283
+ }
284
+
285
+ // 6. Other basic attributes
286
+ const otherAttrs = ['type', 'value', 'href', 'src'];
287
+ for (const attr of otherAttrs) {
288
+ const val = el.getAttribute(attr);
289
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n') && !val.startsWith('data:')) {
290
+ addIfUnique(`${tag}[${attr}="${val}"]`);
291
+ }
292
+ }
293
+
294
+ // 7. Classes
295
+ const classes = el.className && typeof el.className === 'string' ?
296
+ el.className.trim().split(/\s+/).filter(c => c && !isRandomId(c)) : [];
297
+ const classStr = classes.length > 0 ? '.' + classes.join('.') : '';
298
+
299
+ if (classStr) {
300
+ addIfUnique(`${tag}${classStr}`);
301
+ if (classes.length === 1) addIfUnique(`${classStr}`);
302
+ if (classes.length > 1) {
303
+ for (let c of classes) addIfUnique(`${tag}.${c}`);
304
+ }
305
+ }
306
+
307
+ addIfUnique(tag);
308
+
309
+ // 7. Structural
310
+ if (el.parentElement) {
311
+ const siblings = Array.from(el.parentElement.children).filter(c => c.tagName === el.tagName);
312
+ const index = siblings.indexOf(el) + 1;
313
+ addIfUnique(`${tag}:nth-of-type(${index})`);
314
+ if (classStr) addIfUnique(`${tag}${classStr}:nth-of-type(${index})`);
315
+ }
316
+
317
+ // 8. Combinations with parents (Basic Path generation fallback)
318
+ if (selectors.size < 3) {
319
+ let path = '';
320
+ let current = el;
321
+ while (current && current !== document.body && current !== document.documentElement) {
322
+ let step = current.tagName.toLowerCase();
323
+
324
+ // add id if good
325
+ if (current.id && !isRandomId(current.id)) {
326
+ step += `#${current.id}`;
327
+ } else {
328
+ // Add nth-of-type if no id and has siblings of same tag
329
+ if (current.parentElement) {
330
+ const sibs = Array.from(current.parentElement.children).filter(c => c.tagName === current.tagName);
331
+ if (sibs.length > 1) step += `:nth-of-type(${sibs.indexOf(current) + 1})`;
332
+ }
333
+ }
334
+
335
+ path = path ? `${step} > ${path}` : step;
336
+ if (isUnique(path)) {
337
+ selectors.add(path);
338
+ break; // Stop as soon as we found a unique path
339
+ }
340
+
341
+ // Try ID anchor
342
+ if (current.id && !isRandomId(current.id) && isUnique(`#${current.id}`)) {
343
+ break; // We anchored on a unique ID
344
+ }
345
+
346
+ current = current.parentElement;
347
+ }
348
+ }
349
+
350
+ return Array.from(selectors).slice(0, 5);
351
+ };
352
+
353
+ window._figraniumInspectHandler = (e) => {
354
+ const element = e.composedPath ? e.composedPath()[0] : e.target;
355
+ if (!element || element === document || element === document.body) {
356
+ overlay.style.display = 'none';
357
+ tooltip.style.display = 'none';
358
+ return;
359
+ }
360
+
361
+ const rect = element.getBoundingClientRect();
362
+ overlay.style.display = 'block';
363
+ overlay.style.top = rect.top + 'px';
364
+ overlay.style.left = rect.left + 'px';
365
+ overlay.style.width = rect.width + 'px';
366
+ overlay.style.height = rect.height + 'px';
367
+
368
+ const selectors = window._figraniumGetSelectors(element);
369
+ tooltip.style.display = 'block';
370
+ tooltip.innerHTML = selectors.map((s, i) => i === 0 ? `<strong>${s}</strong>` : `<span style="opacity:0.7">${s}</span>`).join('<br/>');
371
+
372
+ let tipTop = e.clientY + 15;
373
+ let tipLeft = e.clientX + 15;
374
+
375
+ const tooltipRect = tooltip.getBoundingClientRect();
376
+ if (tipLeft + tooltipRect.width > window.innerWidth) {
377
+ tipLeft = e.clientX - tooltipRect.width - 15;
378
+ }
379
+ if (tipTop + tooltipRect.height > window.innerHeight) {
380
+ tipTop = e.clientY - tooltipRect.height - 15;
381
+ }
382
+
383
+ tooltip.style.top = tipTop + 'px';
384
+ tooltip.style.left = tipLeft + 'px';
385
+ };
386
+
387
+ window._figraniumInspectClickHandler = async (e) => {
388
+ if (!window._figraniumInspectHandler) return;
389
+ e.preventDefault();
390
+ e.stopPropagation();
391
+ e.stopImmediatePropagation();
392
+
393
+ const element = e.composedPath ? e.composedPath()[0] : e.target;
394
+ const selectors = window._figraniumGetSelectors(element);
395
+ const bestSelector = selectors[0] || '';
396
+
397
+ // Push to backend via Playwright binding
398
+ if (window.__figraniumOnElementSelected && selectors.length > 0) {
399
+ try {
400
+ await window.__figraniumOnElementSelected(JSON.stringify(selectors));
401
+ } catch (err) { }
402
+ }
403
+
404
+ try {
405
+ if (bestSelector && navigator.clipboard && navigator.clipboard.writeText) {
406
+ await navigator.clipboard.writeText(bestSelector);
407
+ }
408
+ } catch (err) { }
409
+ };
410
+
411
+ document.addEventListener('mousemove', window._figraniumInspectHandler, true);
412
+ document.addEventListener('click', window._figraniumInspectClickHandler, true);
413
+ };
414
+
415
+ window.__figraniumInspectDestroy = () => {
416
+ const overlay = document.getElementById('figranium-inspect-overlay');
417
+ if (overlay) overlay.remove();
418
+ const tooltip = document.getElementById('figranium-inspect-tooltip');
419
+ if (tooltip) tooltip.remove();
420
+ if (window._figraniumInspectHandler) {
421
+ document.removeEventListener('mousemove', window._figraniumInspectHandler, true);
422
+ delete window._figraniumInspectHandler;
423
+ }
424
+ if (window._figraniumInspectClickHandler) {
425
+ document.removeEventListener('click', window._figraniumInspectClickHandler, true);
426
+ delete window._figraniumInspectClickHandler;
427
+ }
428
+ };
429
+
430
+ window.addEventListener('DOMContentLoaded', async () => {
431
+ if (window.__figraniumIsInspectEnabled) {
432
+ const enabled = await window.__figraniumIsInspectEnabled();
433
+ if (enabled) {
434
+ window.__figraniumInspectInit();
435
+ }
436
+ }
437
+ });
438
+ };
439
+
440
+ await setupNavigationProtection(context);
441
+ await context.addInitScript(inspectInitFn);
442
+
443
+ await context.exposeBinding('__figraniumIsInspectEnabled', () => {
444
+ return activeSession ? !!activeSession.inspectModeEnabled : false;
445
+ });
446
+
447
+ await context.exposeBinding('__figraniumOnElementSelected', (source, selector) => {
448
+ headfulEventEmitter.emit('selectorSelected', selector);
449
+ });
450
+
451
+ if (!page) {
452
+ // Persistent context auto-creates a blank page; reuse it or open a new one
453
+ const existingPages = context.pages();
454
+ page = existingPages.length > 0 ? existingPages[0] : await context.newPage();
455
+ try {
456
+ const cdp = await context.newCDPSession(page);
457
+ const { windowId } = await cdp.send('Browser.getWindowForTarget');
458
+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
459
+ } catch (e) { }
460
+ } else {
461
+ try { await page.evaluate(inspectInitFn); } catch (e) { }
462
+ try {
463
+ await page.evaluate(() => {
464
+ if (window.__figraniumInspectInit) window.__figraniumInspectInit();
465
+ });
466
+ } catch (e) { }
467
+ }
468
+
469
+ const attachPageTracking = (trackedPage) => {
470
+ if (!trackedPage) return;
471
+
472
+ trackedPage.on('popup', (popup) => {
473
+ setActiveHeadfulPage(popup);
474
+ attachPageTracking(popup);
475
+ });
476
+
477
+ trackedPage.on('close', () => {
478
+ if (!activeSession || activeSession.page !== trackedPage) return;
479
+ const remainingPage = activeSession.context?.pages().find((candidate) => candidate !== trackedPage && !candidate.isClosed());
480
+ if (remainingPage) {
481
+ setActiveHeadfulPage(remainingPage);
482
+ }
483
+ });
484
+ };
485
+
486
+ context.on('page', (newPage) => {
487
+ setActiveHeadfulPage(newPage);
488
+ attachPageTracking(newPage);
489
+ });
490
+ attachPageTracking(page);
491
+
492
+ if (!navigated && url) {
493
+ await page.goto(url).catch(() => { });
494
+ }
495
+
496
+ const syncInterval = statelessExecution ? null : setInterval(() => {
497
+ if (activeSession && activeSession.context) {
498
+ saveHeadfulStorageState(activeSession.context).catch(() => {});
499
+ }
500
+ }, 30000);
501
+ activeSession = { browser, context, page, status: 'running', startedAt: activeSession.startedAt, inspectModeEnabled: activeSession.inspectModeEnabled, statelessExecution, interval: syncInterval };
502
+
503
+ const responseData = {
504
+ message: 'Headful session started.',
505
+ userAgentUsed: selectedUA
506
+ };
507
+
508
+ if (res) {
509
+ res.json(responseData);
510
+ }
511
+
512
+ if (browser) {
513
+ await new Promise((resolve) => browser.once('disconnected', resolve));
514
+ } else {
515
+ // Persistent context: context.browser() returns null; wait for context close instead
516
+ await new Promise((resolve) => context.once('close', resolve));
517
+ }
518
+ if (syncInterval) clearInterval(syncInterval);
519
+ if (!statelessExecution && context) {
520
+ await saveHeadfulStorageState(context).catch(() => {});
521
+ }
522
+ activeSession = null;
523
+ return responseData;
524
+ } catch (error) {
525
+ if (browser) await browser.close();
526
+ else if (context) await context.close().catch(() => {});
527
+ activeSession = null;
528
+ throw error;
529
+ }
530
+ }
531
+
532
+ async function handleHeadful(req, res) {
533
+ await headfulMutex.lock();
534
+ try {
535
+ const data = { ...req.body, ...req.query };
536
+ await runHeadful(data, { res });
537
+ } catch (error) {
538
+ const message = String(error && error.message ? error.message : error);
539
+ const displayUnavailable = /missing x server|\$display|platform failed to initialize/i.test(message);
540
+ if (!res.headersSent && displayUnavailable) {
541
+ return res.status(409).json({ error: 'HEADFUL_DISPLAY_UNAVAILABLE', details: message });
542
+ }
543
+ if (!res.headersSent) {
544
+ res.status(500).json({ error: 'Failed to start headful session', details: message });
545
+ }
546
+ } finally {
547
+ headfulMutex.unlock();
548
+ }
549
+ }
550
+
551
+ async function stopHeadful(req, res) {
552
+ if (!activeSession) {
553
+ return res.status(200).json({ message: 'No active headful session.' });
554
+ }
555
+
556
+ await teardownActiveSession();
557
+ if (res) res.json({ message: 'Headful session stopped.' });
558
+ }
559
+
560
+ async function toggleInspectMode(req, res) {
561
+ if (!activeSession || !activeSession.context) {
562
+ return res.status(400).json({ error: 'No active headful session.' });
563
+ }
564
+ const enabled = req.body.enabled === true || req.body.enabled === 'true';
565
+ activeSession.inspectModeEnabled = enabled;
566
+
567
+ try {
568
+ const pages = activeSession.context.pages();
569
+ for (const page of pages) {
570
+ await page.evaluate((enabled) => {
571
+ if (enabled) {
572
+ if (window.__figraniumInspectInit) window.__figraniumInspectInit();
573
+ } else {
574
+ if (window.__figraniumInspectDestroy) window.__figraniumInspectDestroy();
575
+ }
576
+ }, enabled);
577
+ }
578
+ res.json({ message: `Inspect mode ${enabled ? 'enabled' : 'disabled'}` });
579
+ } catch (error) {
580
+ res.status(500).json({ error: 'Failed to toggle inspect mode', details: String(error) });
581
+ }
582
+ }
583
+
584
+ module.exports = { runHeadful, handleHeadful, stopHeadful, toggleInspectMode, headfulEventEmitter };