@web-auto/camo 0.2.0 → 0.2.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.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +586 -586
  3. package/bin/browser-service.mjs +11 -11
  4. package/bin/camo.mjs +22 -22
  5. package/package.json +48 -48
  6. package/scripts/build.mjs +19 -19
  7. package/scripts/bump-version.mjs +34 -34
  8. package/scripts/check-file-size.mjs +80 -80
  9. package/scripts/file-size-policy.json +12 -2
  10. package/scripts/install.mjs +76 -76
  11. package/scripts/release.sh +54 -54
  12. package/src/autoscript/action-providers/index.mjs +6 -6
  13. package/src/autoscript/impact-engine.mjs +78 -78
  14. package/src/autoscript/runtime.mjs +1017 -1017
  15. package/src/autoscript/schema.mjs +376 -376
  16. package/src/cli.mjs +405 -405
  17. package/src/commands/attach.mjs +141 -141
  18. package/src/commands/autoscript.mjs +1011 -1011
  19. package/src/commands/browser.mjs +1255 -1255
  20. package/src/commands/container.mjs +401 -401
  21. package/src/commands/cookies.mjs +69 -69
  22. package/src/commands/create.mjs +98 -98
  23. package/src/commands/devtools.mjs +349 -349
  24. package/src/commands/events.mjs +152 -152
  25. package/src/commands/highlight-mode.mjs +24 -24
  26. package/src/commands/init.mjs +68 -68
  27. package/src/commands/lifecycle.mjs +275 -275
  28. package/src/commands/mouse.mjs +45 -45
  29. package/src/commands/profile.mjs +46 -46
  30. package/src/commands/record.mjs +115 -115
  31. package/src/commands/system.mjs +14 -14
  32. package/src/commands/window.mjs +123 -123
  33. package/src/container/change-notifier.mjs +362 -362
  34. package/src/container/element-filter.mjs +143 -143
  35. package/src/container/index.mjs +3 -3
  36. package/src/container/runtime-core/checkpoint.mjs +209 -209
  37. package/src/container/runtime-core/index.mjs +21 -21
  38. package/src/container/runtime-core/operations/index.mjs +774 -774
  39. package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
  40. package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
  41. package/src/container/runtime-core/operations/viewport.mjs +189 -189
  42. package/src/container/runtime-core/search.mjs +190 -190
  43. package/src/container/runtime-core/subscription.mjs +224 -224
  44. package/src/container/runtime-core/utils.mjs +94 -94
  45. package/src/container/runtime-core/validation.mjs +127 -127
  46. package/src/container/runtime-core.mjs +1 -1
  47. package/src/container/subscription-registry.mjs +459 -459
  48. package/src/core/actions.mjs +561 -561
  49. package/src/core/browser.mjs +266 -266
  50. package/src/core/index.mjs +52 -52
  51. package/src/core/utils.mjs +91 -91
  52. package/src/events/daemon-entry.mjs +33 -33
  53. package/src/events/daemon.mjs +80 -80
  54. package/src/events/progress-log.mjs +109 -109
  55. package/src/events/ws-server.mjs +239 -239
  56. package/src/lib/client.mjs +200 -200
  57. package/src/lifecycle/cleanup.mjs +83 -83
  58. package/src/lifecycle/lock.mjs +126 -126
  59. package/src/lifecycle/session-registry.mjs +279 -279
  60. package/src/lifecycle/session-view.mjs +76 -76
  61. package/src/lifecycle/session-watchdog.mjs +281 -281
  62. package/src/services/browser-service/index.js +671 -671
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -304
  65. package/src/services/browser-service/internal/ElementRegistry.js +60 -60
  66. package/src/services/browser-service/internal/ProfileLock.js +84 -84
  67. package/src/services/browser-service/internal/SessionManager.js +184 -184
  68. package/src/services/browser-service/internal/SessionManager.test.js +39 -39
  69. package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
  70. package/src/services/browser-service/internal/browser-session/input-ops.js +222 -222
  71. package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
  72. package/src/services/browser-service/internal/browser-session/logging.js +46 -46
  73. package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
  74. package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
  75. package/src/services/browser-service/internal/browser-session/page-management.js +302 -302
  76. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
  77. package/src/services/browser-service/internal/browser-session/recording.js +198 -198
  78. package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
  79. package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
  80. package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
  81. package/src/services/browser-service/internal/browser-session/types.js +14 -14
  82. package/src/services/browser-service/internal/browser-session/utils.js +95 -95
  83. package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
  84. package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
  85. package/src/services/browser-service/internal/container-matcher.js +851 -851
  86. package/src/services/browser-service/internal/container-registry.js +182 -182
  87. package/src/services/browser-service/internal/engine-manager.js +259 -259
  88. package/src/services/browser-service/internal/fingerprint.js +203 -203
  89. package/src/services/browser-service/internal/heartbeat.js +137 -137
  90. package/src/services/browser-service/internal/logging.js +46 -46
  91. package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
  92. package/src/services/browser-service/internal/pageRuntime.js +28 -28
  93. package/src/services/browser-service/internal/runtimeInjector.js +31 -31
  94. package/src/services/browser-service/internal/service-process-logger.js +140 -140
  95. package/src/services/browser-service/internal/state-bus.js +45 -45
  96. package/src/services/browser-service/internal/storage-paths.js +42 -42
  97. package/src/services/browser-service/internal/ws-server.js +1194 -1194
  98. package/src/services/browser-service/internal/ws-server.test.js +58 -58
  99. package/src/services/browser-service/server.mjs +6 -6
  100. package/src/services/controller/cli-bridge.js +93 -93
  101. package/src/services/controller/container-index.js +50 -50
  102. package/src/services/controller/container-storage.js +36 -36
  103. package/src/services/controller/controller-actions.js +207 -207
  104. package/src/services/controller/controller.js +1138 -1138
  105. package/src/services/controller/selectors.js +54 -54
  106. package/src/services/controller/transport.js +125 -125
  107. package/src/utils/args.mjs +26 -26
  108. package/src/utils/browser-service.mjs +544 -544
  109. package/src/utils/command-log.mjs +64 -64
  110. package/src/utils/config.mjs +214 -214
  111. package/src/utils/fingerprint.mjs +181 -181
  112. package/src/utils/help.mjs +216 -216
  113. package/src/utils/js-policy.mjs +13 -13
  114. package/src/utils/ws-client.mjs +30 -30
@@ -1,544 +1,544 @@
1
- #!/usr/bin/env node
2
- import { execSync, spawn } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import os from 'node:os';
6
- import { createRequire } from 'node:module';
7
- import { fileURLToPath } from 'node:url';
8
- import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
9
- import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
10
- import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
11
- import { buildCommandSenderMeta } from './command-log.mjs';
12
-
13
- const require = createRequire(import.meta.url);
14
- const DEFAULT_API_TIMEOUT_MS = 90000;
15
-
16
- function resolveApiTimeoutMs(options = {}) {
17
- const optionValue = Number(options?.timeoutMs);
18
- if (Number.isFinite(optionValue) && optionValue > 0) {
19
- return Math.max(1000, Math.floor(optionValue));
20
- }
21
- const envValue = Number(process.env.CAMO_API_TIMEOUT_MS);
22
- if (Number.isFinite(envValue) && envValue > 0) {
23
- return Math.max(1000, Math.floor(envValue));
24
- }
25
- return DEFAULT_API_TIMEOUT_MS;
26
- }
27
-
28
- function resolveWsUrl() {
29
- const cfg = loadConfig();
30
- const explicit = String(process.env.CAMO_WS_URL || '').trim();
31
- if (explicit) return explicit;
32
- const host = String(process.env.CAMO_WS_HOST || '127.0.0.1').trim() || '127.0.0.1';
33
- const port = Number(process.env.CAMO_WS_PORT || 8765) || 8765;
34
- return `ws://${host}:${port}`;
35
- }
36
-
37
- async function openWs() {
38
- if (typeof WebSocket !== 'function') {
39
- throw new Error('Global WebSocket is unavailable in this Node runtime');
40
- }
41
- const wsUrl = resolveWsUrl();
42
- return new Promise((resolve, reject) => {
43
- const socket = new WebSocket(wsUrl);
44
- const timer = setTimeout(() => {
45
- try { socket.close(); } catch {}
46
- reject(new Error(`WebSocket connect timeout: ${wsUrl}`));
47
- }, 8000);
48
- socket.addEventListener('open', () => {
49
- clearTimeout(timer);
50
- resolve(socket);
51
- });
52
- socket.addEventListener('error', (err) => {
53
- clearTimeout(timer);
54
- reject(new Error(`WebSocket connect failed: ${err?.message || String(err)}`));
55
- });
56
- });
57
- }
58
-
59
- export async function callWS(action, payload = {}, options = {}) {
60
- const timeoutMs = resolveApiTimeoutMs(options);
61
- const socket = await openWs();
62
- const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
63
- const sessionId = String(payload?.profileId || payload?.sessionId || payload?.profile || '').trim();
64
- const message = {
65
- type: 'command',
66
- request_id: requestId,
67
- session_id: sessionId,
68
- data: { command_type: 'dev_command', action, parameters: payload },
69
- };
70
-
71
- return new Promise((resolve, reject) => {
72
- const timer = setTimeout(() => {
73
- try { socket.close(); } catch {}
74
- reject(new Error(`browser-service ws timeout after ${timeoutMs}ms: ${action}`));
75
- }, timeoutMs);
76
-
77
- socket.addEventListener('message', (event) => {
78
- try {
79
- const data = typeof event.data === 'string' ? JSON.parse(event.data) : JSON.parse(String(event.data));
80
- if (data?.type === 'response' && data.request_id === requestId) {
81
- clearTimeout(timer);
82
- try { socket.close(); } catch {}
83
- resolve(data?.data ?? data);
84
- }
85
- } catch (err) {
86
- clearTimeout(timer);
87
- try { socket.close(); } catch {}
88
- reject(err);
89
- }
90
- });
91
-
92
- socket.send(JSON.stringify(message));
93
- });
94
- }
95
-
96
- export function findRepoRootCandidate() {
97
- return null;
98
- }
99
-
100
- function isTimeoutError(error) {
101
- const name = String(error?.name || '').toLowerCase();
102
- const message = String(error?.message || '').toLowerCase();
103
- return (
104
- name.includes('timeout')
105
- || name.includes('abort')
106
- || message.includes('timeout')
107
- || message.includes('timed out')
108
- || message.includes('aborted')
109
- );
110
- }
111
-
112
- function shouldTrackSessionActivity(action, payload) {
113
- const profileId = String(payload?.profileId || '').trim();
114
- if (!profileId) return false;
115
- if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
116
- return true;
117
- }
118
-
119
- export async function callAPI(action, payload = {}, options = {}) {
120
- const timeoutMs = resolveApiTimeoutMs(options);
121
- const senderMeta = buildCommandSenderMeta({
122
- source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
123
- cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
124
- pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
125
- ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
126
- argv: Array.isArray(options?.argv)
127
- ? options.argv
128
- : Array.isArray(payload?.__commandArgv)
129
- ? payload.__commandArgv
130
- : process.argv.slice(),
131
- });
132
- let r;
133
- try {
134
- r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
135
- method: 'POST',
136
- headers: { 'Content-Type': 'application/json' },
137
- body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
138
- signal: AbortSignal.timeout(timeoutMs),
139
- });
140
- } catch (error) {
141
- if (isTimeoutError(error)) {
142
- throw new Error(`browser-service timeout after ${timeoutMs}ms: ${action}`);
143
- }
144
- throw error;
145
- }
146
-
147
- let body;
148
- try {
149
- body = await r.json();
150
- } catch {
151
- const text = await r.text();
152
- throw new Error(`HTTP ${r.status}: ${text}`);
153
- }
154
-
155
- if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
156
- if (shouldTrackSessionActivity(action, payload)) {
157
- touchSessionActivity(payload.profileId, {
158
- lastAction: String(action || '').trim() || null,
159
- lastActionAt: Date.now(),
160
- });
161
- }
162
- return body;
163
- }
164
-
165
- export async function getSessionByProfile(profileId) {
166
- const status = await callAPI('getStatus', {});
167
- if (!profileId) {
168
- return null;
169
- }
170
- const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
171
- const resolved = resolveSessionViewByProfile(profileId, liveSessions);
172
- if (resolved?.live) {
173
- const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
174
- if (activeSession) return activeSession;
175
- }
176
- if (!resolved?.live) {
177
- return null;
178
- }
179
-
180
- // Some browser-service builds do not populate current_url reliably.
181
- // Fallback to page:list only to enrich an already-live profile.
182
- try {
183
- const pagePayload = await callAPI('page:list', { profileId });
184
- const pages = Array.isArray(pagePayload?.pages)
185
- ? pagePayload.pages
186
- : Array.isArray(pagePayload?.data?.pages)
187
- ? pagePayload.data.pages
188
- : [];
189
- if (!pages.length) return null;
190
- const activeIndex = Number(pagePayload?.activeIndex ?? pagePayload?.data?.activeIndex);
191
- const activePage = Number.isFinite(activeIndex)
192
- ? pages.find((page) => Number(page?.index) === activeIndex)
193
- : (pages.find((page) => page?.active) || pages[0]);
194
- return {
195
- profileId,
196
- session_id: profileId,
197
- sessionId: profileId,
198
- current_url: activePage?.url || null,
199
- recoveredFromPages: true,
200
- };
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- export async function getResolvedSessions() {
207
- const status = await callAPI('getStatus', {});
208
- const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
209
- return buildResolvedSessionView(liveSessions);
210
- }
211
-
212
- function buildDomSnapshotScript(maxDepth, maxChildren) {
213
- return `(() => {
214
- const MAX_DEPTH = ${maxDepth};
215
- const MAX_CHILDREN = ${maxChildren};
216
- const viewportWidth = Number(window.innerWidth || 0);
217
- const viewportHeight = Number(window.innerHeight || 0);
218
-
219
- const normalizeRect = (rect) => {
220
- if (!rect) return null;
221
- const left = Number(rect.left ?? rect.x ?? 0);
222
- const top = Number(rect.top ?? rect.y ?? 0);
223
- const width = Number(rect.width ?? 0);
224
- const height = Number(rect.height ?? 0);
225
- return {
226
- left,
227
- top,
228
- right: left + width,
229
- bottom: top + height,
230
- x: left,
231
- y: top,
232
- width,
233
- height,
234
- };
235
- };
236
-
237
- const sanitizeClasses = (el) => {
238
- const classAttr = typeof el.className === 'string'
239
- ? el.className
240
- : (el.getAttribute && el.getAttribute('class')) || '';
241
- return classAttr.split(/\\s+/).filter(Boolean).slice(0, 24);
242
- };
243
-
244
- const collectAttrs = (el) => {
245
- if (!el || !el.getAttribute) return null;
246
- const keys = [
247
- 'href',
248
- 'src',
249
- 'name',
250
- 'type',
251
- 'value',
252
- 'placeholder',
253
- 'role',
254
- 'aria-label',
255
- 'aria-hidden',
256
- 'title',
257
- ];
258
- const attrs = {};
259
- for (const key of keys) {
260
- const value = el.getAttribute(key);
261
- if (value === null || value === undefined || value === '') continue;
262
- attrs[key] = String(value).slice(0, 400);
263
- }
264
- return Object.keys(attrs).length > 0 ? attrs : null;
265
- };
266
-
267
- const inViewport = (rect) => {
268
- if (!rect) return false;
269
- if (rect.width <= 0 || rect.height <= 0) return false;
270
- return (
271
- rect.right > 0
272
- && rect.bottom > 0
273
- && rect.left < viewportWidth
274
- && rect.top < viewportHeight
275
- );
276
- };
277
-
278
- const isRendered = (el) => {
279
- try {
280
- const style = window.getComputedStyle(el);
281
- if (!style) return false;
282
- if (style.display === 'none') return false;
283
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
284
- const opacity = Number.parseFloat(String(style.opacity || '1'));
285
- if (Number.isFinite(opacity) && opacity <= 0.01) return false;
286
- return true;
287
- } catch {
288
- return false;
289
- }
290
- };
291
-
292
- const clampPoint = (value, max) => {
293
- if (!Number.isFinite(value)) return 0;
294
- if (max <= 1) return 0;
295
- return Math.max(0, Math.min(max - 1, value));
296
- };
297
-
298
- const hitTestVisible = (el, rect) => {
299
- if (!rect || viewportWidth <= 0 || viewportHeight <= 0) return false;
300
- const samplePoints = [
301
- [rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
302
- [rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
303
- [rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
304
- ];
305
- for (const [rawX, rawY] of samplePoints) {
306
- const x = clampPoint(rawX, viewportWidth);
307
- const y = clampPoint(rawY, viewportHeight);
308
- const topEl = document.elementFromPoint(x, y);
309
- if (!topEl) continue;
310
- if (topEl === el) return true;
311
- if (el.contains && el.contains(topEl)) return true;
312
- if (topEl.contains && topEl.contains(el)) return true;
313
- }
314
- return false;
315
- };
316
-
317
- const collect = (el, depth = 0, path = 'root') => {
318
- if (!el || depth > MAX_DEPTH) return null;
319
- const classes = sanitizeClasses(el);
320
- const rect = normalizeRect(el.getBoundingClientRect ? el.getBoundingClientRect() : null);
321
- const tag = String(el.tagName || el.nodeName || '').toLowerCase();
322
- const id = el.id || null;
323
- const text = typeof el.textContent === 'string'
324
- ? el.textContent.replace(/\\s+/g, ' ').trim()
325
- : '';
326
- const selector = tag
327
- ? \`\${tag}\${id ? '#' + id : ''}\${classes.length ? '.' + classes.slice(0, 3).join('.') : ''}\`
328
- : null;
329
-
330
- const node = {
331
- tag,
332
- id,
333
- classes,
334
- selector,
335
- path,
336
- };
337
- const attrs = collectAttrs(el);
338
- if (attrs) node.attrs = attrs;
339
- if (attrs && attrs.href) node.href = attrs.href;
340
- if (rect) node.rect = rect;
341
- if (text) node.textSnippet = text.slice(0, 120);
342
- if (rect) {
343
- const rendered = isRendered(el);
344
- const withinViewport = inViewport(rect);
345
- const visible = rendered && withinViewport && hitTestVisible(el, rect);
346
- node.visible = visible;
347
- } else {
348
- node.visible = false;
349
- }
350
-
351
- const children = Array.from(el.children || []);
352
- if (children.length > 0 && depth < MAX_DEPTH) {
353
- node.children = [];
354
- const limit = Math.min(children.length, MAX_CHILDREN);
355
- for (let i = 0; i < limit; i += 1) {
356
- const child = collect(children[i], depth + 1, \`\${path}/\${i}\`);
357
- if (child) node.children.push(child);
358
- }
359
- }
360
-
361
- return node;
362
- };
363
-
364
- const root = collect(document.body || document.documentElement, 0, 'root');
365
- return {
366
- dom_tree: root,
367
- viewport: {
368
- width: viewportWidth,
369
- height: viewportHeight,
370
- },
371
- };
372
- })()`;
373
- }
374
-
375
- export async function getDomSnapshotByProfile(profileId, options = {}) {
376
- const maxDepth = Math.max(1, Math.min(20, Number(options.maxDepth) || 10));
377
- const maxChildren = Math.max(1, Math.min(500, Number(options.maxChildren) || 120));
378
- const response = await callAPI('evaluate', {
379
- profileId,
380
- script: buildDomSnapshotScript(maxDepth, maxChildren),
381
- });
382
- const payload = response?.result || response || {};
383
- const tree = payload.dom_tree || null;
384
- if (tree && payload.viewport && typeof payload.viewport === 'object') {
385
- tree.__viewport = {
386
- width: Number(payload.viewport.width) || 0,
387
- height: Number(payload.viewport.height) || 0,
388
- };
389
- }
390
- return tree;
391
- }
392
-
393
- export async function getViewportByProfile(profileId) {
394
- const response = await callAPI('evaluate', {
395
- profileId,
396
- script: `(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()`,
397
- });
398
- const viewport = response?.result || response?.viewport || {};
399
- const width = Number(viewport?.width) || 1280;
400
- const height = Number(viewport?.height) || 720;
401
- return { width, height };
402
- }
403
-
404
- export async function checkBrowserService() {
405
- try {
406
- const r = await fetch(`${BROWSER_SERVICE_URL}/health`, { signal: AbortSignal.timeout(2000) });
407
- return r.ok;
408
- } catch {
409
- return false;
410
- }
411
- }
412
-
413
- export function detectCamoufoxPath() {
414
- try {
415
- const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
416
- const out = execSync(cmd, {
417
- encoding: 'utf8',
418
- stdio: ['ignore', 'pipe', 'pipe'],
419
- });
420
- const lines = out.trim().split(/\r?\n/);
421
- for (let i = lines.length - 1; i >= 0; i -= 1) {
422
- const line = lines[i].trim();
423
- if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
424
- }
425
- } catch {
426
- return null;
427
- }
428
- return null;
429
- }
430
-
431
- export function ensureCamoufox() {
432
- if (detectCamoufoxPath()) return;
433
- console.log('Camoufox is not found. Installing...');
434
- execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
435
- if (!detectCamoufoxPath()) {
436
- throw new Error('Camoufox install finished but executable was not detected');
437
- }
438
- console.log('Camoufox installed.');
439
- }
440
-
441
- const CONTROLLER_SERVER_REL = path.join('src', 'services', 'browser-service', 'index.js');
442
-
443
-
444
- function hasControllerServer(root) {
445
- if (!root) return false;
446
- return fs.existsSync(path.join(root, CONTROLLER_SERVER_REL));
447
- }
448
-
449
-
450
-
451
-
452
-
453
- function scanCommonInstallRoots() {
454
- const home = os.homedir();
455
- const appData = String(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'));
456
- const npmPrefix = String(process.env.npm_config_prefix || '').trim();
457
- const nodeModuleRoots = [
458
- path.join(appData, 'npm', 'node_modules'),
459
- path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
460
- npmPrefix ? path.join(npmPrefix, 'node_modules') : '',
461
- npmPrefix ? path.join(npmPrefix, 'lib', 'node_modules') : '',
462
- '/usr/local/lib/node_modules',
463
- '/usr/lib/node_modules',
464
- path.join(home, '.npm-global', 'lib', 'node_modules'),
465
- ].filter(Boolean);
466
-
467
- for (const root of nodeModuleRoots) {
468
- const candidate = path.join(root, '@web-auto', 'camo');
469
- if (hasControllerServer(candidate)) return candidate;
470
- }
471
- return null;
472
- }
473
-
474
-
475
-
476
-
477
-
478
-
479
- export function findInstallRootCandidate() {
480
- const cfg = loadConfig();
481
- const currentDir = path.dirname(fileURLToPath(import.meta.url));
482
- const siblingInScopedNodeModules = path.resolve(currentDir, '..', '..', '..', 'camo');
483
- const candidates = [
484
- process.env.CAMO_INSTALL_DIR,
485
- process.env.CAMO_PACKAGE_ROOT,
486
- process.env.CAMO_REPO_ROOT,
487
- cfg.repoRoot,
488
- siblingInScopedNodeModules,
489
- process.cwd(),
490
- ].filter(Boolean);
491
-
492
- try {
493
- const pkgPath = require.resolve('@web-auto/camo/package.json');
494
- candidates.push(path.dirname(pkgPath));
495
- } catch {
496
- // ignore resolution failures in npx-only environments
497
- }
498
-
499
- const seen = new Set();
500
- for (const raw of candidates) {
501
- const resolved = path.resolve(String(raw));
502
- if (seen.has(resolved)) continue;
503
- seen.add(resolved);
504
- if (hasControllerServer(resolved)) return resolved;
505
- }
506
-
507
- return scanCommonInstallRoots();
508
- }
509
-
510
- export async function ensureBrowserService() {
511
- if (await checkBrowserService()) return;
512
-
513
- const installRoot = findInstallRootCandidate();
514
- if (!installRoot) {
515
- throw new Error(
516
- `Cannot locate browser-service launcher (${CONTROLLER_SERVER_REL}). ` +
517
- 'Ensure @web-auto/camo is installed or set CAMO_INSTALL_DIR.',
518
- );
519
- }
520
- const scriptPath = path.join(installRoot, CONTROLLER_SERVER_REL);
521
- const env = {
522
- ...process.env,
523
- CAMO_REPO_ROOT: String(process.env.CAMO_REPO_ROOT || '').trim() || installRoot,
524
- };
525
- const child = spawn(process.execPath, [scriptPath], {
526
- cwd: installRoot,
527
- detached: true,
528
- stdio: 'ignore',
529
- windowsHide: true,
530
- env,
531
- });
532
- child.unref();
533
- console.log(`Starting browser-service daemon (pid=${child.pid || 'unknown'})...`);
534
-
535
- for (let i = 0; i < 20; i += 1) {
536
- await new Promise((r) => setTimeout(r, 400));
537
- if (await checkBrowserService()) {
538
- console.log('Browser-service is ready.');
539
- return;
540
- }
541
- }
542
-
543
- throw new Error('Browser-service failed to become healthy within timeout');
544
- }
1
+ #!/usr/bin/env node
2
+ import { execSync, spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { createRequire } from 'node:module';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
9
+ import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
10
+ import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
11
+ import { buildCommandSenderMeta } from './command-log.mjs';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const DEFAULT_API_TIMEOUT_MS = 90000;
15
+
16
+ function resolveApiTimeoutMs(options = {}) {
17
+ const optionValue = Number(options?.timeoutMs);
18
+ if (Number.isFinite(optionValue) && optionValue > 0) {
19
+ return Math.max(1000, Math.floor(optionValue));
20
+ }
21
+ const envValue = Number(process.env.CAMO_API_TIMEOUT_MS);
22
+ if (Number.isFinite(envValue) && envValue > 0) {
23
+ return Math.max(1000, Math.floor(envValue));
24
+ }
25
+ return DEFAULT_API_TIMEOUT_MS;
26
+ }
27
+
28
+ function resolveWsUrl() {
29
+ const cfg = loadConfig();
30
+ const explicit = String(process.env.CAMO_WS_URL || '').trim();
31
+ if (explicit) return explicit;
32
+ const host = String(process.env.CAMO_WS_HOST || '127.0.0.1').trim() || '127.0.0.1';
33
+ const port = Number(process.env.CAMO_WS_PORT || 8765) || 8765;
34
+ return `ws://${host}:${port}`;
35
+ }
36
+
37
+ async function openWs() {
38
+ if (typeof WebSocket !== 'function') {
39
+ throw new Error('Global WebSocket is unavailable in this Node runtime');
40
+ }
41
+ const wsUrl = resolveWsUrl();
42
+ return new Promise((resolve, reject) => {
43
+ const socket = new WebSocket(wsUrl);
44
+ const timer = setTimeout(() => {
45
+ try { socket.close(); } catch {}
46
+ reject(new Error(`WebSocket connect timeout: ${wsUrl}`));
47
+ }, 8000);
48
+ socket.addEventListener('open', () => {
49
+ clearTimeout(timer);
50
+ resolve(socket);
51
+ });
52
+ socket.addEventListener('error', (err) => {
53
+ clearTimeout(timer);
54
+ reject(new Error(`WebSocket connect failed: ${err?.message || String(err)}`));
55
+ });
56
+ });
57
+ }
58
+
59
+ export async function callWS(action, payload = {}, options = {}) {
60
+ const timeoutMs = resolveApiTimeoutMs(options);
61
+ const socket = await openWs();
62
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
63
+ const sessionId = String(payload?.profileId || payload?.sessionId || payload?.profile || '').trim();
64
+ const message = {
65
+ type: 'command',
66
+ request_id: requestId,
67
+ session_id: sessionId,
68
+ data: { command_type: 'dev_command', action, parameters: payload },
69
+ };
70
+
71
+ return new Promise((resolve, reject) => {
72
+ const timer = setTimeout(() => {
73
+ try { socket.close(); } catch {}
74
+ reject(new Error(`browser-service ws timeout after ${timeoutMs}ms: ${action}`));
75
+ }, timeoutMs);
76
+
77
+ socket.addEventListener('message', (event) => {
78
+ try {
79
+ const data = typeof event.data === 'string' ? JSON.parse(event.data) : JSON.parse(String(event.data));
80
+ if (data?.type === 'response' && data.request_id === requestId) {
81
+ clearTimeout(timer);
82
+ try { socket.close(); } catch {}
83
+ resolve(data?.data ?? data);
84
+ }
85
+ } catch (err) {
86
+ clearTimeout(timer);
87
+ try { socket.close(); } catch {}
88
+ reject(err);
89
+ }
90
+ });
91
+
92
+ socket.send(JSON.stringify(message));
93
+ });
94
+ }
95
+
96
+ export function findRepoRootCandidate() {
97
+ return null;
98
+ }
99
+
100
+ function isTimeoutError(error) {
101
+ const name = String(error?.name || '').toLowerCase();
102
+ const message = String(error?.message || '').toLowerCase();
103
+ return (
104
+ name.includes('timeout')
105
+ || name.includes('abort')
106
+ || message.includes('timeout')
107
+ || message.includes('timed out')
108
+ || message.includes('aborted')
109
+ );
110
+ }
111
+
112
+ function shouldTrackSessionActivity(action, payload) {
113
+ const profileId = String(payload?.profileId || '').trim();
114
+ if (!profileId) return false;
115
+ if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
116
+ return true;
117
+ }
118
+
119
+ export async function callAPI(action, payload = {}, options = {}) {
120
+ const timeoutMs = resolveApiTimeoutMs(options);
121
+ const senderMeta = buildCommandSenderMeta({
122
+ source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
123
+ cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
124
+ pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
125
+ ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
126
+ argv: Array.isArray(options?.argv)
127
+ ? options.argv
128
+ : Array.isArray(payload?.__commandArgv)
129
+ ? payload.__commandArgv
130
+ : process.argv.slice(),
131
+ });
132
+ let r;
133
+ try {
134
+ r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
138
+ signal: AbortSignal.timeout(timeoutMs),
139
+ });
140
+ } catch (error) {
141
+ if (isTimeoutError(error)) {
142
+ throw new Error(`browser-service timeout after ${timeoutMs}ms: ${action}`);
143
+ }
144
+ throw error;
145
+ }
146
+
147
+ let body;
148
+ try {
149
+ body = await r.json();
150
+ } catch {
151
+ const text = await r.text();
152
+ throw new Error(`HTTP ${r.status}: ${text}`);
153
+ }
154
+
155
+ if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
156
+ if (shouldTrackSessionActivity(action, payload)) {
157
+ touchSessionActivity(payload.profileId, {
158
+ lastAction: String(action || '').trim() || null,
159
+ lastActionAt: Date.now(),
160
+ });
161
+ }
162
+ return body;
163
+ }
164
+
165
+ export async function getSessionByProfile(profileId) {
166
+ const status = await callAPI('getStatus', {});
167
+ if (!profileId) {
168
+ return null;
169
+ }
170
+ const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
171
+ const resolved = resolveSessionViewByProfile(profileId, liveSessions);
172
+ if (resolved?.live) {
173
+ const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
174
+ if (activeSession) return activeSession;
175
+ }
176
+ if (!resolved?.live) {
177
+ return null;
178
+ }
179
+
180
+ // Some browser-service builds do not populate current_url reliably.
181
+ // Fallback to page:list only to enrich an already-live profile.
182
+ try {
183
+ const pagePayload = await callAPI('page:list', { profileId });
184
+ const pages = Array.isArray(pagePayload?.pages)
185
+ ? pagePayload.pages
186
+ : Array.isArray(pagePayload?.data?.pages)
187
+ ? pagePayload.data.pages
188
+ : [];
189
+ if (!pages.length) return null;
190
+ const activeIndex = Number(pagePayload?.activeIndex ?? pagePayload?.data?.activeIndex);
191
+ const activePage = Number.isFinite(activeIndex)
192
+ ? pages.find((page) => Number(page?.index) === activeIndex)
193
+ : (pages.find((page) => page?.active) || pages[0]);
194
+ return {
195
+ profileId,
196
+ session_id: profileId,
197
+ sessionId: profileId,
198
+ current_url: activePage?.url || null,
199
+ recoveredFromPages: true,
200
+ };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ export async function getResolvedSessions() {
207
+ const status = await callAPI('getStatus', {});
208
+ const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
209
+ return buildResolvedSessionView(liveSessions);
210
+ }
211
+
212
+ function buildDomSnapshotScript(maxDepth, maxChildren) {
213
+ return `(() => {
214
+ const MAX_DEPTH = ${maxDepth};
215
+ const MAX_CHILDREN = ${maxChildren};
216
+ const viewportWidth = Number(window.innerWidth || 0);
217
+ const viewportHeight = Number(window.innerHeight || 0);
218
+
219
+ const normalizeRect = (rect) => {
220
+ if (!rect) return null;
221
+ const left = Number(rect.left ?? rect.x ?? 0);
222
+ const top = Number(rect.top ?? rect.y ?? 0);
223
+ const width = Number(rect.width ?? 0);
224
+ const height = Number(rect.height ?? 0);
225
+ return {
226
+ left,
227
+ top,
228
+ right: left + width,
229
+ bottom: top + height,
230
+ x: left,
231
+ y: top,
232
+ width,
233
+ height,
234
+ };
235
+ };
236
+
237
+ const sanitizeClasses = (el) => {
238
+ const classAttr = typeof el.className === 'string'
239
+ ? el.className
240
+ : (el.getAttribute && el.getAttribute('class')) || '';
241
+ return classAttr.split(/\\s+/).filter(Boolean).slice(0, 24);
242
+ };
243
+
244
+ const collectAttrs = (el) => {
245
+ if (!el || !el.getAttribute) return null;
246
+ const keys = [
247
+ 'href',
248
+ 'src',
249
+ 'name',
250
+ 'type',
251
+ 'value',
252
+ 'placeholder',
253
+ 'role',
254
+ 'aria-label',
255
+ 'aria-hidden',
256
+ 'title',
257
+ ];
258
+ const attrs = {};
259
+ for (const key of keys) {
260
+ const value = el.getAttribute(key);
261
+ if (value === null || value === undefined || value === '') continue;
262
+ attrs[key] = String(value).slice(0, 400);
263
+ }
264
+ return Object.keys(attrs).length > 0 ? attrs : null;
265
+ };
266
+
267
+ const inViewport = (rect) => {
268
+ if (!rect) return false;
269
+ if (rect.width <= 0 || rect.height <= 0) return false;
270
+ return (
271
+ rect.right > 0
272
+ && rect.bottom > 0
273
+ && rect.left < viewportWidth
274
+ && rect.top < viewportHeight
275
+ );
276
+ };
277
+
278
+ const isRendered = (el) => {
279
+ try {
280
+ const style = window.getComputedStyle(el);
281
+ if (!style) return false;
282
+ if (style.display === 'none') return false;
283
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
284
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
285
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
286
+ return true;
287
+ } catch {
288
+ return false;
289
+ }
290
+ };
291
+
292
+ const clampPoint = (value, max) => {
293
+ if (!Number.isFinite(value)) return 0;
294
+ if (max <= 1) return 0;
295
+ return Math.max(0, Math.min(max - 1, value));
296
+ };
297
+
298
+ const hitTestVisible = (el, rect) => {
299
+ if (!rect || viewportWidth <= 0 || viewportHeight <= 0) return false;
300
+ const samplePoints = [
301
+ [rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
302
+ [rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
303
+ [rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
304
+ ];
305
+ for (const [rawX, rawY] of samplePoints) {
306
+ const x = clampPoint(rawX, viewportWidth);
307
+ const y = clampPoint(rawY, viewportHeight);
308
+ const topEl = document.elementFromPoint(x, y);
309
+ if (!topEl) continue;
310
+ if (topEl === el) return true;
311
+ if (el.contains && el.contains(topEl)) return true;
312
+ if (topEl.contains && topEl.contains(el)) return true;
313
+ }
314
+ return false;
315
+ };
316
+
317
+ const collect = (el, depth = 0, path = 'root') => {
318
+ if (!el || depth > MAX_DEPTH) return null;
319
+ const classes = sanitizeClasses(el);
320
+ const rect = normalizeRect(el.getBoundingClientRect ? el.getBoundingClientRect() : null);
321
+ const tag = String(el.tagName || el.nodeName || '').toLowerCase();
322
+ const id = el.id || null;
323
+ const text = typeof el.textContent === 'string'
324
+ ? el.textContent.replace(/\\s+/g, ' ').trim()
325
+ : '';
326
+ const selector = tag
327
+ ? \`\${tag}\${id ? '#' + id : ''}\${classes.length ? '.' + classes.slice(0, 3).join('.') : ''}\`
328
+ : null;
329
+
330
+ const node = {
331
+ tag,
332
+ id,
333
+ classes,
334
+ selector,
335
+ path,
336
+ };
337
+ const attrs = collectAttrs(el);
338
+ if (attrs) node.attrs = attrs;
339
+ if (attrs && attrs.href) node.href = attrs.href;
340
+ if (rect) node.rect = rect;
341
+ if (text) node.textSnippet = text.slice(0, 120);
342
+ if (rect) {
343
+ const rendered = isRendered(el);
344
+ const withinViewport = inViewport(rect);
345
+ const visible = rendered && withinViewport && hitTestVisible(el, rect);
346
+ node.visible = visible;
347
+ } else {
348
+ node.visible = false;
349
+ }
350
+
351
+ const children = Array.from(el.children || []);
352
+ if (children.length > 0 && depth < MAX_DEPTH) {
353
+ node.children = [];
354
+ const limit = Math.min(children.length, MAX_CHILDREN);
355
+ for (let i = 0; i < limit; i += 1) {
356
+ const child = collect(children[i], depth + 1, \`\${path}/\${i}\`);
357
+ if (child) node.children.push(child);
358
+ }
359
+ }
360
+
361
+ return node;
362
+ };
363
+
364
+ const root = collect(document.body || document.documentElement, 0, 'root');
365
+ return {
366
+ dom_tree: root,
367
+ viewport: {
368
+ width: viewportWidth,
369
+ height: viewportHeight,
370
+ },
371
+ };
372
+ })()`;
373
+ }
374
+
375
+ export async function getDomSnapshotByProfile(profileId, options = {}) {
376
+ const maxDepth = Math.max(1, Math.min(20, Number(options.maxDepth) || 10));
377
+ const maxChildren = Math.max(1, Math.min(500, Number(options.maxChildren) || 120));
378
+ const response = await callAPI('evaluate', {
379
+ profileId,
380
+ script: buildDomSnapshotScript(maxDepth, maxChildren),
381
+ });
382
+ const payload = response?.result || response || {};
383
+ const tree = payload.dom_tree || null;
384
+ if (tree && payload.viewport && typeof payload.viewport === 'object') {
385
+ tree.__viewport = {
386
+ width: Number(payload.viewport.width) || 0,
387
+ height: Number(payload.viewport.height) || 0,
388
+ };
389
+ }
390
+ return tree;
391
+ }
392
+
393
+ export async function getViewportByProfile(profileId) {
394
+ const response = await callAPI('evaluate', {
395
+ profileId,
396
+ script: `(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()`,
397
+ });
398
+ const viewport = response?.result || response?.viewport || {};
399
+ const width = Number(viewport?.width) || 1280;
400
+ const height = Number(viewport?.height) || 720;
401
+ return { width, height };
402
+ }
403
+
404
+ export async function checkBrowserService() {
405
+ try {
406
+ const r = await fetch(`${BROWSER_SERVICE_URL}/health`, { signal: AbortSignal.timeout(2000) });
407
+ return r.ok;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ export function detectCamoufoxPath() {
414
+ try {
415
+ const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
416
+ const out = execSync(cmd, {
417
+ encoding: 'utf8',
418
+ stdio: ['ignore', 'pipe', 'pipe'],
419
+ });
420
+ const lines = out.trim().split(/\r?\n/);
421
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
422
+ const line = lines[i].trim();
423
+ if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
424
+ }
425
+ } catch {
426
+ return null;
427
+ }
428
+ return null;
429
+ }
430
+
431
+ export function ensureCamoufox() {
432
+ if (detectCamoufoxPath()) return;
433
+ console.log('Camoufox is not found. Installing...');
434
+ execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
435
+ if (!detectCamoufoxPath()) {
436
+ throw new Error('Camoufox install finished but executable was not detected');
437
+ }
438
+ console.log('Camoufox installed.');
439
+ }
440
+
441
+ const CONTROLLER_SERVER_REL = path.join('src', 'services', 'browser-service', 'index.js');
442
+
443
+
444
+ function hasControllerServer(root) {
445
+ if (!root) return false;
446
+ return fs.existsSync(path.join(root, CONTROLLER_SERVER_REL));
447
+ }
448
+
449
+
450
+
451
+
452
+
453
+ function scanCommonInstallRoots() {
454
+ const home = os.homedir();
455
+ const appData = String(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'));
456
+ const npmPrefix = String(process.env.npm_config_prefix || '').trim();
457
+ const nodeModuleRoots = [
458
+ path.join(appData, 'npm', 'node_modules'),
459
+ path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
460
+ npmPrefix ? path.join(npmPrefix, 'node_modules') : '',
461
+ npmPrefix ? path.join(npmPrefix, 'lib', 'node_modules') : '',
462
+ '/usr/local/lib/node_modules',
463
+ '/usr/lib/node_modules',
464
+ path.join(home, '.npm-global', 'lib', 'node_modules'),
465
+ ].filter(Boolean);
466
+
467
+ for (const root of nodeModuleRoots) {
468
+ const candidate = path.join(root, '@web-auto', 'camo');
469
+ if (hasControllerServer(candidate)) return candidate;
470
+ }
471
+ return null;
472
+ }
473
+
474
+
475
+
476
+
477
+
478
+
479
+ export function findInstallRootCandidate() {
480
+ const cfg = loadConfig();
481
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
482
+ const siblingInScopedNodeModules = path.resolve(currentDir, '..', '..', '..', 'camo');
483
+ const candidates = [
484
+ process.env.CAMO_INSTALL_DIR,
485
+ process.env.CAMO_PACKAGE_ROOT,
486
+ process.env.CAMO_REPO_ROOT,
487
+ cfg.repoRoot,
488
+ siblingInScopedNodeModules,
489
+ process.cwd(),
490
+ ].filter(Boolean);
491
+
492
+ try {
493
+ const pkgPath = require.resolve('@web-auto/camo/package.json');
494
+ candidates.push(path.dirname(pkgPath));
495
+ } catch {
496
+ // ignore resolution failures in npx-only environments
497
+ }
498
+
499
+ const seen = new Set();
500
+ for (const raw of candidates) {
501
+ const resolved = path.resolve(String(raw));
502
+ if (seen.has(resolved)) continue;
503
+ seen.add(resolved);
504
+ if (hasControllerServer(resolved)) return resolved;
505
+ }
506
+
507
+ return scanCommonInstallRoots();
508
+ }
509
+
510
+ export async function ensureBrowserService() {
511
+ if (await checkBrowserService()) return;
512
+
513
+ const installRoot = findInstallRootCandidate();
514
+ if (!installRoot) {
515
+ throw new Error(
516
+ `Cannot locate browser-service launcher (${CONTROLLER_SERVER_REL}). ` +
517
+ 'Ensure @web-auto/camo is installed or set CAMO_INSTALL_DIR.',
518
+ );
519
+ }
520
+ const scriptPath = path.join(installRoot, CONTROLLER_SERVER_REL);
521
+ const env = {
522
+ ...process.env,
523
+ CAMO_REPO_ROOT: String(process.env.CAMO_REPO_ROOT || '').trim() || installRoot,
524
+ };
525
+ const child = spawn(process.execPath, [scriptPath], {
526
+ cwd: installRoot,
527
+ detached: true,
528
+ stdio: 'ignore',
529
+ windowsHide: true,
530
+ env,
531
+ });
532
+ child.unref();
533
+ console.log(`Starting browser-service daemon (pid=${child.pid || 'unknown'})...`);
534
+
535
+ for (let i = 0; i < 20; i += 1) {
536
+ await new Promise((r) => setTimeout(r, 400));
537
+ if (await checkBrowserService()) {
538
+ console.log('Browser-service is ready.');
539
+ return;
540
+ }
541
+ }
542
+
543
+ throw new Error('Browser-service failed to become healthy within timeout');
544
+ }