@web-auto/camo 0.1.26 → 0.2.1

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 (117) 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 -1257
  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 -184
  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 -674
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -336
  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 -219
  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 -336
  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
  115. package/src/container/runtime-core/operations/tab-pool.mjs.bak +0 -762
  116. package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +0 -762
  117. package/src/services/browser-service/index.js.bak +0 -671
@@ -1,774 +1,774 @@
1
- import { callAPI, getDomSnapshotByProfile } from '../../../utils/browser-service.mjs';
2
- import { isJsExecutionEnabled } from '../../../utils/js-policy.mjs';
3
- import { executeTabPoolOperation } from './tab-pool.mjs';
4
- import { executeViewportOperation } from './viewport.mjs';
5
- import {
6
- asErrorPayload,
7
- buildSelectorCheck,
8
- ensureActiveSession,
9
- extractPageList,
10
- getCurrentUrl,
11
- maybeSelector,
12
- normalizeArray,
13
- } from '../utils.mjs';
14
-
15
- const TAB_ACTIONS = new Set([
16
- 'ensure_tab_pool',
17
- 'tab_pool_switch_next',
18
- 'tab_pool_switch_slot',
19
- ]);
20
-
21
- const VIEWPORT_ACTIONS = new Set([
22
- 'sync_window_viewport',
23
- 'get_current_url',
24
- ]);
25
-
26
- const DEFAULT_MODAL_SELECTORS = [
27
- '[aria-modal="true"]',
28
- '[role="dialog"]',
29
- '.modal',
30
- '.dialog',
31
- '.note-detail-mask',
32
- '.note-detail-page',
33
- '.note-detail-dialog',
34
- ];
35
- function resolveFilterMode(input) {
36
- const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
37
- if (!text) return 'strict';
38
- if (text === 'legacy') return 'legacy';
39
- return 'strict';
40
- }
41
-
42
- async function executeExternalOperationIfAny({
43
- profileId,
44
- action,
45
- params,
46
- operation,
47
- context,
48
- }) {
49
- const executor = context?.executeExternalOperation;
50
- if (typeof executor !== 'function') return null;
51
- const result = await executor({
52
- profileId,
53
- action,
54
- params,
55
- operation,
56
- context,
57
- });
58
- if (result === null || result === undefined) return null;
59
- if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
60
- return result;
61
- }
62
- return asErrorPayload('OPERATION_FAILED', 'external operation executor returned invalid payload', {
63
- action,
64
- resultType: typeof result,
65
- });
66
- }
67
-
68
- async function flashOperationViewport(profileId, params = {}) {
69
- if (!isJsExecutionEnabled()) return;
70
- if (params.highlight === false) return;
71
- try {
72
- await callAPI('evaluate', {
73
- profileId,
74
- script: `(() => {
75
- const root = document.documentElement;
76
- if (!(root instanceof HTMLElement)) return { ok: false };
77
- const prevShadow = root.style.boxShadow;
78
- const prevTransition = root.style.transition;
79
- root.style.transition = 'box-shadow 80ms ease';
80
- root.style.boxShadow = 'inset 0 0 0 3px #ff7a00';
81
- setTimeout(() => {
82
- root.style.boxShadow = prevShadow;
83
- root.style.transition = prevTransition;
84
- }, 260);
85
- return { ok: true };
86
- })()`,
87
- });
88
- } catch {
89
- // highlight failure should never block action execution
90
- }
91
- }
92
-
93
- function sleep(ms) {
94
- return new Promise((resolve) => setTimeout(resolve, ms));
95
- }
96
-
97
- async function pageScroll(profileId, deltaY, delayMs = 80) {
98
- const raw = Number(deltaY) || 0;
99
- if (!Number.isFinite(raw) || raw === 0) return;
100
- const key = raw >= 0 ? 'PageDown' : 'PageUp';
101
- const steps = Math.max(1, Math.min(8, Math.round(Math.abs(raw) / 420) || 1));
102
- for (let step = 0; step < steps; step += 1) {
103
- await callAPI('keyboard:press', { profileId, key });
104
- if (delayMs > 0) await sleep(delayMs);
105
- }
106
- }
107
-
108
- function clamp(value, min, max) {
109
- return Math.min(Math.max(value, min), max);
110
- }
111
-
112
- function isTargetFullyInViewport(target, margin = 6) {
113
- const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
114
- const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
115
- if (!rect || !viewport) return true;
116
- const vw = Number(viewport.width || 0);
117
- const vh = Number(viewport.height || 0);
118
- if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
119
- const left = Number(rect.left || 0);
120
- const top = Number(rect.top || 0);
121
- const width = Math.max(0, Number(rect.width || 0));
122
- const height = Math.max(0, Number(rect.height || 0));
123
- const right = left + width;
124
- const bottom = top + height;
125
- const m = Math.max(0, Number(margin) || 0);
126
- return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
127
- }
128
-
129
- function resolveViewportScrollDelta(target, margin = 6) {
130
- const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
131
- const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
132
- if (!rect || !viewport) return { deltaX: 0, deltaY: 0 };
133
- const vw = Number(viewport.width || 0);
134
- const vh = Number(viewport.height || 0);
135
- if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return { deltaX: 0, deltaY: 0 };
136
- const left = Number(rect.left || 0);
137
- const top = Number(rect.top || 0);
138
- const width = Math.max(0, Number(rect.width || 0));
139
- const height = Math.max(0, Number(rect.height || 0));
140
- const right = left + width;
141
- const bottom = top + height;
142
- const m = Math.max(0, Number(margin) || 0);
143
-
144
- let deltaX = 0;
145
- let deltaY = 0;
146
-
147
- if (left < m) {
148
- deltaX = Math.round(left - m);
149
- } else if (right > (vw - m)) {
150
- deltaX = Math.round(right - (vw - m));
151
- }
152
-
153
- if (top < m) {
154
- deltaY = Math.round(top - m);
155
- } else if (bottom > (vh - m)) {
156
- deltaY = Math.round(bottom - (vh - m));
157
- }
158
-
159
- if (Math.abs(deltaY) < 80 && !isTargetFullyInViewport(target, m)) {
160
- deltaY = deltaY >= 0 ? 120 : -120;
161
- }
162
- if (Math.abs(deltaX) < 40 && (left < m || right > (vw - m))) {
163
- deltaX = deltaX >= 0 ? 60 : -60;
164
- }
165
-
166
- return {
167
- deltaX: clamp(deltaX, -900, 900),
168
- deltaY: clamp(deltaY, -900, 900),
169
- };
170
- }
171
-
172
- function normalizeRect(node) {
173
- const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
174
- if (!rect) return null;
175
- const left = Number(rect.left ?? rect.x ?? 0);
176
- const top = Number(rect.top ?? rect.y ?? 0);
177
- const width = Number(rect.width ?? 0);
178
- const height = Number(rect.height ?? 0);
179
- if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) {
180
- return null;
181
- }
182
- if (width <= 0 || height <= 0) return null;
183
- return { left, top, width, height };
184
- }
185
-
186
- function nodeArea(node) {
187
- const rect = normalizeRect(node);
188
- if (!rect) return 0;
189
- return Number(rect.width || 0) * Number(rect.height || 0);
190
- }
191
-
192
- function nodeCenter(node, viewport = null) {
193
- const rect = normalizeRect(node);
194
- const vw = Number(viewport?.width || 0);
195
- const vh = Number(viewport?.height || 0);
196
- if (!rect) return null;
197
- const rawX = rect.left + Math.max(1, rect.width / 2);
198
- const rawY = rect.top + Math.max(1, rect.height / 2);
199
- const centerX = vw > 1
200
- ? clamp(Math.round(rawX), 1, Math.max(1, vw - 1))
201
- : Math.max(1, Math.round(rawX));
202
- const centerY = vh > 1
203
- ? clamp(Math.round(rawY), 1, Math.max(1, vh - 1))
204
- : Math.max(1, Math.round(rawY));
205
- return {
206
- center: { x: centerX, y: centerY },
207
- rawCenter: { x: rawX, y: rawY },
208
- rect,
209
- };
210
- }
211
-
212
- function getSnapshotViewport(snapshot) {
213
- const width = Number(snapshot?.__viewport?.width || 0);
214
- const height = Number(snapshot?.__viewport?.height || 0);
215
- return { width, height };
216
- }
217
-
218
- function isPathWithin(path, parentPath) {
219
- const child = String(path || '').trim();
220
- const parent = String(parentPath || '').trim();
221
- if (!child || !parent) return false;
222
- return child === parent || child.startsWith(`${parent}/`);
223
- }
224
-
225
- function resolveActiveModal(snapshot) {
226
- if (!snapshot) return null;
227
- const rows = [];
228
- for (const selector of DEFAULT_MODAL_SELECTORS) {
229
- const matches = buildSelectorCheck(snapshot, { css: selector, visible: true });
230
- for (const node of matches) {
231
- if (nodeArea(node) <= 1) continue;
232
- rows.push({
233
- selector,
234
- path: String(node.path || ''),
235
- node,
236
- area: nodeArea(node),
237
- });
238
- }
239
- }
240
- rows.sort((a, b) => b.area - a.area);
241
- return rows[0] || null;
242
- }
243
-
244
- async function resolveSelectorTarget(profileId, selector, options = {}) {
245
- const filterMode = resolveFilterMode(options.filterMode);
246
- const strictFilter = filterMode !== 'legacy';
247
- const normalizedSelector = String(selector || '').trim();
248
- const snapshot = await getDomSnapshotByProfile(profileId);
249
- const viewport = getSnapshotViewport(snapshot);
250
- const modal = strictFilter ? resolveActiveModal(snapshot) : null;
251
- const visibleMatches = buildSelectorCheck(snapshot, { css: normalizedSelector, visible: true });
252
- const allMatches = strictFilter
253
- ? visibleMatches
254
- : buildSelectorCheck(snapshot, { css: normalizedSelector, visible: false });
255
- const scopedVisible = modal
256
- ? visibleMatches.filter((item) => isPathWithin(item.path, modal.path))
257
- : visibleMatches;
258
- const scopedAll = modal
259
- ? allMatches.filter((item) => isPathWithin(item.path, modal.path))
260
- : allMatches;
261
- const candidate = strictFilter
262
- ? (scopedVisible[0] || null)
263
- : (scopedVisible[0] || scopedAll[0] || null);
264
- if (!candidate) {
265
- if (modal) {
266
- throw new Error(`Modal focus locked for selector: ${normalizedSelector}`);
267
- }
268
- throw new Error(`Element not found: ${normalizedSelector}`);
269
- }
270
- const center = nodeCenter(candidate, viewport);
271
- if (!center) {
272
- throw new Error(`Element not found: ${normalizedSelector}`);
273
- }
274
- return {
275
- ok: true,
276
- selector: normalizedSelector,
277
- matchedIndex: Math.max(0, scopedAll.indexOf(candidate)),
278
- center: center.center,
279
- rawCenter: center.rawCenter,
280
- rect: center.rect,
281
- viewport,
282
- modalLocked: Boolean(modal),
283
- };
284
- }
285
-
286
- async function scrollTargetIntoViewport(profileId, selector, initialTarget, params = {}, options = {}) {
287
- let target = initialTarget;
288
- const maxSteps = Math.max(0, Math.min(24, Number(params.maxScrollSteps ?? 8) || 8));
289
- const settleMs = Math.max(0, Number(params.scrollSettleMs ?? 140) || 140);
290
- const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
291
- for (let i = 0; i < maxSteps; i += 1) {
292
- if (isTargetFullyInViewport(target, visibilityMargin)) break;
293
- const delta = resolveViewportScrollDelta(target, visibilityMargin);
294
- if (Math.abs(delta.deltaX) < 1 && Math.abs(delta.deltaY) < 1) break;
295
- const deltaY = delta.deltaY !== 0 ? delta.deltaY : (delta.deltaX !== 0 ? delta.deltaX : 0);
296
- await pageScroll(profileId, deltaY);
297
- if (settleMs > 0) await sleep(settleMs);
298
- target = await resolveSelectorTarget(profileId, selector, options);
299
- }
300
- return target;
301
- }
302
-
303
- async function resolveScrollAnchor(profileId, options = {}) {
304
- const filterMode = resolveFilterMode(options.filterMode);
305
- const strictFilter = filterMode !== 'legacy';
306
- const selector = String(options.selector || '').trim();
307
- const snapshot = await getDomSnapshotByProfile(profileId);
308
- const viewport = getSnapshotViewport(snapshot);
309
- const modal = strictFilter ? resolveActiveModal(snapshot) : null;
310
-
311
- if (selector) {
312
- const visibleMatches = buildSelectorCheck(snapshot, { css: selector, visible: true });
313
- const target = visibleMatches[0] || null;
314
- if (target) {
315
- if (modal && !isPathWithin(target.path, modal.path)) {
316
- const modalCenter = nodeCenter(modal.node, viewport);
317
- if (modalCenter) {
318
- return {
319
- ok: true,
320
- source: 'modal',
321
- center: modalCenter.center,
322
- modalLocked: true,
323
- modalSelector: modal.selector,
324
- selectorRejectedByModalLock: true,
325
- };
326
- }
327
- } else {
328
- const targetCenter = nodeCenter(target, viewport);
329
- if (targetCenter) {
330
- return {
331
- ok: true,
332
- source: 'selector',
333
- center: targetCenter.center,
334
- modalLocked: Boolean(modal),
335
- };
336
- }
337
- }
338
- }
339
- }
340
-
341
- if (modal) {
342
- const modalCenter = nodeCenter(modal.node, viewport);
343
- if (modalCenter) {
344
- return {
345
- ok: true,
346
- source: 'modal',
347
- center: modalCenter.center,
348
- modalLocked: true,
349
- modalSelector: modal.selector,
350
- };
351
- }
352
- }
353
-
354
- const width = Number(viewport.width || 0);
355
- const height = Number(viewport.height || 0);
356
- return {
357
- ok: true,
358
- source: 'document',
359
- center: {
360
- x: width > 1 ? Math.round(width / 2) : 1,
361
- y: height > 1 ? Math.round(height / 2) : 1,
362
- },
363
- modalLocked: false,
364
- };
365
- }
366
-
367
- async function executeSelectorOperation({ profileId, action, operation, params, filterMode }) {
368
- const selector = maybeSelector({
369
- profileId,
370
- containerId: params.containerId || operation?.containerId || null,
371
- selector: params.selector || operation?.selector || null,
372
- });
373
- if (!selector) return asErrorPayload('CONTAINER_NOT_FOUND', `${action} requires selector/containerId`);
374
-
375
- let target = await resolveSelectorTarget(profileId, selector, { filterMode });
376
- target = await scrollTargetIntoViewport(profileId, selector, target, params, { filterMode });
377
- const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
378
- const targetFullyVisible = isTargetFullyInViewport(target, visibilityMargin);
379
- if (action === 'click' && !targetFullyVisible) {
380
- return asErrorPayload('TARGET_NOT_FULLY_VISIBLE', 'click target is not fully visible after auto scroll', {
381
- selector,
382
- target,
383
- visibilityMargin,
384
- });
385
- }
386
-
387
- if (action === 'scroll_into_view') {
388
- return {
389
- ok: true,
390
- code: 'OPERATION_DONE',
391
- message: 'scroll_into_view done',
392
- data: { selector, target, targetFullyVisible, visibilityMargin },
393
- };
394
- }
395
-
396
- if (action === 'click') {
397
- const button = String(params.button || 'left').trim() || 'left';
398
- const clicks = Math.max(1, Number(params.clicks ?? 1) || 1);
399
- const delay = Number(params.delay);
400
- const result = await callAPI('mouse:click', {
401
- profileId,
402
- x: target.center.x,
403
- y: target.center.y,
404
- button,
405
- clicks,
406
- ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
407
- });
408
- return {
409
- ok: true,
410
- code: 'OPERATION_DONE',
411
- message: 'click done',
412
- data: { selector, target, result, targetFullyVisible, visibilityMargin },
413
- };
414
- }
415
-
416
- const text = String(params.text ?? params.value ?? '');
417
- await callAPI('mouse:click', {
418
- profileId,
419
- x: target.center.x,
420
- y: target.center.y,
421
- button: 'left',
422
- clicks: 1,
423
- });
424
- const clearBeforeType = params.clear !== false;
425
- if (clearBeforeType) {
426
- await callAPI('keyboard:press', {
427
- profileId,
428
- key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
429
- });
430
- await callAPI('keyboard:press', { profileId, key: 'Backspace' });
431
- }
432
- const delay = Number(params.keyDelayMs ?? params.delay);
433
- await callAPI('keyboard:type', {
434
- profileId,
435
- text,
436
- ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
437
- });
438
- if (params.pressEnter === true) {
439
- await callAPI('keyboard:press', { profileId, key: 'Enter' });
440
- }
441
- return {
442
- ok: true,
443
- code: 'OPERATION_DONE',
444
- message: 'type done',
445
- data: { selector, target, length: text.length },
446
- };
447
- }
448
-
449
- async function executeVerifySubscriptions({ profileId, params }) {
450
- const defaultVisible = params.visible !== false;
451
- const defaultMinCount = Math.max(0, Number(params.minCount ?? 1) || 1);
452
- const selectorItems = normalizeArray(params.subscriptions || params.selectors)
453
- .map((item, idx) => {
454
- if (typeof item === 'string') {
455
- return {
456
- id: `selector_${idx + 1}`,
457
- selector: item,
458
- visible: defaultVisible,
459
- minCount: defaultMinCount,
460
- };
461
- }
462
- if (!item || typeof item !== 'object') return null;
463
- const selector = String(item.selector || '').trim();
464
- if (!selector) return null;
465
- const visible = item.visible !== undefined
466
- ? item.visible !== false
467
- : defaultVisible;
468
- const minCount = Math.max(0, Number(item.minCount ?? defaultMinCount) || defaultMinCount);
469
- return {
470
- id: String(item.id || `selector_${idx + 1}`),
471
- selector,
472
- visible,
473
- minCount,
474
- };
475
- })
476
- .filter(Boolean);
477
- if (selectorItems.length === 0) {
478
- return asErrorPayload('OPERATION_FAILED', 'verify_subscriptions requires params.selectors');
479
- }
480
-
481
- const acrossPages = params.acrossPages === true;
482
- const settleMs = Math.max(0, Number(params.settleMs ?? 280) || 280);
483
- const pageUrlIncludes = normalizeArray(params.pageUrlIncludes)
484
- .map((item) => String(item || '').trim())
485
- .filter(Boolean);
486
- const pageUrlExcludes = normalizeArray(params.pageUrlExcludes)
487
- .map((item) => String(item || '').trim())
488
- .filter(Boolean);
489
- const pageUrlRegex = String(params.pageUrlRegex || '').trim();
490
- const pageUrlNotRegex = String(params.pageUrlNotRegex || '').trim();
491
- const requireMatchedPages = params.requireMatchedPages !== false;
492
-
493
- let includeRegex = null;
494
- if (pageUrlRegex) {
495
- try {
496
- includeRegex = new RegExp(pageUrlRegex);
497
- } catch {
498
- return asErrorPayload('OPERATION_FAILED', `invalid pageUrlRegex: ${pageUrlRegex}`);
499
- }
500
- }
501
- let excludeRegex = null;
502
- if (pageUrlNotRegex) {
503
- try {
504
- excludeRegex = new RegExp(pageUrlNotRegex);
505
- } catch {
506
- return asErrorPayload('OPERATION_FAILED', `invalid pageUrlNotRegex: ${pageUrlNotRegex}`);
507
- }
508
- }
509
-
510
- const hasPageFilter = (
511
- pageUrlIncludes.length > 0
512
- || pageUrlExcludes.length > 0
513
- || Boolean(includeRegex)
514
- || Boolean(excludeRegex)
515
- );
516
-
517
- const shouldVerifyPage = (rawUrl) => {
518
- const url = String(rawUrl || '').trim();
519
- if (pageUrlIncludes.length > 0 && !pageUrlIncludes.some((part) => url.includes(part))) {
520
- return false;
521
- }
522
- if (pageUrlExcludes.length > 0 && pageUrlExcludes.some((part) => url.includes(part))) {
523
- return false;
524
- }
525
- if (includeRegex && !includeRegex.test(url)) {
526
- return false;
527
- }
528
- if (excludeRegex && excludeRegex.test(url)) {
529
- return false;
530
- }
531
- return true;
532
- };
533
-
534
- const collectForCurrentPage = async () => {
535
- const snapshot = await getDomSnapshotByProfile(profileId);
536
- const url = await getCurrentUrl(profileId);
537
- const matches = selectorItems.map((item) => ({
538
- id: item.id,
539
- selector: item.selector,
540
- visible: item.visible,
541
- minCount: item.minCount,
542
- count: buildSelectorCheck(snapshot, {
543
- css: item.selector,
544
- visible: item.visible,
545
- }).length,
546
- }));
547
- return { url, matches };
548
- };
549
-
550
- let pagesResult = [];
551
- let overallOk = true;
552
- let matchedPageCount = 0;
553
- let activePageIndex = null;
554
- if (!acrossPages) {
555
- const current = await collectForCurrentPage();
556
- overallOk = current.matches.every((item) => item.count >= item.minCount);
557
- pagesResult = [{ index: null, ...current }];
558
- } else {
559
- const listed = await callAPI('page:list', { profileId });
560
- const { pages, activeIndex } = extractPageList(listed);
561
- activePageIndex = Number.isFinite(activeIndex) ? activeIndex : null;
562
- for (const page of pages) {
563
- const pageIndex = Number(page.index);
564
- const listedUrl = String(page.url || '');
565
- if (!shouldVerifyPage(listedUrl)) {
566
- pagesResult.push({
567
- index: pageIndex,
568
- url: listedUrl,
569
- skipped: true,
570
- ok: true,
571
- });
572
- continue;
573
- }
574
- if (Number.isFinite(activeIndex) && activeIndex !== pageIndex) {
575
- await callAPI('page:switch', { profileId, index: pageIndex });
576
- if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
577
- }
578
- const current = await collectForCurrentPage();
579
- const pageOk = current.matches.every((item) => item.count >= item.minCount);
580
- overallOk = overallOk && pageOk;
581
- pagesResult.push({ index: pageIndex, ...current, ok: pageOk });
582
- matchedPageCount += 1;
583
- }
584
- if (Number.isFinite(activeIndex)) {
585
- await callAPI('page:switch', { profileId, index: activeIndex });
586
- }
587
- }
588
-
589
- if (acrossPages && hasPageFilter && requireMatchedPages && matchedPageCount === 0) {
590
- const fallback = await collectForCurrentPage();
591
- const fallbackOk = fallback.matches.every((item) => item.count >= item.minCount);
592
- if (fallbackOk) {
593
- matchedPageCount = 1;
594
- overallOk = true;
595
- pagesResult.push({
596
- index: Number.isFinite(activePageIndex) ? activePageIndex : null,
597
- urlMatched: false,
598
- fallback: 'dom_match',
599
- ok: true,
600
- ...fallback,
601
- });
602
- } else {
603
- return asErrorPayload('SUBSCRIPTION_MISMATCH', 'no page matched verify_subscriptions pageUrl filter', {
604
- acrossPages,
605
- pageUrlIncludes,
606
- pageUrlExcludes,
607
- pageUrlRegex: pageUrlRegex || null,
608
- pageUrlNotRegex: pageUrlNotRegex || null,
609
- pages: pagesResult,
610
- fallback,
611
- });
612
- }
613
- }
614
-
615
- if (!overallOk) {
616
- return asErrorPayload('SUBSCRIPTION_MISMATCH', 'subscription selectors missing on one or more pages', {
617
- acrossPages,
618
- matchedPageCount,
619
- pages: pagesResult,
620
- });
621
- }
622
-
623
- return {
624
- ok: true,
625
- code: 'OPERATION_DONE',
626
- message: 'verify_subscriptions done',
627
- data: { acrossPages, matchedPageCount, pages: pagesResult },
628
- };
629
- }
630
-
631
- export async function executeOperation({ profileId, operation, context = {} }) {
632
- try {
633
- const session = await ensureActiveSession(profileId);
634
- const resolvedProfile = session.profileId || profileId;
635
- const action = String(operation?.action || '').trim();
636
- const params = operation?.params || operation?.config || {};
637
- const filterMode = resolveFilterMode(
638
- params.filterMode
639
- || operation?.filterMode
640
- || context?.filterMode
641
- || context?.runtime?.filterMode
642
- || null,
643
- );
644
-
645
- if (!action) {
646
- return asErrorPayload('OPERATION_FAILED', 'operation.action is required');
647
- }
648
-
649
- if (action !== 'wait') {
650
- await flashOperationViewport(resolvedProfile, params);
651
- }
652
-
653
- if (TAB_ACTIONS.has(action)) {
654
- return await executeTabPoolOperation({
655
- profileId: resolvedProfile,
656
- action,
657
- params,
658
- context,
659
- });
660
- }
661
-
662
- if (VIEWPORT_ACTIONS.has(action)) {
663
- return await executeViewportOperation({
664
- profileId: resolvedProfile,
665
- action,
666
- params,
667
- });
668
- }
669
-
670
- if (action === 'goto') {
671
- const url = String(params.url || params.value || '').trim();
672
- if (!url) return asErrorPayload('OPERATION_FAILED', 'goto requires params.url');
673
- const result = await callAPI('goto', { profileId: resolvedProfile, url });
674
- return { ok: true, code: 'OPERATION_DONE', message: 'goto done', data: result };
675
- }
676
-
677
- if (action === 'back') {
678
- const result = await callAPI('page:back', { profileId: resolvedProfile });
679
- return { ok: true, code: 'OPERATION_DONE', message: 'back done', data: result };
680
- }
681
-
682
- if (action === 'list_pages') {
683
- const result = await callAPI('page:list', { profileId: resolvedProfile });
684
- const { pages, activeIndex } = extractPageList(result);
685
- return { ok: true, code: 'OPERATION_DONE', message: 'list_pages done', data: { pages, activeIndex, raw: result } };
686
- }
687
-
688
- if (action === 'new_page') {
689
- const rawUrl = String(params.url || params.value || '').trim();
690
- const payload = rawUrl ? { profileId: resolvedProfile, url: rawUrl } : { profileId: resolvedProfile };
691
- const result = await callAPI('newPage', payload);
692
- return { ok: true, code: 'OPERATION_DONE', message: 'new_page done', data: result };
693
- }
694
-
695
- if (action === 'switch_page') {
696
- const index = Number(params.index ?? params.value);
697
- if (!Number.isFinite(index)) {
698
- return asErrorPayload('OPERATION_FAILED', 'switch_page requires params.index');
699
- }
700
- const result = await callAPI('page:switch', { profileId: resolvedProfile, index });
701
- return { ok: true, code: 'OPERATION_DONE', message: 'switch_page done', data: result };
702
- }
703
-
704
- if (action === 'wait') {
705
- const ms = Math.max(0, Number(params.ms ?? params.value ?? 0));
706
- await new Promise((resolve) => setTimeout(resolve, ms));
707
- return { ok: true, code: 'OPERATION_DONE', message: 'wait done', data: { ms } };
708
- }
709
-
710
- if (action === 'scroll') {
711
- const amount = Math.max(1, Number(params.amount ?? params.value ?? 300) || 300);
712
- const direction = String(params.direction || 'down').toLowerCase();
713
- let deltaX = 0;
714
- let deltaY = amount;
715
- if (direction === 'up') deltaY = -amount;
716
- else if (direction === 'left') {
717
- deltaX = -amount;
718
- deltaY = 0;
719
- } else if (direction === 'right') {
720
- deltaX = amount;
721
- deltaY = 0;
722
- }
723
- const result = await pageScroll(resolvedProfile, deltaY);
724
- return {
725
- ok: true,
726
- code: 'OPERATION_DONE',
727
- message: 'scroll done',
728
- data: { direction, amount, deltaX, deltaY, result },
729
- };
730
- }
731
-
732
- if (action === 'press_key') {
733
- const key = String(params.key || params.value || '').trim();
734
- if (!key) return asErrorPayload('OPERATION_FAILED', 'press_key requires params.key');
735
- const delay = Number(params.delay);
736
- const result = await callAPI('keyboard:press', {
737
- profileId: resolvedProfile,
738
- key,
739
- ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
740
- });
741
- return { ok: true, code: 'OPERATION_DONE', message: 'press_key done', data: result };
742
- }
743
-
744
- if (action === 'verify_subscriptions') {
745
- return executeVerifySubscriptions({ profileId: resolvedProfile, params });
746
- }
747
-
748
- if (action === 'evaluate') {
749
- return asErrorPayload('JS_DISABLED', 'evaluate is disabled in camo runtime');
750
- }
751
-
752
- if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
753
- return await executeSelectorOperation({
754
- profileId: resolvedProfile,
755
- action,
756
- operation,
757
- params,
758
- });
759
- }
760
-
761
- const externalResult = await executeExternalOperationIfAny({
762
- profileId: resolvedProfile,
763
- action,
764
- params,
765
- operation,
766
- context,
767
- });
768
- if (externalResult) return externalResult;
769
-
770
- return asErrorPayload('UNSUPPORTED_OPERATION', `Unsupported operation action: ${action}`);
771
- } catch (err) {
772
- return asErrorPayload('OPERATION_FAILED', err?.message || String(err), { context });
773
- }
774
- }
1
+ import { callAPI, getDomSnapshotByProfile } from '../../../utils/browser-service.mjs';
2
+ import { isJsExecutionEnabled } from '../../../utils/js-policy.mjs';
3
+ import { executeTabPoolOperation } from './tab-pool.mjs';
4
+ import { executeViewportOperation } from './viewport.mjs';
5
+ import {
6
+ asErrorPayload,
7
+ buildSelectorCheck,
8
+ ensureActiveSession,
9
+ extractPageList,
10
+ getCurrentUrl,
11
+ maybeSelector,
12
+ normalizeArray,
13
+ } from '../utils.mjs';
14
+
15
+ const TAB_ACTIONS = new Set([
16
+ 'ensure_tab_pool',
17
+ 'tab_pool_switch_next',
18
+ 'tab_pool_switch_slot',
19
+ ]);
20
+
21
+ const VIEWPORT_ACTIONS = new Set([
22
+ 'sync_window_viewport',
23
+ 'get_current_url',
24
+ ]);
25
+
26
+ const DEFAULT_MODAL_SELECTORS = [
27
+ '[aria-modal="true"]',
28
+ '[role="dialog"]',
29
+ '.modal',
30
+ '.dialog',
31
+ '.note-detail-mask',
32
+ '.note-detail-page',
33
+ '.note-detail-dialog',
34
+ ];
35
+ function resolveFilterMode(input) {
36
+ const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
37
+ if (!text) return 'strict';
38
+ if (text === 'legacy') return 'legacy';
39
+ return 'strict';
40
+ }
41
+
42
+ async function executeExternalOperationIfAny({
43
+ profileId,
44
+ action,
45
+ params,
46
+ operation,
47
+ context,
48
+ }) {
49
+ const executor = context?.executeExternalOperation;
50
+ if (typeof executor !== 'function') return null;
51
+ const result = await executor({
52
+ profileId,
53
+ action,
54
+ params,
55
+ operation,
56
+ context,
57
+ });
58
+ if (result === null || result === undefined) return null;
59
+ if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
60
+ return result;
61
+ }
62
+ return asErrorPayload('OPERATION_FAILED', 'external operation executor returned invalid payload', {
63
+ action,
64
+ resultType: typeof result,
65
+ });
66
+ }
67
+
68
+ async function flashOperationViewport(profileId, params = {}) {
69
+ if (!isJsExecutionEnabled()) return;
70
+ if (params.highlight === false) return;
71
+ try {
72
+ await callAPI('evaluate', {
73
+ profileId,
74
+ script: `(() => {
75
+ const root = document.documentElement;
76
+ if (!(root instanceof HTMLElement)) return { ok: false };
77
+ const prevShadow = root.style.boxShadow;
78
+ const prevTransition = root.style.transition;
79
+ root.style.transition = 'box-shadow 80ms ease';
80
+ root.style.boxShadow = 'inset 0 0 0 3px #ff7a00';
81
+ setTimeout(() => {
82
+ root.style.boxShadow = prevShadow;
83
+ root.style.transition = prevTransition;
84
+ }, 260);
85
+ return { ok: true };
86
+ })()`,
87
+ });
88
+ } catch {
89
+ // highlight failure should never block action execution
90
+ }
91
+ }
92
+
93
+ function sleep(ms) {
94
+ return new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+
97
+ async function pageScroll(profileId, deltaY, delayMs = 80) {
98
+ const raw = Number(deltaY) || 0;
99
+ if (!Number.isFinite(raw) || raw === 0) return;
100
+ const key = raw >= 0 ? 'PageDown' : 'PageUp';
101
+ const steps = Math.max(1, Math.min(8, Math.round(Math.abs(raw) / 420) || 1));
102
+ for (let step = 0; step < steps; step += 1) {
103
+ await callAPI('keyboard:press', { profileId, key });
104
+ if (delayMs > 0) await sleep(delayMs);
105
+ }
106
+ }
107
+
108
+ function clamp(value, min, max) {
109
+ return Math.min(Math.max(value, min), max);
110
+ }
111
+
112
+ function isTargetFullyInViewport(target, margin = 6) {
113
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
114
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
115
+ if (!rect || !viewport) return true;
116
+ const vw = Number(viewport.width || 0);
117
+ const vh = Number(viewport.height || 0);
118
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
119
+ const left = Number(rect.left || 0);
120
+ const top = Number(rect.top || 0);
121
+ const width = Math.max(0, Number(rect.width || 0));
122
+ const height = Math.max(0, Number(rect.height || 0));
123
+ const right = left + width;
124
+ const bottom = top + height;
125
+ const m = Math.max(0, Number(margin) || 0);
126
+ return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
127
+ }
128
+
129
+ function resolveViewportScrollDelta(target, margin = 6) {
130
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
131
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
132
+ if (!rect || !viewport) return { deltaX: 0, deltaY: 0 };
133
+ const vw = Number(viewport.width || 0);
134
+ const vh = Number(viewport.height || 0);
135
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return { deltaX: 0, deltaY: 0 };
136
+ const left = Number(rect.left || 0);
137
+ const top = Number(rect.top || 0);
138
+ const width = Math.max(0, Number(rect.width || 0));
139
+ const height = Math.max(0, Number(rect.height || 0));
140
+ const right = left + width;
141
+ const bottom = top + height;
142
+ const m = Math.max(0, Number(margin) || 0);
143
+
144
+ let deltaX = 0;
145
+ let deltaY = 0;
146
+
147
+ if (left < m) {
148
+ deltaX = Math.round(left - m);
149
+ } else if (right > (vw - m)) {
150
+ deltaX = Math.round(right - (vw - m));
151
+ }
152
+
153
+ if (top < m) {
154
+ deltaY = Math.round(top - m);
155
+ } else if (bottom > (vh - m)) {
156
+ deltaY = Math.round(bottom - (vh - m));
157
+ }
158
+
159
+ if (Math.abs(deltaY) < 80 && !isTargetFullyInViewport(target, m)) {
160
+ deltaY = deltaY >= 0 ? 120 : -120;
161
+ }
162
+ if (Math.abs(deltaX) < 40 && (left < m || right > (vw - m))) {
163
+ deltaX = deltaX >= 0 ? 60 : -60;
164
+ }
165
+
166
+ return {
167
+ deltaX: clamp(deltaX, -900, 900),
168
+ deltaY: clamp(deltaY, -900, 900),
169
+ };
170
+ }
171
+
172
+ function normalizeRect(node) {
173
+ const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
174
+ if (!rect) return null;
175
+ const left = Number(rect.left ?? rect.x ?? 0);
176
+ const top = Number(rect.top ?? rect.y ?? 0);
177
+ const width = Number(rect.width ?? 0);
178
+ const height = Number(rect.height ?? 0);
179
+ if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) {
180
+ return null;
181
+ }
182
+ if (width <= 0 || height <= 0) return null;
183
+ return { left, top, width, height };
184
+ }
185
+
186
+ function nodeArea(node) {
187
+ const rect = normalizeRect(node);
188
+ if (!rect) return 0;
189
+ return Number(rect.width || 0) * Number(rect.height || 0);
190
+ }
191
+
192
+ function nodeCenter(node, viewport = null) {
193
+ const rect = normalizeRect(node);
194
+ const vw = Number(viewport?.width || 0);
195
+ const vh = Number(viewport?.height || 0);
196
+ if (!rect) return null;
197
+ const rawX = rect.left + Math.max(1, rect.width / 2);
198
+ const rawY = rect.top + Math.max(1, rect.height / 2);
199
+ const centerX = vw > 1
200
+ ? clamp(Math.round(rawX), 1, Math.max(1, vw - 1))
201
+ : Math.max(1, Math.round(rawX));
202
+ const centerY = vh > 1
203
+ ? clamp(Math.round(rawY), 1, Math.max(1, vh - 1))
204
+ : Math.max(1, Math.round(rawY));
205
+ return {
206
+ center: { x: centerX, y: centerY },
207
+ rawCenter: { x: rawX, y: rawY },
208
+ rect,
209
+ };
210
+ }
211
+
212
+ function getSnapshotViewport(snapshot) {
213
+ const width = Number(snapshot?.__viewport?.width || 0);
214
+ const height = Number(snapshot?.__viewport?.height || 0);
215
+ return { width, height };
216
+ }
217
+
218
+ function isPathWithin(path, parentPath) {
219
+ const child = String(path || '').trim();
220
+ const parent = String(parentPath || '').trim();
221
+ if (!child || !parent) return false;
222
+ return child === parent || child.startsWith(`${parent}/`);
223
+ }
224
+
225
+ function resolveActiveModal(snapshot) {
226
+ if (!snapshot) return null;
227
+ const rows = [];
228
+ for (const selector of DEFAULT_MODAL_SELECTORS) {
229
+ const matches = buildSelectorCheck(snapshot, { css: selector, visible: true });
230
+ for (const node of matches) {
231
+ if (nodeArea(node) <= 1) continue;
232
+ rows.push({
233
+ selector,
234
+ path: String(node.path || ''),
235
+ node,
236
+ area: nodeArea(node),
237
+ });
238
+ }
239
+ }
240
+ rows.sort((a, b) => b.area - a.area);
241
+ return rows[0] || null;
242
+ }
243
+
244
+ async function resolveSelectorTarget(profileId, selector, options = {}) {
245
+ const filterMode = resolveFilterMode(options.filterMode);
246
+ const strictFilter = filterMode !== 'legacy';
247
+ const normalizedSelector = String(selector || '').trim();
248
+ const snapshot = await getDomSnapshotByProfile(profileId);
249
+ const viewport = getSnapshotViewport(snapshot);
250
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
251
+ const visibleMatches = buildSelectorCheck(snapshot, { css: normalizedSelector, visible: true });
252
+ const allMatches = strictFilter
253
+ ? visibleMatches
254
+ : buildSelectorCheck(snapshot, { css: normalizedSelector, visible: false });
255
+ const scopedVisible = modal
256
+ ? visibleMatches.filter((item) => isPathWithin(item.path, modal.path))
257
+ : visibleMatches;
258
+ const scopedAll = modal
259
+ ? allMatches.filter((item) => isPathWithin(item.path, modal.path))
260
+ : allMatches;
261
+ const candidate = strictFilter
262
+ ? (scopedVisible[0] || null)
263
+ : (scopedVisible[0] || scopedAll[0] || null);
264
+ if (!candidate) {
265
+ if (modal) {
266
+ throw new Error(`Modal focus locked for selector: ${normalizedSelector}`);
267
+ }
268
+ throw new Error(`Element not found: ${normalizedSelector}`);
269
+ }
270
+ const center = nodeCenter(candidate, viewport);
271
+ if (!center) {
272
+ throw new Error(`Element not found: ${normalizedSelector}`);
273
+ }
274
+ return {
275
+ ok: true,
276
+ selector: normalizedSelector,
277
+ matchedIndex: Math.max(0, scopedAll.indexOf(candidate)),
278
+ center: center.center,
279
+ rawCenter: center.rawCenter,
280
+ rect: center.rect,
281
+ viewport,
282
+ modalLocked: Boolean(modal),
283
+ };
284
+ }
285
+
286
+ async function scrollTargetIntoViewport(profileId, selector, initialTarget, params = {}, options = {}) {
287
+ let target = initialTarget;
288
+ const maxSteps = Math.max(0, Math.min(24, Number(params.maxScrollSteps ?? 8) || 8));
289
+ const settleMs = Math.max(0, Number(params.scrollSettleMs ?? 140) || 140);
290
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
291
+ for (let i = 0; i < maxSteps; i += 1) {
292
+ if (isTargetFullyInViewport(target, visibilityMargin)) break;
293
+ const delta = resolveViewportScrollDelta(target, visibilityMargin);
294
+ if (Math.abs(delta.deltaX) < 1 && Math.abs(delta.deltaY) < 1) break;
295
+ const deltaY = delta.deltaY !== 0 ? delta.deltaY : (delta.deltaX !== 0 ? delta.deltaX : 0);
296
+ await pageScroll(profileId, deltaY);
297
+ if (settleMs > 0) await sleep(settleMs);
298
+ target = await resolveSelectorTarget(profileId, selector, options);
299
+ }
300
+ return target;
301
+ }
302
+
303
+ async function resolveScrollAnchor(profileId, options = {}) {
304
+ const filterMode = resolveFilterMode(options.filterMode);
305
+ const strictFilter = filterMode !== 'legacy';
306
+ const selector = String(options.selector || '').trim();
307
+ const snapshot = await getDomSnapshotByProfile(profileId);
308
+ const viewport = getSnapshotViewport(snapshot);
309
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
310
+
311
+ if (selector) {
312
+ const visibleMatches = buildSelectorCheck(snapshot, { css: selector, visible: true });
313
+ const target = visibleMatches[0] || null;
314
+ if (target) {
315
+ if (modal && !isPathWithin(target.path, modal.path)) {
316
+ const modalCenter = nodeCenter(modal.node, viewport);
317
+ if (modalCenter) {
318
+ return {
319
+ ok: true,
320
+ source: 'modal',
321
+ center: modalCenter.center,
322
+ modalLocked: true,
323
+ modalSelector: modal.selector,
324
+ selectorRejectedByModalLock: true,
325
+ };
326
+ }
327
+ } else {
328
+ const targetCenter = nodeCenter(target, viewport);
329
+ if (targetCenter) {
330
+ return {
331
+ ok: true,
332
+ source: 'selector',
333
+ center: targetCenter.center,
334
+ modalLocked: Boolean(modal),
335
+ };
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ if (modal) {
342
+ const modalCenter = nodeCenter(modal.node, viewport);
343
+ if (modalCenter) {
344
+ return {
345
+ ok: true,
346
+ source: 'modal',
347
+ center: modalCenter.center,
348
+ modalLocked: true,
349
+ modalSelector: modal.selector,
350
+ };
351
+ }
352
+ }
353
+
354
+ const width = Number(viewport.width || 0);
355
+ const height = Number(viewport.height || 0);
356
+ return {
357
+ ok: true,
358
+ source: 'document',
359
+ center: {
360
+ x: width > 1 ? Math.round(width / 2) : 1,
361
+ y: height > 1 ? Math.round(height / 2) : 1,
362
+ },
363
+ modalLocked: false,
364
+ };
365
+ }
366
+
367
+ async function executeSelectorOperation({ profileId, action, operation, params, filterMode }) {
368
+ const selector = maybeSelector({
369
+ profileId,
370
+ containerId: params.containerId || operation?.containerId || null,
371
+ selector: params.selector || operation?.selector || null,
372
+ });
373
+ if (!selector) return asErrorPayload('CONTAINER_NOT_FOUND', `${action} requires selector/containerId`);
374
+
375
+ let target = await resolveSelectorTarget(profileId, selector, { filterMode });
376
+ target = await scrollTargetIntoViewport(profileId, selector, target, params, { filterMode });
377
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
378
+ const targetFullyVisible = isTargetFullyInViewport(target, visibilityMargin);
379
+ if (action === 'click' && !targetFullyVisible) {
380
+ return asErrorPayload('TARGET_NOT_FULLY_VISIBLE', 'click target is not fully visible after auto scroll', {
381
+ selector,
382
+ target,
383
+ visibilityMargin,
384
+ });
385
+ }
386
+
387
+ if (action === 'scroll_into_view') {
388
+ return {
389
+ ok: true,
390
+ code: 'OPERATION_DONE',
391
+ message: 'scroll_into_view done',
392
+ data: { selector, target, targetFullyVisible, visibilityMargin },
393
+ };
394
+ }
395
+
396
+ if (action === 'click') {
397
+ const button = String(params.button || 'left').trim() || 'left';
398
+ const clicks = Math.max(1, Number(params.clicks ?? 1) || 1);
399
+ const delay = Number(params.delay);
400
+ const result = await callAPI('mouse:click', {
401
+ profileId,
402
+ x: target.center.x,
403
+ y: target.center.y,
404
+ button,
405
+ clicks,
406
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
407
+ });
408
+ return {
409
+ ok: true,
410
+ code: 'OPERATION_DONE',
411
+ message: 'click done',
412
+ data: { selector, target, result, targetFullyVisible, visibilityMargin },
413
+ };
414
+ }
415
+
416
+ const text = String(params.text ?? params.value ?? '');
417
+ await callAPI('mouse:click', {
418
+ profileId,
419
+ x: target.center.x,
420
+ y: target.center.y,
421
+ button: 'left',
422
+ clicks: 1,
423
+ });
424
+ const clearBeforeType = params.clear !== false;
425
+ if (clearBeforeType) {
426
+ await callAPI('keyboard:press', {
427
+ profileId,
428
+ key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
429
+ });
430
+ await callAPI('keyboard:press', { profileId, key: 'Backspace' });
431
+ }
432
+ const delay = Number(params.keyDelayMs ?? params.delay);
433
+ await callAPI('keyboard:type', {
434
+ profileId,
435
+ text,
436
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
437
+ });
438
+ if (params.pressEnter === true) {
439
+ await callAPI('keyboard:press', { profileId, key: 'Enter' });
440
+ }
441
+ return {
442
+ ok: true,
443
+ code: 'OPERATION_DONE',
444
+ message: 'type done',
445
+ data: { selector, target, length: text.length },
446
+ };
447
+ }
448
+
449
+ async function executeVerifySubscriptions({ profileId, params }) {
450
+ const defaultVisible = params.visible !== false;
451
+ const defaultMinCount = Math.max(0, Number(params.minCount ?? 1) || 1);
452
+ const selectorItems = normalizeArray(params.subscriptions || params.selectors)
453
+ .map((item, idx) => {
454
+ if (typeof item === 'string') {
455
+ return {
456
+ id: `selector_${idx + 1}`,
457
+ selector: item,
458
+ visible: defaultVisible,
459
+ minCount: defaultMinCount,
460
+ };
461
+ }
462
+ if (!item || typeof item !== 'object') return null;
463
+ const selector = String(item.selector || '').trim();
464
+ if (!selector) return null;
465
+ const visible = item.visible !== undefined
466
+ ? item.visible !== false
467
+ : defaultVisible;
468
+ const minCount = Math.max(0, Number(item.minCount ?? defaultMinCount) || defaultMinCount);
469
+ return {
470
+ id: String(item.id || `selector_${idx + 1}`),
471
+ selector,
472
+ visible,
473
+ minCount,
474
+ };
475
+ })
476
+ .filter(Boolean);
477
+ if (selectorItems.length === 0) {
478
+ return asErrorPayload('OPERATION_FAILED', 'verify_subscriptions requires params.selectors');
479
+ }
480
+
481
+ const acrossPages = params.acrossPages === true;
482
+ const settleMs = Math.max(0, Number(params.settleMs ?? 280) || 280);
483
+ const pageUrlIncludes = normalizeArray(params.pageUrlIncludes)
484
+ .map((item) => String(item || '').trim())
485
+ .filter(Boolean);
486
+ const pageUrlExcludes = normalizeArray(params.pageUrlExcludes)
487
+ .map((item) => String(item || '').trim())
488
+ .filter(Boolean);
489
+ const pageUrlRegex = String(params.pageUrlRegex || '').trim();
490
+ const pageUrlNotRegex = String(params.pageUrlNotRegex || '').trim();
491
+ const requireMatchedPages = params.requireMatchedPages !== false;
492
+
493
+ let includeRegex = null;
494
+ if (pageUrlRegex) {
495
+ try {
496
+ includeRegex = new RegExp(pageUrlRegex);
497
+ } catch {
498
+ return asErrorPayload('OPERATION_FAILED', `invalid pageUrlRegex: ${pageUrlRegex}`);
499
+ }
500
+ }
501
+ let excludeRegex = null;
502
+ if (pageUrlNotRegex) {
503
+ try {
504
+ excludeRegex = new RegExp(pageUrlNotRegex);
505
+ } catch {
506
+ return asErrorPayload('OPERATION_FAILED', `invalid pageUrlNotRegex: ${pageUrlNotRegex}`);
507
+ }
508
+ }
509
+
510
+ const hasPageFilter = (
511
+ pageUrlIncludes.length > 0
512
+ || pageUrlExcludes.length > 0
513
+ || Boolean(includeRegex)
514
+ || Boolean(excludeRegex)
515
+ );
516
+
517
+ const shouldVerifyPage = (rawUrl) => {
518
+ const url = String(rawUrl || '').trim();
519
+ if (pageUrlIncludes.length > 0 && !pageUrlIncludes.some((part) => url.includes(part))) {
520
+ return false;
521
+ }
522
+ if (pageUrlExcludes.length > 0 && pageUrlExcludes.some((part) => url.includes(part))) {
523
+ return false;
524
+ }
525
+ if (includeRegex && !includeRegex.test(url)) {
526
+ return false;
527
+ }
528
+ if (excludeRegex && excludeRegex.test(url)) {
529
+ return false;
530
+ }
531
+ return true;
532
+ };
533
+
534
+ const collectForCurrentPage = async () => {
535
+ const snapshot = await getDomSnapshotByProfile(profileId);
536
+ const url = await getCurrentUrl(profileId);
537
+ const matches = selectorItems.map((item) => ({
538
+ id: item.id,
539
+ selector: item.selector,
540
+ visible: item.visible,
541
+ minCount: item.minCount,
542
+ count: buildSelectorCheck(snapshot, {
543
+ css: item.selector,
544
+ visible: item.visible,
545
+ }).length,
546
+ }));
547
+ return { url, matches };
548
+ };
549
+
550
+ let pagesResult = [];
551
+ let overallOk = true;
552
+ let matchedPageCount = 0;
553
+ let activePageIndex = null;
554
+ if (!acrossPages) {
555
+ const current = await collectForCurrentPage();
556
+ overallOk = current.matches.every((item) => item.count >= item.minCount);
557
+ pagesResult = [{ index: null, ...current }];
558
+ } else {
559
+ const listed = await callAPI('page:list', { profileId });
560
+ const { pages, activeIndex } = extractPageList(listed);
561
+ activePageIndex = Number.isFinite(activeIndex) ? activeIndex : null;
562
+ for (const page of pages) {
563
+ const pageIndex = Number(page.index);
564
+ const listedUrl = String(page.url || '');
565
+ if (!shouldVerifyPage(listedUrl)) {
566
+ pagesResult.push({
567
+ index: pageIndex,
568
+ url: listedUrl,
569
+ skipped: true,
570
+ ok: true,
571
+ });
572
+ continue;
573
+ }
574
+ if (Number.isFinite(activeIndex) && activeIndex !== pageIndex) {
575
+ await callAPI('page:switch', { profileId, index: pageIndex });
576
+ if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
577
+ }
578
+ const current = await collectForCurrentPage();
579
+ const pageOk = current.matches.every((item) => item.count >= item.minCount);
580
+ overallOk = overallOk && pageOk;
581
+ pagesResult.push({ index: pageIndex, ...current, ok: pageOk });
582
+ matchedPageCount += 1;
583
+ }
584
+ if (Number.isFinite(activeIndex)) {
585
+ await callAPI('page:switch', { profileId, index: activeIndex });
586
+ }
587
+ }
588
+
589
+ if (acrossPages && hasPageFilter && requireMatchedPages && matchedPageCount === 0) {
590
+ const fallback = await collectForCurrentPage();
591
+ const fallbackOk = fallback.matches.every((item) => item.count >= item.minCount);
592
+ if (fallbackOk) {
593
+ matchedPageCount = 1;
594
+ overallOk = true;
595
+ pagesResult.push({
596
+ index: Number.isFinite(activePageIndex) ? activePageIndex : null,
597
+ urlMatched: false,
598
+ fallback: 'dom_match',
599
+ ok: true,
600
+ ...fallback,
601
+ });
602
+ } else {
603
+ return asErrorPayload('SUBSCRIPTION_MISMATCH', 'no page matched verify_subscriptions pageUrl filter', {
604
+ acrossPages,
605
+ pageUrlIncludes,
606
+ pageUrlExcludes,
607
+ pageUrlRegex: pageUrlRegex || null,
608
+ pageUrlNotRegex: pageUrlNotRegex || null,
609
+ pages: pagesResult,
610
+ fallback,
611
+ });
612
+ }
613
+ }
614
+
615
+ if (!overallOk) {
616
+ return asErrorPayload('SUBSCRIPTION_MISMATCH', 'subscription selectors missing on one or more pages', {
617
+ acrossPages,
618
+ matchedPageCount,
619
+ pages: pagesResult,
620
+ });
621
+ }
622
+
623
+ return {
624
+ ok: true,
625
+ code: 'OPERATION_DONE',
626
+ message: 'verify_subscriptions done',
627
+ data: { acrossPages, matchedPageCount, pages: pagesResult },
628
+ };
629
+ }
630
+
631
+ export async function executeOperation({ profileId, operation, context = {} }) {
632
+ try {
633
+ const session = await ensureActiveSession(profileId);
634
+ const resolvedProfile = session.profileId || profileId;
635
+ const action = String(operation?.action || '').trim();
636
+ const params = operation?.params || operation?.config || {};
637
+ const filterMode = resolveFilterMode(
638
+ params.filterMode
639
+ || operation?.filterMode
640
+ || context?.filterMode
641
+ || context?.runtime?.filterMode
642
+ || null,
643
+ );
644
+
645
+ if (!action) {
646
+ return asErrorPayload('OPERATION_FAILED', 'operation.action is required');
647
+ }
648
+
649
+ if (action !== 'wait') {
650
+ await flashOperationViewport(resolvedProfile, params);
651
+ }
652
+
653
+ if (TAB_ACTIONS.has(action)) {
654
+ return await executeTabPoolOperation({
655
+ profileId: resolvedProfile,
656
+ action,
657
+ params,
658
+ context,
659
+ });
660
+ }
661
+
662
+ if (VIEWPORT_ACTIONS.has(action)) {
663
+ return await executeViewportOperation({
664
+ profileId: resolvedProfile,
665
+ action,
666
+ params,
667
+ });
668
+ }
669
+
670
+ if (action === 'goto') {
671
+ const url = String(params.url || params.value || '').trim();
672
+ if (!url) return asErrorPayload('OPERATION_FAILED', 'goto requires params.url');
673
+ const result = await callAPI('goto', { profileId: resolvedProfile, url });
674
+ return { ok: true, code: 'OPERATION_DONE', message: 'goto done', data: result };
675
+ }
676
+
677
+ if (action === 'back') {
678
+ const result = await callAPI('page:back', { profileId: resolvedProfile });
679
+ return { ok: true, code: 'OPERATION_DONE', message: 'back done', data: result };
680
+ }
681
+
682
+ if (action === 'list_pages') {
683
+ const result = await callAPI('page:list', { profileId: resolvedProfile });
684
+ const { pages, activeIndex } = extractPageList(result);
685
+ return { ok: true, code: 'OPERATION_DONE', message: 'list_pages done', data: { pages, activeIndex, raw: result } };
686
+ }
687
+
688
+ if (action === 'new_page') {
689
+ const rawUrl = String(params.url || params.value || '').trim();
690
+ const payload = rawUrl ? { profileId: resolvedProfile, url: rawUrl } : { profileId: resolvedProfile };
691
+ const result = await callAPI('newPage', payload);
692
+ return { ok: true, code: 'OPERATION_DONE', message: 'new_page done', data: result };
693
+ }
694
+
695
+ if (action === 'switch_page') {
696
+ const index = Number(params.index ?? params.value);
697
+ if (!Number.isFinite(index)) {
698
+ return asErrorPayload('OPERATION_FAILED', 'switch_page requires params.index');
699
+ }
700
+ const result = await callAPI('page:switch', { profileId: resolvedProfile, index });
701
+ return { ok: true, code: 'OPERATION_DONE', message: 'switch_page done', data: result };
702
+ }
703
+
704
+ if (action === 'wait') {
705
+ const ms = Math.max(0, Number(params.ms ?? params.value ?? 0));
706
+ await new Promise((resolve) => setTimeout(resolve, ms));
707
+ return { ok: true, code: 'OPERATION_DONE', message: 'wait done', data: { ms } };
708
+ }
709
+
710
+ if (action === 'scroll') {
711
+ const amount = Math.max(1, Number(params.amount ?? params.value ?? 300) || 300);
712
+ const direction = String(params.direction || 'down').toLowerCase();
713
+ let deltaX = 0;
714
+ let deltaY = amount;
715
+ if (direction === 'up') deltaY = -amount;
716
+ else if (direction === 'left') {
717
+ deltaX = -amount;
718
+ deltaY = 0;
719
+ } else if (direction === 'right') {
720
+ deltaX = amount;
721
+ deltaY = 0;
722
+ }
723
+ const result = await pageScroll(resolvedProfile, deltaY);
724
+ return {
725
+ ok: true,
726
+ code: 'OPERATION_DONE',
727
+ message: 'scroll done',
728
+ data: { direction, amount, deltaX, deltaY, result },
729
+ };
730
+ }
731
+
732
+ if (action === 'press_key') {
733
+ const key = String(params.key || params.value || '').trim();
734
+ if (!key) return asErrorPayload('OPERATION_FAILED', 'press_key requires params.key');
735
+ const delay = Number(params.delay);
736
+ const result = await callAPI('keyboard:press', {
737
+ profileId: resolvedProfile,
738
+ key,
739
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
740
+ });
741
+ return { ok: true, code: 'OPERATION_DONE', message: 'press_key done', data: result };
742
+ }
743
+
744
+ if (action === 'verify_subscriptions') {
745
+ return executeVerifySubscriptions({ profileId: resolvedProfile, params });
746
+ }
747
+
748
+ if (action === 'evaluate') {
749
+ return asErrorPayload('JS_DISABLED', 'evaluate is disabled in camo runtime');
750
+ }
751
+
752
+ if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
753
+ return await executeSelectorOperation({
754
+ profileId: resolvedProfile,
755
+ action,
756
+ operation,
757
+ params,
758
+ });
759
+ }
760
+
761
+ const externalResult = await executeExternalOperationIfAny({
762
+ profileId: resolvedProfile,
763
+ action,
764
+ params,
765
+ operation,
766
+ context,
767
+ });
768
+ if (externalResult) return externalResult;
769
+
770
+ return asErrorPayload('UNSUPPORTED_OPERATION', `Unsupported operation action: ${action}`);
771
+ } catch (err) {
772
+ return asErrorPayload('OPERATION_FAILED', err?.message || String(err), { context });
773
+ }
774
+ }