@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.
- package/LICENSE +21 -21
- package/README.md +586 -586
- package/bin/browser-service.mjs +11 -11
- package/bin/camo.mjs +22 -22
- package/package.json +48 -48
- package/scripts/build.mjs +19 -19
- package/scripts/bump-version.mjs +34 -34
- package/scripts/check-file-size.mjs +80 -80
- package/scripts/file-size-policy.json +12 -2
- package/scripts/install.mjs +76 -76
- package/scripts/release.sh +54 -54
- package/src/autoscript/action-providers/index.mjs +6 -6
- package/src/autoscript/impact-engine.mjs +78 -78
- package/src/autoscript/runtime.mjs +1017 -1017
- package/src/autoscript/schema.mjs +376 -376
- package/src/cli.mjs +405 -405
- package/src/commands/attach.mjs +141 -141
- package/src/commands/autoscript.mjs +1011 -1011
- package/src/commands/browser.mjs +1255 -1257
- package/src/commands/container.mjs +401 -401
- package/src/commands/cookies.mjs +69 -69
- package/src/commands/create.mjs +98 -98
- package/src/commands/devtools.mjs +349 -349
- package/src/commands/events.mjs +152 -152
- package/src/commands/highlight-mode.mjs +24 -24
- package/src/commands/init.mjs +68 -68
- package/src/commands/lifecycle.mjs +275 -275
- package/src/commands/mouse.mjs +45 -45
- package/src/commands/profile.mjs +46 -46
- package/src/commands/record.mjs +115 -115
- package/src/commands/system.mjs +14 -14
- package/src/commands/window.mjs +123 -123
- package/src/container/change-notifier.mjs +362 -362
- package/src/container/element-filter.mjs +143 -143
- package/src/container/index.mjs +3 -3
- package/src/container/runtime-core/checkpoint.mjs +209 -209
- package/src/container/runtime-core/index.mjs +21 -21
- package/src/container/runtime-core/operations/index.mjs +774 -774
- package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
- package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
- package/src/container/runtime-core/operations/viewport.mjs +189 -189
- package/src/container/runtime-core/search.mjs +190 -190
- package/src/container/runtime-core/subscription.mjs +224 -224
- package/src/container/runtime-core/utils.mjs +94 -94
- package/src/container/runtime-core/validation.mjs +127 -184
- package/src/container/runtime-core.mjs +1 -1
- package/src/container/subscription-registry.mjs +459 -459
- package/src/core/actions.mjs +561 -561
- package/src/core/browser.mjs +266 -266
- package/src/core/index.mjs +52 -52
- package/src/core/utils.mjs +91 -91
- package/src/events/daemon-entry.mjs +33 -33
- package/src/events/daemon.mjs +80 -80
- package/src/events/progress-log.mjs +109 -109
- package/src/events/ws-server.mjs +239 -239
- package/src/lib/client.mjs +200 -200
- package/src/lifecycle/cleanup.mjs +83 -83
- package/src/lifecycle/lock.mjs +126 -126
- package/src/lifecycle/session-registry.mjs +279 -279
- package/src/lifecycle/session-view.mjs +76 -76
- package/src/lifecycle/session-watchdog.mjs +281 -281
- package/src/services/browser-service/index.js +671 -674
- package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
- package/src/services/browser-service/internal/BrowserSession.js +325 -336
- package/src/services/browser-service/internal/ElementRegistry.js +60 -60
- package/src/services/browser-service/internal/ProfileLock.js +84 -84
- package/src/services/browser-service/internal/SessionManager.js +184 -184
- package/src/services/browser-service/internal/SessionManager.test.js +39 -39
- package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
- package/src/services/browser-service/internal/browser-session/input-ops.js +222 -219
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
- package/src/services/browser-service/internal/browser-session/logging.js +46 -46
- package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
- package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
- package/src/services/browser-service/internal/browser-session/page-management.js +302 -336
- package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
- package/src/services/browser-service/internal/browser-session/recording.js +198 -198
- package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
- package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
- package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
- package/src/services/browser-service/internal/browser-session/types.js +14 -14
- package/src/services/browser-service/internal/browser-session/utils.js +95 -95
- package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
- package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
- package/src/services/browser-service/internal/container-matcher.js +851 -851
- package/src/services/browser-service/internal/container-registry.js +182 -182
- package/src/services/browser-service/internal/engine-manager.js +259 -259
- package/src/services/browser-service/internal/fingerprint.js +203 -203
- package/src/services/browser-service/internal/heartbeat.js +137 -137
- package/src/services/browser-service/internal/logging.js +46 -46
- package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
- package/src/services/browser-service/internal/pageRuntime.js +28 -28
- package/src/services/browser-service/internal/runtimeInjector.js +31 -31
- package/src/services/browser-service/internal/service-process-logger.js +140 -140
- package/src/services/browser-service/internal/state-bus.js +45 -45
- package/src/services/browser-service/internal/storage-paths.js +42 -42
- package/src/services/browser-service/internal/ws-server.js +1194 -1194
- package/src/services/browser-service/internal/ws-server.test.js +58 -58
- package/src/services/browser-service/server.mjs +6 -6
- package/src/services/controller/cli-bridge.js +93 -93
- package/src/services/controller/container-index.js +50 -50
- package/src/services/controller/container-storage.js +36 -36
- package/src/services/controller/controller-actions.js +207 -207
- package/src/services/controller/controller.js +1138 -1138
- package/src/services/controller/selectors.js +54 -54
- package/src/services/controller/transport.js +125 -125
- package/src/utils/args.mjs +26 -26
- package/src/utils/browser-service.mjs +544 -544
- package/src/utils/command-log.mjs +64 -64
- package/src/utils/config.mjs +214 -214
- package/src/utils/fingerprint.mjs +181 -181
- package/src/utils/help.mjs +216 -216
- package/src/utils/js-policy.mjs +13 -13
- package/src/utils/ws-client.mjs +30 -30
- package/src/container/runtime-core/operations/tab-pool.mjs.bak +0 -762
- package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +0 -762
- 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
|
+
}
|