@web-auto/camo 0.1.25 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +1 -1
- package/src/commands/browser.mjs +2 -4
- package/src/container/runtime-core/validation.mjs +4 -61
- package/src/services/browser-service/index.js +11 -14
- package/src/services/browser-service/internal/BrowserSession.js +2 -34
- package/src/services/browser-service/internal/browser-session/input-ops.js +74 -11
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +3 -1
- package/src/services/browser-service/internal/browser-session/page-management.js +4 -38
- package/src/services/browser-service/internal/browser-session/utils.js +6 -0
- package/src/utils/help.mjs +1 -1
- 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
package/README.md
CHANGED
|
@@ -473,6 +473,7 @@ Condition types:
|
|
|
473
473
|
|
|
474
474
|
### Environment Variables
|
|
475
475
|
|
|
476
|
+
- `CAMO_INPUT_MODE` - Input mode: `playwright` (default) or `cdp`. CDP mode uses `Input.dispatchMouseEvent` via Chrome DevTools Protocol, bypassing OS-level input system. Does not require window foreground. See [CDP Input Mode](#cdp-input-mode) below.
|
|
476
477
|
- `CAMO_BROWSER_URL` - Browser service URL (default: `http://127.0.0.1:7704`)
|
|
477
478
|
- `CAMO_INSTALL_DIR` - `@web-auto/camo` 安装目录(可选,首次安装兜底)
|
|
478
479
|
- `CAMO_REPO_ROOT` - Camo repository root (optional, dev mode)
|
|
@@ -484,6 +485,51 @@ Condition types:
|
|
|
484
485
|
- `CAMO_PROGRESS_WS_HOST` / `CAMO_PROGRESS_WS_PORT` - Progress websocket daemon bind address (default: `127.0.0.1:7788`)
|
|
485
486
|
- `CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE` - Reserved vertical pixels for default headful auto-size
|
|
486
487
|
|
|
488
|
+
### CDP Input Mode
|
|
489
|
+
|
|
490
|
+
By default, Camo uses Playwright's high-level input API (`page.mouse.click`), which goes through the OS input system and requires the browser window to be in the foreground. This can cause hangs (up to 30s timeout) on Windows when the window loses focus.
|
|
491
|
+
|
|
492
|
+
CDP mode sends mouse events directly via the Chrome DevTools Protocol (`Input.dispatchMouseEvent`), which:
|
|
493
|
+
|
|
494
|
+
- **Does not require window foreground** — works with minimized, background, or headless windows
|
|
495
|
+
- **Does not depend on OS input system** — no `bringToFront`, no `ensureInputReady`
|
|
496
|
+
- **Bypasses input pipeline checks** — no 30s timeout risk from `ensureInputReady` hanging
|
|
497
|
+
|
|
498
|
+
#### How to enable
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
# Environment variable (recommended)
|
|
502
|
+
CAMO_INPUT_MODE=cdp camo start xhs-qa-1 --url https://www.xiaohongshu.com
|
|
503
|
+
|
|
504
|
+
# Or set in shell profile
|
|
505
|
+
export CAMO_INPUT_MODE=cdp
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
#### Behavior differences
|
|
509
|
+
|
|
510
|
+
| Feature | Playwright (default) | CDP mode |
|
|
511
|
+
|---------|---------------------|----------|
|
|
512
|
+
| Window foreground required | Yes | No |
|
|
513
|
+
| OS input system | Yes | No |
|
|
514
|
+
| Auto-scroll to element | Yes (via Playwright) | No (caller must ensure element in viewport) |
|
|
515
|
+
| `ensureInputReady` check | Yes (can hang 30s) | Skipped |
|
|
516
|
+
| `bringToFront` | Yes (default) | Skipped |
|
|
517
|
+
| Nudge/recovery on timeout | Yes | No (fast fail) |
|
|
518
|
+
| Input coordinate system | Viewport-relative | Viewport-relative (same) |
|
|
519
|
+
|
|
520
|
+
#### Limitations
|
|
521
|
+
|
|
522
|
+
- **Element must be in viewport**: CDP clicks at coordinates only. If the target element is scrolled out of view, the click will miss. Callers (like webauto's `clickPoint`) already resolve viewport-relative coordinates via `getBoundingClientRect`.
|
|
523
|
+
- **No auto-scroll**: Unlike Playwright's `page.click(selector)`, CDP mode does not scroll to bring elements into view.
|
|
524
|
+
- **keyboard operations still use Playwright**: `keyboard:press` and `keyboard:type` are not affected by CDP mode (they already work reliably in background via Playwright's keyboard API).
|
|
525
|
+
|
|
526
|
+
#### Related environment variables
|
|
527
|
+
|
|
528
|
+
- `CAMO_INPUT_ACTION_TIMEOUT_MS` — Max wait for input action (default: 30000)
|
|
529
|
+
- `CAMO_INPUT_ACTION_MAX_ATTEMPTS` — Retry count on failure (default: 2)
|
|
530
|
+
- `CAMO_INPUT_READY_SETTLE_MS` — Settle time after input ready (default: 80)
|
|
531
|
+
- `CAMO_BRING_TO_FRONT_MODE` — `never` (skip) or `auto` (default, bring window to front)
|
|
532
|
+
|
|
487
533
|
## Session Persistence
|
|
488
534
|
|
|
489
535
|
Camo CLI persists session information locally:
|
package/package.json
CHANGED
package/src/commands/browser.mjs
CHANGED
|
@@ -496,7 +496,6 @@ export async function handleStartCommand(args) {
|
|
|
496
496
|
const alias = validateAlias(readFlagValue(args, ['--alias']));
|
|
497
497
|
const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
|
|
498
498
|
const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
|
|
499
|
-
const maxTabs = Math.max(1, Math.floor(Number(readFlagValue(args, ['--max-tabs']) || 1) || 1));
|
|
500
499
|
const wantsDevtools = args.includes('--devtools');
|
|
501
500
|
const wantsRecord = args.includes('--record');
|
|
502
501
|
const recordName = readFlagValue(args, ['--record-name']);
|
|
@@ -507,7 +506,7 @@ export async function handleStartCommand(args) {
|
|
|
507
506
|
? true
|
|
508
507
|
: null;
|
|
509
508
|
if (hasExplicitWidth !== hasExplicitHeight) {
|
|
510
|
-
throw new Error('Usage: camo start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>
|
|
509
|
+
throw new Error('Usage: camo start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
|
|
511
510
|
}
|
|
512
511
|
if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
|
|
513
512
|
throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
|
|
@@ -528,7 +527,7 @@ export async function handleStartCommand(args) {
|
|
|
528
527
|
const arg = args[i];
|
|
529
528
|
if (arg === '--url') { i++; continue; }
|
|
530
529
|
if (arg === '--width' || arg === '--height') { i++; continue; }
|
|
531
|
-
if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output'
|
|
530
|
+
if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
|
|
532
531
|
if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
|
|
533
532
|
if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
|
|
534
533
|
if (arg.startsWith('--')) continue;
|
|
@@ -628,7 +627,6 @@ export async function handleStartCommand(args) {
|
|
|
628
627
|
headless,
|
|
629
628
|
devtools: wantsDevtools,
|
|
630
629
|
...(wantsRecord ? { record: true } : {}),
|
|
631
|
-
...(Number.isFinite(maxTabs) ? { maxTabs } : {}),
|
|
632
630
|
...(recordName ? { recordName } : {}),
|
|
633
631
|
...(recordOutput ? { recordOutput } : {}),
|
|
634
632
|
...(recordOverlay !== null ? { recordOverlay } : {}),
|
|
@@ -26,21 +26,13 @@ async function validatePage(profileId, spec = {}, platform = 'generic') {
|
|
|
26
26
|
errors.push(`url host mismatch, expected one of: ${hostIncludes.join(',')}`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// 锚点驱动的 checkpoint 检查
|
|
30
29
|
const checkpoints = normalizeArray(spec.checkpointIn || []);
|
|
31
30
|
let checkpoint = null;
|
|
32
31
|
if (checkpoints.length > 0) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
intervalMs: 500,
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
if (anchorResult.found) {
|
|
41
|
-
checkpoint = checkpoints[0]; // 锚点存在,使用第一个 checkpoint
|
|
42
|
-
} else {
|
|
43
|
-
errors.push(`anchor not found within ${anchorResult.elapsed}ms, expected one of: ${checkpoints.join(',')}`);
|
|
32
|
+
const detected = await detectCheckpoint({ profileId, platform });
|
|
33
|
+
checkpoint = detected?.data?.checkpoint || null;
|
|
34
|
+
if (!checkpoints.includes(checkpoint)) {
|
|
35
|
+
errors.push(`checkpoint mismatch: got ${checkpoint}, expect one of ${checkpoints.join(',')}`);
|
|
44
36
|
}
|
|
45
37
|
}
|
|
46
38
|
|
|
@@ -50,29 +42,8 @@ async function validatePage(profileId, spec = {}, platform = 'generic') {
|
|
|
50
42
|
checkpoint,
|
|
51
43
|
errors,
|
|
52
44
|
};
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// 添加 getCheckpointSelectors 辅助函数
|
|
56
|
-
function getCheckpointSelectors(checkpoints, platform = 'generic') {
|
|
57
|
-
const XHS_CHECKPOINTS = {
|
|
58
|
-
search_ready: ['#search-input', 'input.search-input', '.search-result-list'],
|
|
59
|
-
home_ready: ['.note-item', '.note-item a', 'a[href*="/explore/"]', '[class*="note-item"]'],
|
|
60
|
-
detail_ready: ['.note-scroller', '.note-content', '.interaction-container'],
|
|
61
|
-
comments_ready: ['.comments-container', '.comment-item'],
|
|
62
|
-
login_guard: ['.login-container', '.login-dialog', '#login-container'],
|
|
63
|
-
risk_control: ['.qrcode-box', '.captcha-container', '[class*="captcha"]'],
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const selectors = [];
|
|
67
|
-
for (const cp of checkpoints) {
|
|
68
|
-
if (platform === 'xiaohongshu' && XHS_CHECKPOINTS[cp]) {
|
|
69
|
-
selectors.push(...XHS_CHECKPOINTS[cp]);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return selectors.length > 0 ? selectors : checkpoints;
|
|
73
45
|
}
|
|
74
46
|
|
|
75
|
-
|
|
76
47
|
async function validateContainer(profileId, spec = {}) {
|
|
77
48
|
const snapshot = await getDomSnapshotByProfile(profileId);
|
|
78
49
|
const selector = maybeSelector({
|
|
@@ -154,31 +125,3 @@ export async function validateOperation({
|
|
|
154
125
|
return asErrorPayload('VALIDATION_FAILED', err?.message || String(err), { phase, context });
|
|
155
126
|
}
|
|
156
127
|
}
|
|
157
|
-
|
|
158
|
-
// 锚点驱动的验证:轮询容器 selector,而非 evaluate
|
|
159
|
-
async function validateAnchors(profileId, selectors = [], options = {}) {
|
|
160
|
-
const maxMs = Math.max(1000, Number(options.timeoutMs || 30000));
|
|
161
|
-
const intervalMs = Math.max(200, Number(options.intervalMs || 500));
|
|
162
|
-
const startTime = Date.now();
|
|
163
|
-
|
|
164
|
-
while (Date.now() - startTime < maxMs) {
|
|
165
|
-
try {
|
|
166
|
-
const snapshot = await getDomSnapshotByProfile(profileId, { maxDepth: 5, maxChildren: 50 });
|
|
167
|
-
for (const selector of selectors) {
|
|
168
|
-
const matched = buildSelectorCheck(snapshot, selector);
|
|
169
|
-
if (matched.length > 0) {
|
|
170
|
-
return { ok: true, found: true, selector, count: matched.length, elapsed: Date.now() - startTime };
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
} catch (err) {
|
|
174
|
-
// 忽略 snapshot 错误,继续轮询
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// 等待下次检查
|
|
178
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return { ok: false, found: false, elapsed: maxMs };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export { validateAnchors };
|
|
@@ -305,19 +305,16 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
|
|
|
305
305
|
switch (action) {
|
|
306
306
|
case 'start': {
|
|
307
307
|
const startViewport = resolveStartViewport(args);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (Number.isFinite(args.maxTabs) && args.maxTabs >= 1) {
|
|
319
|
-
opts.maxTabs = Math.floor(args.maxTabs);
|
|
320
|
-
}
|
|
308
|
+
const opts = {
|
|
309
|
+
profileId: args.profileId || 'default',
|
|
310
|
+
sessionName: args.profileId || 'default',
|
|
311
|
+
headless: !!args.headless,
|
|
312
|
+
initialUrl: args.url,
|
|
313
|
+
engine: args.engine || 'camoufox',
|
|
314
|
+
fingerprintPlatform: args.fingerprintPlatform || null,
|
|
315
|
+
...(startViewport ? { viewport: startViewport } : {}),
|
|
316
|
+
...(args.ownerPid ? { ownerPid: args.ownerPid } : {}),
|
|
317
|
+
};
|
|
321
318
|
const res = await manager.createSession(opts);
|
|
322
319
|
const session = manager.getSession(opts.profileId);
|
|
323
320
|
if (!session) {
|
|
@@ -495,8 +492,8 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
|
|
|
495
492
|
const activeIndex = pages.find((p) => p.active)?.index ?? 0;
|
|
496
493
|
return { ok: true, body: { ok: true, pages, activeIndex } };
|
|
497
494
|
}
|
|
498
|
-
case 'newTab':
|
|
499
495
|
case 'page:new':
|
|
496
|
+
case 'newTab':
|
|
500
497
|
case 'newPage': {
|
|
501
498
|
const profileId = args.profileId || 'default';
|
|
502
499
|
const session = manager.getSession(profileId);
|
|
@@ -37,7 +37,6 @@ export class BrowserSession {
|
|
|
37
37
|
exitNotified = false;
|
|
38
38
|
constructor(options) {
|
|
39
39
|
this.options = options;
|
|
40
|
-
this.maxTabs = Math.max(5, Math.floor(Number(options.maxTabs ?? 5) || 5));
|
|
41
40
|
const profileId = options.profileId || 'default';
|
|
42
41
|
const root = resolveProfilesRoot();
|
|
43
42
|
this.profileDir = path.join(root, profileId);
|
|
@@ -70,7 +69,6 @@ export class BrowserSession {
|
|
|
70
69
|
recordLastKnownUrl: (url) => { if (url)
|
|
71
70
|
this.lastKnownUrl = url; },
|
|
72
71
|
isHeadless: () => this.options.headless === true,
|
|
73
|
-
getMaxTabs: () => this.maxTabs,
|
|
74
72
|
});
|
|
75
73
|
this.navigation = new BrowserSessionNavigation({
|
|
76
74
|
ensurePrimaryPage: () => this.pageManager.ensurePrimaryPage(),
|
|
@@ -149,10 +147,7 @@ export class BrowserSession {
|
|
|
149
147
|
const existing = this.context.pages();
|
|
150
148
|
this.page = existing.length ? existing[0] : await this.context.newPage();
|
|
151
149
|
this.setupPageHooks(this.page);
|
|
152
|
-
this.context.on('page', (p) =>
|
|
153
|
-
this.setupPageHooks(p);
|
|
154
|
-
this.enforceMaxTabs();
|
|
155
|
-
});
|
|
150
|
+
this.context.on('page', (p) => this.setupPageHooks(p));
|
|
156
151
|
if (this.viewportManager.isFollowingWindow()) {
|
|
157
152
|
await this.viewportManager.refreshFromWindow(this.page).catch(() => { });
|
|
158
153
|
}
|
|
@@ -163,33 +158,6 @@ export class BrowserSession {
|
|
|
163
158
|
setupPageHooks(page) {
|
|
164
159
|
this.pageHooks.setupPageHooks(page);
|
|
165
160
|
}
|
|
166
|
-
|
|
167
|
-
async enforceMaxTabs() {
|
|
168
|
-
try {
|
|
169
|
-
const ctx = this.context;
|
|
170
|
-
if (!ctx) return;
|
|
171
|
-
const pages = ctx.pages().filter((p) => !p.isClosed());
|
|
172
|
-
if (pages.length <= this.maxTabs) return;
|
|
173
|
-
const activePage = this.getActivePage();
|
|
174
|
-
const excess = pages.length - this.maxTabs;
|
|
175
|
-
const closable = pages
|
|
176
|
-
.filter((p) => p !== activePage)
|
|
177
|
-
.sort((a, b) => {
|
|
178
|
-
const aUrl = a.url() || '';
|
|
179
|
-
const bUrl = b.url() || '';
|
|
180
|
-
const aScore = (aUrl.includes('captcha') || aUrl === 'about:blank') ? 1 : 0;
|
|
181
|
-
const bScore = (bUrl.includes('captcha') || bUrl === 'about:blank') ? 1 : 0;
|
|
182
|
-
if (aScore !== bScore) return bScore - aScore;
|
|
183
|
-
return 0;
|
|
184
|
-
});
|
|
185
|
-
const toClose = closable.slice(0, Math.min(excess, closable.length));
|
|
186
|
-
for (const page of toClose) {
|
|
187
|
-
try {
|
|
188
|
-
await page.close({ runBeforeUnload: false });
|
|
189
|
-
} catch { /* ignore */ }
|
|
190
|
-
}
|
|
191
|
-
} catch { /* ignore enforcement errors */ }
|
|
192
|
-
}
|
|
193
161
|
addRuntimeEventObserver(observer) {
|
|
194
162
|
return this.runtimeEvents.addObserver(observer);
|
|
195
163
|
}
|
|
@@ -333,4 +301,4 @@ export class BrowserSession {
|
|
|
333
301
|
this.onExit?.(this.options.profileId);
|
|
334
302
|
}
|
|
335
303
|
}
|
|
336
|
-
//# sourceMappingURL=BrowserSession.js.map
|
|
304
|
+
//# sourceMappingURL=BrowserSession.js.map
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import { isTimeoutLikeError } from './utils.js';
|
|
2
|
+
import { resolveInputMode } from './utils.js';
|
|
3
|
+
|
|
4
|
+
async function createCDPSession(page) {
|
|
5
|
+
const context = page.context();
|
|
6
|
+
return context.newCDPSession(page);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function cdpMouseClick(cdp, x, y, button = 'left', delay = 50) {
|
|
10
|
+
const normalizedButton = button === 'left' ? 'left' : button === 'right' ? 'right' : button === 'middle' ? 'middle' : 'left';
|
|
11
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
12
|
+
type: 'mousePressed',
|
|
13
|
+
x: Math.round(x),
|
|
14
|
+
y: Math.round(y),
|
|
15
|
+
button: normalizedButton,
|
|
16
|
+
clickCount: 1
|
|
17
|
+
});
|
|
18
|
+
if (delay > 0) {
|
|
19
|
+
await new Promise(r => setTimeout(r, delay));
|
|
20
|
+
}
|
|
21
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
22
|
+
type: 'mouseReleased',
|
|
23
|
+
x: Math.round(x),
|
|
24
|
+
y: Math.round(y),
|
|
25
|
+
button: normalizedButton,
|
|
26
|
+
clickCount: 1
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cdpMouseMove(cdp, x, y) {
|
|
31
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
32
|
+
type: 'mouseMoved',
|
|
33
|
+
x: Math.round(x),
|
|
34
|
+
y: Math.round(y)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
2
37
|
|
|
3
38
|
async function readInteractiveViewport(page) {
|
|
4
39
|
const fallback = page.viewportSize?.() || null;
|
|
@@ -39,9 +74,34 @@ export class BrowserSessionInputOps {
|
|
|
39
74
|
this.withInputActionLock = withInputActionLock;
|
|
40
75
|
const envMode = String(process.env.CAMO_SCROLL_INPUT_MODE || '').trim().toLowerCase();
|
|
41
76
|
this.wheelMode = envMode === 'keyboard' ? 'keyboard' : 'wheel';
|
|
77
|
+
this.inputMode = resolveInputMode();
|
|
42
78
|
}
|
|
43
79
|
async mouseClick(opts) {
|
|
44
80
|
const page = await this.ensurePrimaryPage();
|
|
81
|
+
|
|
82
|
+
if (this.inputMode === 'cdp') {
|
|
83
|
+
const { x, y, button = 'left', clicks = 1, delay = 50 } = opts;
|
|
84
|
+
let cdp = null;
|
|
85
|
+
try {
|
|
86
|
+
cdp = await createCDPSession(page);
|
|
87
|
+
for (let i = 0; i < clicks; i++) {
|
|
88
|
+
if (i > 0) {
|
|
89
|
+
await new Promise(r => setTimeout(r, 100 + Math.random() * 100));
|
|
90
|
+
}
|
|
91
|
+
await this.withInputActionLock(async () => {
|
|
92
|
+
await this.runInputAction(page, 'mouse:click(cdp)', async () => {
|
|
93
|
+
await cdpMouseClick(cdp, x, y, button, delay);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
if (cdp) {
|
|
99
|
+
await cdp.detach().catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
45
105
|
await this.withInputActionLock(async () => {
|
|
46
106
|
await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
|
|
47
107
|
const { x, y, button = 'left', clicks = 1, delay = 50, nudgeBefore = false } = opts;
|
|
@@ -60,25 +120,28 @@ export class BrowserSessionInputOps {
|
|
|
60
120
|
await clickPage.mouse.move(nudgeX, nudgeY, { steps: 3 }).catch(() => { });
|
|
61
121
|
await clickPage.waitForTimeout(40).catch(() => { });
|
|
62
122
|
};
|
|
123
|
+
const performClick = async (clickPage, label) => {
|
|
124
|
+
await this.runInputAction(page, label, async (activePage) => {
|
|
125
|
+
if (nudgeBefore)
|
|
126
|
+
await nudgePointer(activePage);
|
|
127
|
+
await moveToTarget(activePage);
|
|
128
|
+
await activePage.mouse.down({ button });
|
|
129
|
+
const pause = Math.max(0, Number(delay) || 0);
|
|
130
|
+
if (pause > 0)
|
|
131
|
+
await activePage.waitForTimeout(pause).catch(() => { });
|
|
132
|
+
await activePage.mouse.up({ button });
|
|
133
|
+
});
|
|
134
|
+
};
|
|
63
135
|
for (let i = 0; i < clicks; i++) {
|
|
64
136
|
if (i > 0)
|
|
65
137
|
await new Promise(r => setTimeout(r, 100 + Math.random() * 100));
|
|
66
138
|
try {
|
|
67
|
-
await
|
|
68
|
-
if (nudgeBefore)
|
|
69
|
-
await nudgePointer(clickPage);
|
|
70
|
-
await moveToTarget(clickPage);
|
|
71
|
-
await clickPage.mouse.click(x, y, { button, clickCount: 1, delay: Math.max(0, Number(delay) || 0) });
|
|
72
|
-
});
|
|
139
|
+
await performClick(page, 'mouse:click(direct)');
|
|
73
140
|
}
|
|
74
141
|
catch (error) {
|
|
75
142
|
if (!isTimeoutLikeError(error))
|
|
76
143
|
throw error;
|
|
77
|
-
await
|
|
78
|
-
await nudgePointer(clickPage);
|
|
79
|
-
await moveToTarget(clickPage);
|
|
80
|
-
await clickPage.mouse.click(x, y, { button, clickCount: 1, delay: Math.max(0, Number(delay) || 0) });
|
|
81
|
-
});
|
|
144
|
+
await performClick(page, 'mouse:click(retry)');
|
|
82
145
|
}
|
|
83
146
|
}
|
|
84
147
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
|
|
1
|
+
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputMode, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
|
|
2
2
|
import { ensurePageRuntime } from '../pageRuntime.js';
|
|
3
3
|
export class BrowserInputPipeline {
|
|
4
4
|
ensurePrimaryPage;
|
|
@@ -9,6 +9,8 @@ export class BrowserInputPipeline {
|
|
|
9
9
|
}
|
|
10
10
|
inputActionTail = Promise.resolve();
|
|
11
11
|
async ensureInputReady(page) {
|
|
12
|
+
if (resolveInputMode() === 'cdp')
|
|
13
|
+
return;
|
|
12
14
|
if (this.isHeadless())
|
|
13
15
|
return;
|
|
14
16
|
if (shouldSkipBringToFront()) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { ensurePageRuntime } from '../pageRuntime.js';
|
|
3
|
-
import { resolveNavigationWaitUntil, normalizeUrl
|
|
3
|
+
import { resolveNavigationWaitUntil, normalizeUrl } from './utils.js';
|
|
4
4
|
export class BrowserSessionPageManagement {
|
|
5
5
|
deps;
|
|
6
6
|
trackedPages = [];
|
|
@@ -182,24 +182,14 @@ export class BrowserSessionPageManagement {
|
|
|
182
182
|
active: active === p,
|
|
183
183
|
}));
|
|
184
184
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (Number.isFinite(maxTabs) && maxTabs >= 1) {
|
|
189
|
-
const pages = this.collectPages(ctx);
|
|
190
|
-
if (pages.length >= maxTabs) {
|
|
191
|
-
throw new Error(`max_tabs_exceeded:${maxTabs}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
const isMac = process.platform === 'darwin';
|
|
185
|
+
async newPage(url, options = {}) {
|
|
186
|
+
const ctx = this.deps.ensureContext();
|
|
187
|
+
const isMac = process.platform === 'darwin';
|
|
195
188
|
const shortcut = isMac ? 'Meta+t' : 'Control+t';
|
|
196
189
|
let page = null;
|
|
197
190
|
const opener = this.deps.getActivePage() || ctx.pages()[0];
|
|
198
191
|
if (!opener)
|
|
199
192
|
throw new Error('no_opener_page');
|
|
200
|
-
if (!shouldSkipBringToFront()) {
|
|
201
|
-
await opener.bringToFront().catch(() => null);
|
|
202
|
-
}
|
|
203
193
|
const before = this.collectPages(ctx).length;
|
|
204
194
|
if (!options?.strictShortcut) {
|
|
205
195
|
page = await this.openPageViaContext(ctx, before);
|
|
@@ -252,14 +242,6 @@ export class BrowserSessionPageManagement {
|
|
|
252
242
|
catch {
|
|
253
243
|
/* ignore */
|
|
254
244
|
}
|
|
255
|
-
if (!shouldSkipBringToFront()) {
|
|
256
|
-
try {
|
|
257
|
-
await page.bringToFront();
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
/* ignore */
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
245
|
if (url) {
|
|
264
246
|
await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
|
|
265
247
|
await ensurePageRuntime(page);
|
|
@@ -283,14 +265,6 @@ export class BrowserSessionPageManagement {
|
|
|
283
265
|
catch {
|
|
284
266
|
/* ignore */
|
|
285
267
|
}
|
|
286
|
-
if (!shouldSkipBringToFront()) {
|
|
287
|
-
try {
|
|
288
|
-
await page.bringToFront();
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
/* ignore */
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
268
|
await ensurePageRuntime(page, true).catch(() => { });
|
|
295
269
|
this.deps.recordLastKnownUrl(page.url());
|
|
296
270
|
return { index: idx, url: page.url() };
|
|
@@ -316,14 +290,6 @@ export class BrowserSessionPageManagement {
|
|
|
316
290
|
if (nextIndex >= 0) {
|
|
317
291
|
const nextPage = remaining[nextIndex];
|
|
318
292
|
this.deps.setActivePage(nextPage);
|
|
319
|
-
if (!shouldSkipBringToFront()) {
|
|
320
|
-
try {
|
|
321
|
-
await nextPage.bringToFront();
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
/* ignore */
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
293
|
await ensurePageRuntime(nextPage, true).catch(() => { });
|
|
328
294
|
this.deps.recordLastKnownUrl(nextPage.url());
|
|
329
295
|
}
|
|
@@ -46,6 +46,12 @@ export function isTimeoutLikeError(error) {
|
|
|
46
46
|
const message = String(error?.message || error || '').toLowerCase();
|
|
47
47
|
return message.includes('timed out') || message.includes('timeout');
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export function resolveInputMode() {
|
|
51
|
+
const raw = String(process.env.CAMO_INPUT_MODE ?? '').trim().toLowerCase();
|
|
52
|
+
return raw === 'cdp' ? 'cdp' : 'playwright';
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
export function normalizeUrl(raw) {
|
|
50
56
|
try {
|
|
51
57
|
const url = new URL(raw);
|
package/src/utils/help.mjs
CHANGED
|
@@ -26,7 +26,7 @@ CONFIG:
|
|
|
26
26
|
|
|
27
27
|
BROWSER CONTROL:
|
|
28
28
|
init Ensure camoufox + ensure browser-service daemon
|
|
29
|
-
start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>
|
|
29
|
+
start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
|
|
30
30
|
stop [profileId]
|
|
31
31
|
stop --id <instanceId> Stop by instance id
|
|
32
32
|
stop --alias <alias> Stop by alias
|