@web-auto/camo 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -970,6 +970,7 @@ export class AutoscriptRunner {
970
970
  profileId: this.profileId,
971
971
  subscriptions: this.script.subscriptions,
972
972
  throttle: this.script.throttle,
973
+ filterMode: this.script?.defaults?.filterMode || 'strict',
973
974
  onEvent: async (event) => {
974
975
  this.log('autoscript:event', {
975
976
  type: event.type,
@@ -127,9 +127,14 @@ function normalizeSubscription(item, index, defaults) {
127
127
  const selector = toTrimmedString(item.selector);
128
128
  if (!selector) return null;
129
129
  const events = toArray(item.events).map((name) => toTrimmedString(name)).filter(Boolean);
130
+ const pageUrlIncludes = toArray(item.pageUrlIncludes).map((token) => toTrimmedString(token)).filter(Boolean);
131
+ const pageUrlExcludes = toArray(item.pageUrlExcludes).map((token) => toTrimmedString(token)).filter(Boolean);
130
132
  return {
131
133
  id,
132
134
  selector,
135
+ visible: item.visible !== false,
136
+ pageUrlIncludes,
137
+ pageUrlExcludes,
133
138
  events: events.length > 0 ? events : ['appear', 'exist', 'disappear', 'change'],
134
139
  dependsOn: toArray(item.dependsOn).map((x) => toTrimmedString(x)).filter(Boolean),
135
140
  retry: normalizeRetry(item.retry, defaults.retry),
@@ -243,6 +248,7 @@ export function normalizeAutoscript(raw, sourcePath = null) {
243
248
  retry: normalizeRetry(defaults.retry, {}),
244
249
  impact: toTrimmedString(defaults.impact || 'op') || 'op',
245
250
  onFailure: toTrimmedString(defaults.onFailure || 'chain_stop') || 'chain_stop',
251
+ filterMode: toTrimmedString(defaults.filterMode || 'strict') || 'strict',
246
252
  validationMode: toTrimmedString(defaults.validationMode || 'none') || 'none',
247
253
  recovery: normalizeRecovery(defaults.recovery, {}),
248
254
  pacing: normalizePacing(defaults.pacing, {}),
package/src/cli.mjs CHANGED
@@ -160,7 +160,11 @@ async function handleConfigCommand(args) {
160
160
  }
161
161
 
162
162
  async function main() {
163
- const args = process.argv.slice(2);
163
+ const rawArgs = process.argv.slice(2);
164
+ const jsEnabled = rawArgs.includes('--js');
165
+ if (jsEnabled) process.env.CAMO_ALLOW_JS = '1';
166
+ else delete process.env.CAMO_ALLOW_JS;
167
+ const args = rawArgs.filter((arg) => arg !== '--js');
164
168
  const cmd = args[0];
165
169
 
166
170
  if (!cmd) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getDefaultProfile } from '../utils/config.mjs';
3
+ import { getDefaultProfile, listProfiles } from '../utils/config.mjs';
4
4
  import { explainAutoscript, loadAndValidateAutoscript } from '../autoscript/schema.mjs';
5
5
  import { AutoscriptRunner } from '../autoscript/runtime.mjs';
6
6
  import { safeAppendProgressEvent } from '../events/progress-log.mjs';
@@ -30,6 +30,18 @@ function collectPositionals(args, startIndex = 2, valueFlags = new Set(['--profi
30
30
  return out;
31
31
  }
32
32
 
33
+ function assertExistingProfile(profileId) {
34
+ const id = String(profileId || '').trim();
35
+ if (!id) {
36
+ throw new Error('profileId is required');
37
+ }
38
+ const known = new Set(listProfiles());
39
+ if (!known.has(id)) {
40
+ throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
41
+ }
42
+ return id;
43
+ }
44
+
33
45
  function appendJsonLine(filePath, payload) {
34
46
  if (!filePath) return;
35
47
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -783,6 +795,7 @@ async function handleRun(args) {
783
795
  if (!profileId) {
784
796
  throw new Error('profileId is required. Set in script or pass --profile <id>');
785
797
  }
798
+ assertExistingProfile(profileId);
786
799
 
787
800
  await executeAutoscriptRuntime({
788
801
  commandName: 'autoscript.run',
@@ -896,6 +909,7 @@ async function handleResume(args) {
896
909
  if (!profileId) {
897
910
  throw new Error('profileId is required. Set in script or pass --profile <id>');
898
911
  }
912
+ assertExistingProfile(profileId);
899
913
  const resumeState = buildResumeStateFromSnapshot(script, snapshot, fromNode || null);
900
914
  await executeAutoscriptRuntime({
901
915
  commandName: 'autoscript.resume',
@@ -9,11 +9,10 @@ import {
9
9
  } from '../utils/config.mjs';
10
10
  import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
11
11
  import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
12
+ import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
12
13
  import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
13
14
  import {
14
- buildSelectorClickScript,
15
15
  buildScrollTargetScript,
16
- buildSelectorTypeScript,
17
16
  } from '../container/runtime-core/operations/selector-scripts.mjs';
18
17
  import {
19
18
  registerSession,
@@ -31,11 +30,15 @@ import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } fr
31
30
  const START_WINDOW_MIN_WIDTH = 960;
32
31
  const START_WINDOW_MIN_HEIGHT = 700;
33
32
  const START_WINDOW_MAX_RESERVE = 240;
34
- const START_WINDOW_DEFAULT_RESERVE = 72;
33
+ const START_WINDOW_DEFAULT_RESERVE = 0;
35
34
  const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
36
35
  const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
37
36
  ? ['Meta+Alt+I', 'F12']
38
37
  : ['F12', 'Control+Shift+I'];
38
+ const INPUT_ACTION_TIMEOUT_MS = Math.max(
39
+ 1000,
40
+ parseNumber(process.env.CAMO_INPUT_ACTION_TIMEOUT_MS, 30000),
41
+ );
39
42
 
40
43
  function sleep(ms) {
41
44
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -77,6 +80,167 @@ function parseDurationMs(raw, fallbackMs) {
77
80
  return Math.floor(value * factor);
78
81
  }
79
82
 
83
+ function assertExistingProfile(profileId, profileSet = null) {
84
+ const id = String(profileId || '').trim();
85
+ if (!id) throw new Error('profileId is required');
86
+ const known = profileSet || new Set(listProfiles());
87
+ if (!known.has(id)) {
88
+ throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
89
+ }
90
+ return id;
91
+ }
92
+
93
+ async function resolveVisibleTargetPoint(profileId, selector, options = {}) {
94
+ const selectorLiteral = JSON.stringify(String(selector || '').trim());
95
+ const highlight = options.highlight === true;
96
+ const payload = await callAPI('evaluate', {
97
+ profileId,
98
+ script: `(() => {
99
+ const selector = ${selectorLiteral};
100
+ const highlight = ${highlight ? 'true' : 'false'};
101
+ const nodes = Array.from(document.querySelectorAll(selector));
102
+ const isVisible = (node) => {
103
+ if (!(node instanceof Element)) return false;
104
+ const rect = node.getBoundingClientRect?.();
105
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
106
+ try {
107
+ const style = window.getComputedStyle(node);
108
+ if (!style) return false;
109
+ if (style.display === 'none') return false;
110
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
111
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
112
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ return true;
117
+ };
118
+ const hitVisible = (node) => {
119
+ if (!(node instanceof Element)) return false;
120
+ const rect = node.getBoundingClientRect?.();
121
+ if (!rect) return false;
122
+ const x = Math.max(0, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + rect.width / 2)));
123
+ const y = Math.max(0, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + rect.height / 2)));
124
+ const top = document.elementFromPoint(x, y);
125
+ if (!top) return false;
126
+ return top === node || node.contains(top) || top.contains(node);
127
+ };
128
+ const target = nodes.find((item) => isVisible(item) && hitVisible(item))
129
+ || nodes.find((item) => isVisible(item))
130
+ || nodes[0]
131
+ || null;
132
+ if (!target) {
133
+ return { ok: false, error: 'selector_not_found', selector };
134
+ }
135
+ const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
136
+ const center = {
137
+ x: Math.max(1, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + Math.max(1, rect.width / 2)))),
138
+ y: Math.max(1, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + Math.max(1, rect.height / 2)))),
139
+ };
140
+ if (highlight) {
141
+ try {
142
+ const id = 'webauto-action-highlight-overlay';
143
+ const old = document.getElementById(id);
144
+ if (old) old.remove();
145
+ const overlay = document.createElement('div');
146
+ overlay.id = id;
147
+ overlay.style.position = 'fixed';
148
+ overlay.style.left = rect.left + 'px';
149
+ overlay.style.top = rect.top + 'px';
150
+ overlay.style.width = rect.width + 'px';
151
+ overlay.style.height = rect.height + 'px';
152
+ overlay.style.border = '2px solid #00A8FF';
153
+ overlay.style.borderRadius = '8px';
154
+ overlay.style.background = 'rgba(0,168,255,0.12)';
155
+ overlay.style.pointerEvents = 'none';
156
+ overlay.style.zIndex = '2147483647';
157
+ overlay.style.transition = 'opacity 120ms ease';
158
+ overlay.style.opacity = '1';
159
+ document.documentElement.appendChild(overlay);
160
+ setTimeout(() => {
161
+ overlay.style.opacity = '0';
162
+ setTimeout(() => overlay.remove(), 180);
163
+ }, 260);
164
+ } catch {}
165
+ }
166
+ return {
167
+ ok: true,
168
+ selector,
169
+ center,
170
+ rect: {
171
+ left: rect.left,
172
+ top: rect.top,
173
+ width: rect.width,
174
+ height: rect.height,
175
+ },
176
+ viewport: {
177
+ width: Number(window.innerWidth || 0),
178
+ height: Number(window.innerHeight || 0),
179
+ },
180
+ };
181
+ })()`,
182
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
183
+ const result = payload?.result || payload?.data?.result || payload?.data || payload || null;
184
+ if (!result || result.ok !== true || !result.center) {
185
+ throw new Error(`Element not found: ${selector}`);
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function isTargetFullyInViewport(target, margin = 6) {
191
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
192
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
193
+ if (!rect || !viewport) return true;
194
+ const vw = Number(viewport.width || 0);
195
+ const vh = Number(viewport.height || 0);
196
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
197
+ const left = Number(rect.left || 0);
198
+ const top = Number(rect.top || 0);
199
+ const width = Math.max(0, Number(rect.width || 0));
200
+ const height = Math.max(0, Number(rect.height || 0));
201
+ const right = left + width;
202
+ const bottom = top + height;
203
+ const m = Math.max(0, Number(margin) || 0);
204
+ return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
205
+ }
206
+
207
+ async function ensureClickTargetInViewport(profileId, selector, initialTarget, options = {}) {
208
+ let target = initialTarget;
209
+ const maxSteps = Math.max(0, Number(options.maxAutoScrollSteps ?? 8) || 8);
210
+ const settleMs = Math.max(0, Number(options.autoScrollSettleMs ?? 140) || 140);
211
+ let autoScrolled = 0;
212
+
213
+ while (autoScrolled < maxSteps && !isTargetFullyInViewport(target)) {
214
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : {};
215
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : {};
216
+ const vw = Math.max(1, Number(viewport.width || 1));
217
+ const vh = Math.max(1, Number(viewport.height || 1));
218
+ const rawCenterY = Number(rect.top || 0) + Math.max(1, Number(rect.height || 0)) / 2;
219
+ const desiredCenterY = clamp(Math.round(vh * 0.45), 80, Math.max(80, vh - 80));
220
+ let deltaY = Math.round(rawCenterY - desiredCenterY);
221
+ deltaY = clamp(deltaY, -900, 900);
222
+ if (Math.abs(deltaY) < 100) {
223
+ deltaY = deltaY >= 0 ? 120 : -120;
224
+ }
225
+ const anchorX = clamp(Math.round(vw / 2), 1, Math.max(1, vw - 1));
226
+ const anchorY = clamp(Math.round(vh / 2), 1, Math.max(1, vh - 1));
227
+
228
+ await callAPI('mouse:move', { profileId, x: anchorX, y: anchorY, steps: 1 }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
229
+ await callAPI('mouse:wheel', { profileId, deltaX: 0, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
230
+ autoScrolled += 1;
231
+ if (settleMs > 0) {
232
+ await sleep(settleMs);
233
+ }
234
+ target = await resolveVisibleTargetPoint(profileId, selector, { highlight: false });
235
+ }
236
+
237
+ return {
238
+ target,
239
+ autoScrolled,
240
+ targetFullyVisible: isTargetFullyInViewport(target),
241
+ };
242
+ }
243
+
80
244
  function validateAlias(alias) {
81
245
  const text = String(alias || '').trim();
82
246
  if (!text) return null;
@@ -378,6 +542,7 @@ export async function handleStartCommand(args) {
378
542
  throw new Error('No default profile set. Run: camo profile default <profileId>');
379
543
  }
380
544
  }
545
+ assertExistingProfile(profileId, profileSet);
381
546
  if (alias && isSessionAliasTaken(alias, profileId)) {
382
547
  throw new Error(`Alias is already in use: ${alias}`);
383
548
  }
@@ -497,17 +662,28 @@ export async function handleStartCommand(args) {
497
662
  source: 'explicit',
498
663
  };
499
664
  } else {
665
+ const display = await callAPI('system:display', {}).catch(() => null);
666
+ const displayTarget = computeStartWindowSize(display);
500
667
  const rememberedWindow = getProfileWindowSize(profileId);
501
668
  if (rememberedWindow) {
502
- windowTarget = {
669
+ const rememberedTarget = {
503
670
  width: rememberedWindow.width,
504
671
  height: rememberedWindow.height,
505
672
  source: 'profile',
506
673
  updatedAt: rememberedWindow.updatedAt,
507
674
  };
675
+ const canTrustDisplayTarget = displayTarget?.source && displayTarget.source !== 'fallback';
676
+ const refreshFromDisplay = canTrustDisplayTarget
677
+ && (
678
+ rememberedTarget.height < Math.floor(displayTarget.height * 0.92)
679
+ || rememberedTarget.width < Math.floor(displayTarget.width * 0.92)
680
+ );
681
+ windowTarget = refreshFromDisplay ? {
682
+ ...displayTarget,
683
+ source: 'display',
684
+ } : rememberedTarget;
508
685
  } else {
509
- const display = await callAPI('system:display', {}).catch(() => null);
510
- windowTarget = computeStartWindowSize(display);
686
+ windowTarget = displayTarget;
511
687
  }
512
688
  }
513
689
 
@@ -704,6 +880,7 @@ export async function handleStatusCommand(args) {
704
880
  export async function handleGotoCommand(args) {
705
881
  await ensureBrowserService();
706
882
  const positionals = getPositionals(args);
883
+ const profileSet = new Set(listProfiles());
707
884
 
708
885
  let profileId;
709
886
  let url;
@@ -718,6 +895,13 @@ export async function handleGotoCommand(args) {
718
895
 
719
896
  if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
720
897
  if (!url) throw new Error('Usage: camo goto [profileId] <url>');
898
+ assertExistingProfile(profileId, profileSet);
899
+ const active = await getSessionByProfile(profileId);
900
+ if (!active) {
901
+ throw new Error(
902
+ `No active session for profile: ${profileId}. Start via "camo start ${profileId}" (or UI CLI startup) before goto.`,
903
+ );
904
+ }
721
905
 
722
906
  const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
723
907
  updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
@@ -768,7 +952,8 @@ export async function handleScrollCommand(args) {
768
952
  const isFlag = (arg) => arg?.startsWith('--');
769
953
  const selectorIdx = args.indexOf('--selector');
770
954
  const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
771
- const highlight = resolveHighlightEnabled(args);
955
+ const highlightRequested = resolveHighlightEnabled(args);
956
+ const highlight = highlightRequested;
772
957
 
773
958
  let profileId = null;
774
959
  for (let i = 1; i < args.length; i++) {
@@ -794,16 +979,15 @@ export async function handleScrollCommand(args) {
794
979
  const target = await callAPI('evaluate', {
795
980
  profileId,
796
981
  script: buildScrollTargetScript({ selector, highlight }),
797
- });
982
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
798
983
  const centerX = Number(target?.result?.center?.x);
799
984
  const centerY = Number(target?.result?.center?.y);
800
985
  if (Number.isFinite(centerX) && Number.isFinite(centerY)) {
801
- await callAPI('mouse:move', { profileId, x: centerX, y: centerY, steps: 2 });
986
+ await callAPI('mouse:move', { profileId, x: centerX, y: centerY, steps: 2 }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
802
987
  }
803
-
804
988
  const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
805
989
  const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
806
- const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY });
990
+ const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
807
991
  console.log(JSON.stringify({
808
992
  ...result,
809
993
  scrollTarget: target?.result || null,
@@ -829,11 +1013,33 @@ export async function handleClickCommand(args) {
829
1013
  if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
830
1014
  if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
831
1015
 
832
- const result = await callAPI('evaluate', {
833
- profileId,
834
- script: buildSelectorClickScript({ selector, highlight }),
1016
+ let target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1017
+ const ensured = await ensureClickTargetInViewport(profileId, selector, target, {
1018
+ maxAutoScrollSteps: 3,
835
1019
  });
836
- console.log(JSON.stringify(result, null, 2));
1020
+ if (!ensured.targetFullyVisible) {
1021
+ throw new Error(`Click target not fully visible after ${ensured.autoScrolled} auto-scroll attempts: ${selector}`);
1022
+ }
1023
+ target = ensured.target;
1024
+ if (highlight) {
1025
+ target = await resolveVisibleTargetPoint(profileId, selector, { highlight: true });
1026
+ }
1027
+ await callAPI('mouse:move', { profileId, x: target.center.x, y: target.center.y, steps: 2 }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1028
+ const result = await callAPI('mouse:click', {
1029
+ profileId,
1030
+ x: target.center.x,
1031
+ y: target.center.y,
1032
+ button: 'left',
1033
+ clicks: 1,
1034
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1035
+ console.log(JSON.stringify({
1036
+ ...result,
1037
+ selector,
1038
+ highlight,
1039
+ autoScrolled: ensured.autoScrolled,
1040
+ targetFullyVisible: ensured.targetFullyVisible,
1041
+ target,
1042
+ }, null, 2));
837
1043
  }
838
1044
 
839
1045
  export async function handleTypeCommand(args) {
@@ -857,15 +1063,36 @@ export async function handleTypeCommand(args) {
857
1063
  if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
858
1064
  if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
859
1065
 
860
- const result = await callAPI('evaluate', {
1066
+ const target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1067
+ await callAPI('mouse:move', { profileId, x: target.center.x, y: target.center.y, steps: 2 }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1068
+ await callAPI('mouse:click', {
861
1069
  profileId,
862
- script: buildSelectorTypeScript({ selector, highlight, text }),
863
- });
864
- console.log(JSON.stringify(result, null, 2));
1070
+ x: target.center.x,
1071
+ y: target.center.y,
1072
+ button: 'left',
1073
+ clicks: 1,
1074
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1075
+ await callAPI('keyboard:press', {
1076
+ profileId,
1077
+ key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
1078
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1079
+ await callAPI('keyboard:press', { profileId, key: 'Backspace' }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1080
+ const result = await callAPI('keyboard:type', {
1081
+ profileId,
1082
+ text: String(text),
1083
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1084
+ console.log(JSON.stringify({
1085
+ ...result,
1086
+ selector,
1087
+ typed: String(text).length,
1088
+ highlight,
1089
+ target,
1090
+ }, null, 2));
865
1091
  }
866
1092
 
867
1093
  export async function handleHighlightCommand(args) {
868
1094
  await ensureBrowserService();
1095
+ ensureJsExecutionEnabled('highlight command');
869
1096
  const positionals = getPositionals(args);
870
1097
  let profileId;
871
1098
  let selector;
@@ -898,6 +1125,7 @@ export async function handleHighlightCommand(args) {
898
1125
 
899
1126
  export async function handleClearHighlightCommand(args) {
900
1127
  await ensureBrowserService();
1128
+ ensureJsExecutionEnabled('clear-highlight command');
901
1129
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
902
1130
  if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
903
1131
 
@@ -2,6 +2,12 @@ import { getDefaultProfile } from '../utils/config.mjs';
2
2
  import { callAPI, ensureBrowserService } from '../utils/browser-service.mjs';
3
3
  import { getPositionals } from '../utils/args.mjs';
4
4
 
5
+ const INPUT_ACTION_TIMEOUT_MS = (() => {
6
+ const parsed = Number(process.env.CAMO_INPUT_ACTION_TIMEOUT_MS);
7
+ if (!Number.isFinite(parsed) || parsed <= 0) return 30000;
8
+ return Math.max(1000, Math.floor(parsed));
9
+ })();
10
+
5
11
  export async function handleMouseCommand(args) {
6
12
  await ensureBrowserService();
7
13
 
@@ -17,7 +23,7 @@ export async function handleMouseCommand(args) {
17
23
  const x = parseInt(args[xIdx + 1]);
18
24
  const y = parseInt(args[yIdx + 1]);
19
25
  const steps = stepsIdx >= 0 ? parseInt(args[stepsIdx + 1]) : undefined;
20
- const result = await callAPI('mouse:move', { profileId, x, y, steps });
26
+ const result = await callAPI('mouse:move', { profileId, x, y, steps }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
21
27
  console.log(JSON.stringify(result, null, 2));
22
28
  } else if (sub === 'click') {
23
29
  // Use existing click command? We already have click command for element clicking.
@@ -33,7 +39,7 @@ export async function handleMouseCommand(args) {
33
39
  const button = buttonIdx >= 0 ? args[buttonIdx + 1] : 'left';
34
40
  const clicks = clicksIdx >= 0 ? parseInt(args[clicksIdx + 1]) : 1;
35
41
  const delay = delayIdx >= 0 ? parseInt(args[delayIdx + 1]) : undefined;
36
- const result = await callAPI('mouse:click', { profileId, x, y, button, clicks, delay });
42
+ const result = await callAPI('mouse:click', { profileId, x, y, button, clicks, delay }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
37
43
  console.log(JSON.stringify(result, null, 2));
38
44
  } else if (sub === 'wheel') {
39
45
  const deltaXIdx = args.indexOf('--deltax');
@@ -41,7 +47,7 @@ export async function handleMouseCommand(args) {
41
47
  if (deltaXIdx === -1 && deltaYIdx === -1) throw new Error('Usage: camo mouse wheel [profileId] [--deltax <px>] [--deltay <px>]');
42
48
  const deltaX = deltaXIdx >= 0 ? parseInt(args[deltaXIdx + 1]) : 0;
43
49
  const deltaY = deltaYIdx >= 0 ? parseInt(args[deltaYIdx + 1]) : 0;
44
- const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY });
50
+ const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
45
51
  console.log(JSON.stringify(result, null, 2));
46
52
  } else {
47
53
  throw new Error('Usage: camo mouse <move|click|wheel> [profileId] [options]');
@@ -1,10 +1,6 @@
1
1
  import { callAPI, getDomSnapshotByProfile } from '../../../utils/browser-service.mjs';
2
+ import { isJsExecutionEnabled } from '../../../utils/js-policy.mjs';
2
3
  import { executeTabPoolOperation } from './tab-pool.mjs';
3
- import {
4
- buildSelectorClickScript,
5
- buildSelectorScrollIntoViewScript,
6
- buildSelectorTypeScript,
7
- } from './selector-scripts.mjs';
8
4
  import { executeViewportOperation } from './viewport.mjs';
9
5
  import {
10
6
  asErrorPayload,
@@ -27,6 +23,23 @@ const VIEWPORT_ACTIONS = new Set([
27
23
  'get_current_url',
28
24
  ]);
29
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
+
36
+ function resolveFilterMode(input) {
37
+ const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
38
+ if (!text) return 'strict';
39
+ if (text === 'legacy') return 'legacy';
40
+ return 'strict';
41
+ }
42
+
30
43
  async function executeExternalOperationIfAny({
31
44
  profileId,
32
45
  action,
@@ -54,6 +67,7 @@ async function executeExternalOperationIfAny({
54
67
  }
55
68
 
56
69
  async function flashOperationViewport(profileId, params = {}) {
70
+ if (!isJsExecutionEnabled()) return;
57
71
  if (params.highlight === false) return;
58
72
  try {
59
73
  await callAPI('evaluate', {
@@ -77,7 +91,272 @@ async function flashOperationViewport(profileId, params = {}) {
77
91
  }
78
92
  }
79
93
 
80
- async function executeSelectorOperation({ profileId, action, operation, params }) {
94
+ function sleep(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
97
+
98
+ function clamp(value, min, max) {
99
+ return Math.min(Math.max(value, min), max);
100
+ }
101
+
102
+ function isTargetFullyInViewport(target, margin = 6) {
103
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
104
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
105
+ if (!rect || !viewport) return true;
106
+ const vw = Number(viewport.width || 0);
107
+ const vh = Number(viewport.height || 0);
108
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
109
+ const left = Number(rect.left || 0);
110
+ const top = Number(rect.top || 0);
111
+ const width = Math.max(0, Number(rect.width || 0));
112
+ const height = Math.max(0, Number(rect.height || 0));
113
+ const right = left + width;
114
+ const bottom = top + height;
115
+ const m = Math.max(0, Number(margin) || 0);
116
+ return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
117
+ }
118
+
119
+ function resolveViewportScrollDelta(target, margin = 6) {
120
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
121
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
122
+ if (!rect || !viewport) return { deltaX: 0, deltaY: 0 };
123
+ const vw = Number(viewport.width || 0);
124
+ const vh = Number(viewport.height || 0);
125
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return { deltaX: 0, deltaY: 0 };
126
+ const left = Number(rect.left || 0);
127
+ const top = Number(rect.top || 0);
128
+ const width = Math.max(0, Number(rect.width || 0));
129
+ const height = Math.max(0, Number(rect.height || 0));
130
+ const right = left + width;
131
+ const bottom = top + height;
132
+ const m = Math.max(0, Number(margin) || 0);
133
+
134
+ let deltaX = 0;
135
+ let deltaY = 0;
136
+
137
+ if (left < m) {
138
+ deltaX = Math.round(left - m);
139
+ } else if (right > (vw - m)) {
140
+ deltaX = Math.round(right - (vw - m));
141
+ }
142
+
143
+ if (top < m) {
144
+ deltaY = Math.round(top - m);
145
+ } else if (bottom > (vh - m)) {
146
+ deltaY = Math.round(bottom - (vh - m));
147
+ }
148
+
149
+ if (Math.abs(deltaY) < 80 && !isTargetFullyInViewport(target, m)) {
150
+ deltaY = deltaY >= 0 ? 120 : -120;
151
+ }
152
+ if (Math.abs(deltaX) < 40 && (left < m || right > (vw - m))) {
153
+ deltaX = deltaX >= 0 ? 60 : -60;
154
+ }
155
+
156
+ return {
157
+ deltaX: clamp(deltaX, -900, 900),
158
+ deltaY: clamp(deltaY, -900, 900),
159
+ };
160
+ }
161
+
162
+ function normalizeRect(node) {
163
+ const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
164
+ if (!rect) return null;
165
+ const left = Number(rect.left ?? rect.x ?? 0);
166
+ const top = Number(rect.top ?? rect.y ?? 0);
167
+ const width = Number(rect.width ?? 0);
168
+ const height = Number(rect.height ?? 0);
169
+ if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) {
170
+ return null;
171
+ }
172
+ if (width <= 0 || height <= 0) return null;
173
+ return { left, top, width, height };
174
+ }
175
+
176
+ function nodeArea(node) {
177
+ const rect = normalizeRect(node);
178
+ if (!rect) return 0;
179
+ return Number(rect.width || 0) * Number(rect.height || 0);
180
+ }
181
+
182
+ function nodeCenter(node, viewport = null) {
183
+ const rect = normalizeRect(node);
184
+ const vw = Number(viewport?.width || 0);
185
+ const vh = Number(viewport?.height || 0);
186
+ if (!rect) return null;
187
+ const rawX = rect.left + Math.max(1, rect.width / 2);
188
+ const rawY = rect.top + Math.max(1, rect.height / 2);
189
+ const centerX = vw > 1
190
+ ? clamp(Math.round(rawX), 1, Math.max(1, vw - 1))
191
+ : Math.max(1, Math.round(rawX));
192
+ const centerY = vh > 1
193
+ ? clamp(Math.round(rawY), 1, Math.max(1, vh - 1))
194
+ : Math.max(1, Math.round(rawY));
195
+ return {
196
+ center: { x: centerX, y: centerY },
197
+ rawCenter: { x: rawX, y: rawY },
198
+ rect,
199
+ };
200
+ }
201
+
202
+ function getSnapshotViewport(snapshot) {
203
+ const width = Number(snapshot?.__viewport?.width || 0);
204
+ const height = Number(snapshot?.__viewport?.height || 0);
205
+ return { width, height };
206
+ }
207
+
208
+ function isPathWithin(path, parentPath) {
209
+ const child = String(path || '').trim();
210
+ const parent = String(parentPath || '').trim();
211
+ if (!child || !parent) return false;
212
+ return child === parent || child.startsWith(`${parent}/`);
213
+ }
214
+
215
+ function resolveActiveModal(snapshot) {
216
+ if (!snapshot) return null;
217
+ const rows = [];
218
+ for (const selector of DEFAULT_MODAL_SELECTORS) {
219
+ const matches = buildSelectorCheck(snapshot, { css: selector, visible: true });
220
+ for (const node of matches) {
221
+ if (nodeArea(node) <= 1) continue;
222
+ rows.push({
223
+ selector,
224
+ path: String(node.path || ''),
225
+ node,
226
+ area: nodeArea(node),
227
+ });
228
+ }
229
+ }
230
+ rows.sort((a, b) => b.area - a.area);
231
+ return rows[0] || null;
232
+ }
233
+
234
+ async function resolveSelectorTarget(profileId, selector, options = {}) {
235
+ const filterMode = resolveFilterMode(options.filterMode);
236
+ const strictFilter = filterMode !== 'legacy';
237
+ const normalizedSelector = String(selector || '').trim();
238
+ const snapshot = await getDomSnapshotByProfile(profileId);
239
+ const viewport = getSnapshotViewport(snapshot);
240
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
241
+ const visibleMatches = buildSelectorCheck(snapshot, { css: normalizedSelector, visible: true });
242
+ const allMatches = strictFilter
243
+ ? visibleMatches
244
+ : buildSelectorCheck(snapshot, { css: normalizedSelector, visible: false });
245
+ const scopedVisible = modal
246
+ ? visibleMatches.filter((item) => isPathWithin(item.path, modal.path))
247
+ : visibleMatches;
248
+ const scopedAll = modal
249
+ ? allMatches.filter((item) => isPathWithin(item.path, modal.path))
250
+ : allMatches;
251
+ const candidate = strictFilter
252
+ ? (scopedVisible[0] || null)
253
+ : (scopedVisible[0] || scopedAll[0] || null);
254
+ if (!candidate) {
255
+ if (modal) {
256
+ throw new Error(`Modal focus locked for selector: ${normalizedSelector}`);
257
+ }
258
+ throw new Error(`Element not found: ${normalizedSelector}`);
259
+ }
260
+ const center = nodeCenter(candidate, viewport);
261
+ if (!center) {
262
+ throw new Error(`Element not found: ${normalizedSelector}`);
263
+ }
264
+ return {
265
+ ok: true,
266
+ selector: normalizedSelector,
267
+ matchedIndex: Math.max(0, scopedAll.indexOf(candidate)),
268
+ center: center.center,
269
+ rawCenter: center.rawCenter,
270
+ rect: center.rect,
271
+ viewport,
272
+ modalLocked: Boolean(modal),
273
+ };
274
+ }
275
+
276
+ async function scrollTargetIntoViewport(profileId, selector, initialTarget, params = {}, options = {}) {
277
+ let target = initialTarget;
278
+ const maxSteps = Math.max(0, Math.min(24, Number(params.maxScrollSteps ?? 8) || 8));
279
+ const settleMs = Math.max(0, Number(params.scrollSettleMs ?? 140) || 140);
280
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
281
+ for (let i = 0; i < maxSteps; i += 1) {
282
+ if (isTargetFullyInViewport(target, visibilityMargin)) break;
283
+ const delta = resolveViewportScrollDelta(target, visibilityMargin);
284
+ if (Math.abs(delta.deltaX) < 1 && Math.abs(delta.deltaY) < 1) break;
285
+ const anchorX = clamp(Math.round(Number(target?.center?.x || 0) || 1), 1, Math.max(1, Number(target?.viewport?.width || 1) - 1));
286
+ const anchorY = clamp(Math.round(Number(target?.center?.y || 0) || 1), 1, Math.max(1, Number(target?.viewport?.height || 1) - 1));
287
+ await callAPI('mouse:move', { profileId, x: anchorX, y: anchorY, steps: 1 });
288
+ await callAPI('mouse:wheel', { profileId, deltaX: delta.deltaX, deltaY: delta.deltaY });
289
+ if (settleMs > 0) await sleep(settleMs);
290
+ target = await resolveSelectorTarget(profileId, selector, options);
291
+ }
292
+ return target;
293
+ }
294
+
295
+ async function resolveScrollAnchor(profileId, options = {}) {
296
+ const filterMode = resolveFilterMode(options.filterMode);
297
+ const strictFilter = filterMode !== 'legacy';
298
+ const selector = String(options.selector || '').trim();
299
+ const snapshot = await getDomSnapshotByProfile(profileId);
300
+ const viewport = getSnapshotViewport(snapshot);
301
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
302
+
303
+ if (selector) {
304
+ const visibleMatches = buildSelectorCheck(snapshot, { css: selector, visible: true });
305
+ const target = visibleMatches[0] || null;
306
+ if (target) {
307
+ if (modal && !isPathWithin(target.path, modal.path)) {
308
+ const modalCenter = nodeCenter(modal.node, viewport);
309
+ if (modalCenter) {
310
+ return {
311
+ ok: true,
312
+ source: 'modal',
313
+ center: modalCenter.center,
314
+ modalLocked: true,
315
+ modalSelector: modal.selector,
316
+ selectorRejectedByModalLock: true,
317
+ };
318
+ }
319
+ } else {
320
+ const targetCenter = nodeCenter(target, viewport);
321
+ if (targetCenter) {
322
+ return {
323
+ ok: true,
324
+ source: 'selector',
325
+ center: targetCenter.center,
326
+ modalLocked: Boolean(modal),
327
+ };
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ if (modal) {
334
+ const modalCenter = nodeCenter(modal.node, viewport);
335
+ if (modalCenter) {
336
+ return {
337
+ ok: true,
338
+ source: 'modal',
339
+ center: modalCenter.center,
340
+ modalLocked: true,
341
+ modalSelector: modal.selector,
342
+ };
343
+ }
344
+ }
345
+
346
+ const width = Number(viewport.width || 0);
347
+ const height = Number(viewport.height || 0);
348
+ return {
349
+ ok: true,
350
+ source: 'document',
351
+ center: {
352
+ x: width > 1 ? Math.round(width / 2) : 1,
353
+ y: height > 1 ? Math.round(height / 2) : 1,
354
+ },
355
+ modalLocked: false,
356
+ };
357
+ }
358
+
359
+ async function executeSelectorOperation({ profileId, action, operation, params, filterMode }) {
81
360
  const selector = maybeSelector({
82
361
  profileId,
83
362
  containerId: params.containerId || operation?.containerId || null,
@@ -85,22 +364,75 @@ async function executeSelectorOperation({ profileId, action, operation, params }
85
364
  });
86
365
  if (!selector) return asErrorPayload('CONTAINER_NOT_FOUND', `${action} requires selector/containerId`);
87
366
 
88
- const highlight = params.highlight !== false;
367
+ let target = await resolveSelectorTarget(profileId, selector, { filterMode });
368
+ target = await scrollTargetIntoViewport(profileId, selector, target, params, { filterMode });
369
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
370
+ const targetFullyVisible = isTargetFullyInViewport(target, visibilityMargin);
371
+ if (action === 'click' && !targetFullyVisible) {
372
+ return asErrorPayload('TARGET_NOT_FULLY_VISIBLE', 'click target is not fully visible after auto scroll', {
373
+ selector,
374
+ target,
375
+ visibilityMargin,
376
+ });
377
+ }
378
+
379
+ await callAPI('mouse:move', { profileId, x: target.center.x, y: target.center.y, steps: 2 });
380
+
89
381
  if (action === 'scroll_into_view') {
90
- const script = buildSelectorScrollIntoViewScript({ selector, highlight });
91
- const result = await callAPI('evaluate', {
382
+ return {
383
+ ok: true,
384
+ code: 'OPERATION_DONE',
385
+ message: 'scroll_into_view done',
386
+ data: { selector, target, targetFullyVisible, visibilityMargin },
387
+ };
388
+ }
389
+
390
+ if (action === 'click') {
391
+ const button = String(params.button || 'left').trim() || 'left';
392
+ const clicks = Math.max(1, Number(params.clicks ?? 1) || 1);
393
+ const delay = Number(params.delay);
394
+ const result = await callAPI('mouse:click', {
92
395
  profileId,
93
- script,
396
+ x: target.center.x,
397
+ y: target.center.y,
398
+ button,
399
+ clicks,
400
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
94
401
  });
95
- return { ok: true, code: 'OPERATION_DONE', message: 'scroll_into_view done', data: result };
402
+ return { ok: true, code: 'OPERATION_DONE', message: 'click done', data: { selector, target, result, targetFullyVisible, visibilityMargin } };
96
403
  }
97
404
 
98
- const typeText = String(params.text ?? params.value ?? '');
99
- const script = action === 'click'
100
- ? buildSelectorClickScript({ selector, highlight })
101
- : buildSelectorTypeScript({ selector, highlight, text: typeText });
102
- const result = await callAPI('evaluate', { profileId, script });
103
- return { ok: true, code: 'OPERATION_DONE', message: `${action} done`, data: result };
405
+ const text = String(params.text ?? params.value ?? '');
406
+ await callAPI('mouse:click', {
407
+ profileId,
408
+ x: target.center.x,
409
+ y: target.center.y,
410
+ button: 'left',
411
+ clicks: 1,
412
+ });
413
+ const clearBeforeType = params.clear !== false;
414
+ if (clearBeforeType) {
415
+ await callAPI('keyboard:press', {
416
+ profileId,
417
+ key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
418
+ });
419
+ await callAPI('keyboard:press', { profileId, key: 'Backspace' });
420
+ }
421
+ const delay = Number(params.keyDelayMs ?? params.delay);
422
+ await callAPI('keyboard:type', {
423
+ profileId,
424
+ text,
425
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
426
+ });
427
+ if (params.pressEnter === true) {
428
+ await callAPI('keyboard:press', { profileId, key: 'Enter' });
429
+ }
430
+ return {
431
+ ok: true,
432
+ code: 'OPERATION_DONE',
433
+ message: 'type done',
434
+ data: { selector, target, length: text.length },
435
+ };
104
436
  }
105
437
 
106
438
  async function executeVerifySubscriptions({ profileId, params }) {
@@ -200,6 +532,13 @@ export async function executeOperation({ profileId, operation, context = {} }) {
200
532
  const resolvedProfile = session.profileId || profileId;
201
533
  const action = String(operation?.action || '').trim();
202
534
  const params = operation?.params || operation?.config || {};
535
+ const filterMode = resolveFilterMode(
536
+ params.filterMode
537
+ || operation?.filterMode
538
+ || context?.filterMode
539
+ || context?.runtime?.filterMode
540
+ || null,
541
+ );
203
542
 
204
543
  if (!action) {
205
544
  return asErrorPayload('OPERATION_FAILED', 'operation.action is required');
@@ -279,38 +618,49 @@ export async function executeOperation({ profileId, operation, context = {} }) {
279
618
  deltaX = amount;
280
619
  deltaY = 0;
281
620
  }
621
+ const anchorSelector = maybeSelector({
622
+ profileId: resolvedProfile,
623
+ containerId: params.containerId || operation?.containerId || null,
624
+ selector: params.selector || operation?.selector || null,
625
+ });
626
+ const anchor = await resolveScrollAnchor(resolvedProfile, {
627
+ selector: anchorSelector,
628
+ filterMode,
629
+ });
630
+ if (anchor?.center?.x && anchor?.center?.y) {
631
+ await callAPI('mouse:move', {
632
+ profileId: resolvedProfile,
633
+ x: Math.max(1, Math.round(Number(anchor.center.x) || 1)),
634
+ y: Math.max(1, Math.round(Number(anchor.center.y) || 1)),
635
+ steps: 2,
636
+ });
637
+ }
282
638
  const result = await callAPI('mouse:wheel', { profileId: resolvedProfile, deltaX, deltaY });
283
639
  return {
284
640
  ok: true,
285
641
  code: 'OPERATION_DONE',
286
642
  message: 'scroll done',
287
- data: { direction, amount, deltaX, deltaY, result },
643
+ data: {
644
+ direction,
645
+ amount,
646
+ deltaX,
647
+ deltaY,
648
+ filterMode,
649
+ anchorSource: String(anchor?.source || 'document'),
650
+ modalLocked: anchor?.modalLocked === true,
651
+ result,
652
+ },
288
653
  };
289
654
  }
290
655
 
291
656
  if (action === 'press_key') {
292
657
  const key = String(params.key || params.value || '').trim();
293
658
  if (!key) return asErrorPayload('OPERATION_FAILED', 'press_key requires params.key');
294
- const result = await callAPI('evaluate', {
659
+ const delay = Number(params.delay);
660
+ const result = await callAPI('keyboard:press', {
295
661
  profileId: resolvedProfile,
296
- script: `(async () => {
297
- const target = document.activeElement || document.body || document.documentElement;
298
- const key = ${JSON.stringify(key)};
299
- const code = key.length === 1 ? 'Key' + key.toUpperCase() : key;
300
- const opts = { key, code, bubbles: true, cancelable: true };
301
- target.dispatchEvent(new KeyboardEvent('keydown', opts));
302
- target.dispatchEvent(new KeyboardEvent('keypress', opts));
303
- target.dispatchEvent(new KeyboardEvent('keyup', opts));
304
- if (key === 'Escape') {
305
- const closeButton = document.querySelector('.note-detail-mask .close-box, .note-detail-mask .close-circle');
306
- if (closeButton instanceof HTMLElement) closeButton.click();
307
- }
308
- if (key === 'Enter' && target instanceof HTMLInputElement && target.form) {
309
- if (typeof target.form.requestSubmit === 'function') target.form.requestSubmit();
310
- else target.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
311
- }
312
- return { key, targetTag: target?.tagName || null };
313
- })()`,
662
+ key,
663
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
314
664
  });
315
665
  return { ok: true, code: 'OPERATION_DONE', message: 'press_key done', data: result };
316
666
  }
@@ -320,6 +670,9 @@ export async function executeOperation({ profileId, operation, context = {} }) {
320
670
  }
321
671
 
322
672
  if (action === 'evaluate') {
673
+ if (!isJsExecutionEnabled()) {
674
+ return asErrorPayload('JS_DISABLED', 'evaluate is disabled by default. Re-run camo command with --js.');
675
+ }
323
676
  const script = String(params.script || '').trim();
324
677
  if (!script) return asErrorPayload('OPERATION_FAILED', 'evaluate requires params.script');
325
678
  const result = await callAPI('evaluate', { profileId: resolvedProfile, script });
@@ -327,11 +680,12 @@ export async function executeOperation({ profileId, operation, context = {} }) {
327
680
  }
328
681
 
329
682
  if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
330
- return executeSelectorOperation({
683
+ return await executeSelectorOperation({
331
684
  profileId: resolvedProfile,
332
685
  action,
333
686
  operation,
334
687
  params,
688
+ filterMode,
335
689
  });
336
690
  }
337
691
 
@@ -1,17 +1,36 @@
1
1
  import { getDomSnapshotByProfile } from '../../utils/browser-service.mjs';
2
2
  import { ChangeNotifier } from '../change-notifier.mjs';
3
- import { ensureActiveSession, normalizeArray } from './utils.mjs';
3
+ import { ensureActiveSession, getCurrentUrl, normalizeArray } from './utils.mjs';
4
+
5
+ function resolveFilterMode(input) {
6
+ const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
7
+ if (!text) return 'strict';
8
+ if (text === 'legacy') return 'legacy';
9
+ return 'strict';
10
+ }
11
+
12
+ function urlMatchesFilter(url, item) {
13
+ const href = String(url || '').trim();
14
+ const includes = normalizeArray(item?.pageUrlIncludes).map((token) => String(token || '').trim()).filter(Boolean);
15
+ const excludes = normalizeArray(item?.pageUrlExcludes).map((token) => String(token || '').trim()).filter(Boolean);
16
+ if (includes.length > 0 && !includes.every((token) => href.includes(token))) return false;
17
+ if (excludes.length > 0 && excludes.some((token) => href.includes(token))) return false;
18
+ return true;
19
+ }
4
20
 
5
21
  export async function watchSubscriptions({
6
22
  profileId,
7
23
  subscriptions,
8
24
  throttle = 500,
25
+ filterMode = 'strict',
9
26
  onEvent = () => {},
10
27
  onError = () => {},
11
28
  }) {
12
29
  const session = await ensureActiveSession(profileId);
13
30
  const resolvedProfile = session.profileId || profileId;
14
31
  const notifier = new ChangeNotifier();
32
+ const effectiveFilterMode = resolveFilterMode(filterMode);
33
+ const strictFilter = effectiveFilterMode === 'strict';
15
34
  const items = normalizeArray(subscriptions)
16
35
  .map((item, index) => {
17
36
  if (!item || typeof item !== 'object') return null;
@@ -19,7 +38,16 @@ export async function watchSubscriptions({
19
38
  const selector = String(item.selector || '').trim();
20
39
  if (!selector) return null;
21
40
  const events = normalizeArray(item.events).map((name) => String(name).trim()).filter(Boolean);
22
- return { id, selector, events: events.length > 0 ? new Set(events) : null };
41
+ const pageUrlIncludes = normalizeArray(item.pageUrlIncludes).map((token) => String(token || '').trim()).filter(Boolean);
42
+ const pageUrlExcludes = normalizeArray(item.pageUrlExcludes).map((token) => String(token || '').trim()).filter(Boolean);
43
+ return {
44
+ id,
45
+ selector,
46
+ visible: strictFilter ? true : (item.visible !== false),
47
+ pageUrlIncludes,
48
+ pageUrlExcludes,
49
+ events: events.length > 0 ? new Set(events) : null,
50
+ };
23
51
  })
24
52
  .filter(Boolean);
25
53
 
@@ -39,10 +67,14 @@ export async function watchSubscriptions({
39
67
  if (stopped) return;
40
68
  try {
41
69
  const snapshot = await getDomSnapshotByProfile(resolvedProfile);
70
+ const currentUrl = await getCurrentUrl(resolvedProfile).catch(() => '');
42
71
  const ts = new Date().toISOString();
43
72
  for (const item of items) {
44
73
  const prev = state.get(item.id) || { exists: false, stateSig: '', appearCount: 0 };
45
- const elements = notifier.findElements(snapshot, { css: item.selector });
74
+ const urlMatched = urlMatchesFilter(currentUrl, item);
75
+ const elements = urlMatched
76
+ ? notifier.findElements(snapshot, { css: item.selector, visible: item.visible })
77
+ : [];
46
78
  const exists = elements.length > 0;
47
79
  const stateSig = elements.map((node) => node.path).sort().join(',');
48
80
  const changed = stateSig !== prev.stateSig;
@@ -55,16 +87,56 @@ export async function watchSubscriptions({
55
87
 
56
88
  const shouldEmit = (type) => !item.events || item.events.has(type);
57
89
  if (exists && !prev.exists && shouldEmit('appear')) {
58
- await emit({ type: 'appear', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
90
+ await emit({
91
+ type: 'appear',
92
+ profileId: resolvedProfile,
93
+ subscriptionId: item.id,
94
+ selector: item.selector,
95
+ count: elements.length,
96
+ elements,
97
+ pageUrl: currentUrl,
98
+ filterMode: effectiveFilterMode,
99
+ timestamp: ts,
100
+ });
59
101
  }
60
102
  if (!exists && prev.exists && shouldEmit('disappear')) {
61
- await emit({ type: 'disappear', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: 0, elements: [], timestamp: ts });
103
+ await emit({
104
+ type: 'disappear',
105
+ profileId: resolvedProfile,
106
+ subscriptionId: item.id,
107
+ selector: item.selector,
108
+ count: 0,
109
+ elements: [],
110
+ pageUrl: currentUrl,
111
+ filterMode: effectiveFilterMode,
112
+ timestamp: ts,
113
+ });
62
114
  }
63
115
  if (exists && shouldEmit('exist')) {
64
- await emit({ type: 'exist', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
116
+ await emit({
117
+ type: 'exist',
118
+ profileId: resolvedProfile,
119
+ subscriptionId: item.id,
120
+ selector: item.selector,
121
+ count: elements.length,
122
+ elements,
123
+ pageUrl: currentUrl,
124
+ filterMode: effectiveFilterMode,
125
+ timestamp: ts,
126
+ });
65
127
  }
66
128
  if (changed && shouldEmit('change')) {
67
- await emit({ type: 'change', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
129
+ await emit({
130
+ type: 'change',
131
+ profileId: resolvedProfile,
132
+ subscriptionId: item.id,
133
+ selector: item.selector,
134
+ count: elements.length,
135
+ elements,
136
+ pageUrl: currentUrl,
137
+ filterMode: effectiveFilterMode,
138
+ timestamp: ts,
139
+ });
68
140
  }
69
141
  }
70
142
  await emit({ type: 'tick', profileId: resolvedProfile, timestamp: ts });
@@ -9,6 +9,31 @@ import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
9
9
  import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
10
10
 
11
11
  const require = createRequire(import.meta.url);
12
+ const DEFAULT_API_TIMEOUT_MS = 30000;
13
+
14
+ function resolveApiTimeoutMs(options = {}) {
15
+ const optionValue = Number(options?.timeoutMs);
16
+ if (Number.isFinite(optionValue) && optionValue > 0) {
17
+ return Math.max(1000, Math.floor(optionValue));
18
+ }
19
+ const envValue = Number(process.env.CAMO_API_TIMEOUT_MS);
20
+ if (Number.isFinite(envValue) && envValue > 0) {
21
+ return Math.max(1000, Math.floor(envValue));
22
+ }
23
+ return DEFAULT_API_TIMEOUT_MS;
24
+ }
25
+
26
+ function isTimeoutError(error) {
27
+ const name = String(error?.name || '').toLowerCase();
28
+ const message = String(error?.message || '').toLowerCase();
29
+ return (
30
+ name.includes('timeout')
31
+ || name.includes('abort')
32
+ || message.includes('timeout')
33
+ || message.includes('timed out')
34
+ || message.includes('aborted')
35
+ );
36
+ }
12
37
 
13
38
  function shouldTrackSessionActivity(action, payload) {
14
39
  const profileId = String(payload?.profileId || '').trim();
@@ -17,12 +42,22 @@ function shouldTrackSessionActivity(action, payload) {
17
42
  return true;
18
43
  }
19
44
 
20
- export async function callAPI(action, payload = {}) {
21
- const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
22
- method: 'POST',
23
- headers: { 'Content-Type': 'application/json' },
24
- body: JSON.stringify({ action, args: payload }),
25
- });
45
+ export async function callAPI(action, payload = {}, options = {}) {
46
+ const timeoutMs = resolveApiTimeoutMs(options);
47
+ let r;
48
+ try {
49
+ r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ action, args: payload }),
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ });
55
+ } catch (error) {
56
+ if (isTimeoutError(error)) {
57
+ throw new Error(`browser-service timeout after ${timeoutMs}ms: ${action}`);
58
+ }
59
+ throw error;
60
+ }
26
61
 
27
62
  let body;
28
63
  try {
@@ -0,0 +1,13 @@
1
+ function isTruthy(value) {
2
+ const text = String(value || '').trim().toLowerCase();
3
+ return ['1', 'true', 'yes', 'on'].includes(text);
4
+ }
5
+
6
+ export function isJsExecutionEnabled() {
7
+ return isTruthy(process.env.CAMO_ALLOW_JS);
8
+ }
9
+
10
+ export function ensureJsExecutionEnabled(scope = 'JavaScript execution') {
11
+ if (isJsExecutionEnabled()) return;
12
+ throw new Error(`${scope} is disabled by default. Re-run with --js to enable.`);
13
+ }