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