@web-auto/camo 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -1255
- 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 -127
- 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 -671
- package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
- package/src/services/browser-service/internal/BrowserSession.js +325 -304
- 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 -222
- 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 -302
- 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/commands/browser.mjs
CHANGED
|
@@ -1,1255 +1,1255 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
listProfiles,
|
|
5
|
-
getDefaultProfile,
|
|
6
|
-
getHighlightMode,
|
|
7
|
-
getProfileWindowSize,
|
|
8
|
-
setProfileWindowSize,
|
|
9
|
-
} from '../utils/config.mjs';
|
|
10
|
-
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
11
|
-
import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
|
|
12
|
-
import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
|
|
13
|
-
import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
|
|
14
|
-
import {
|
|
15
|
-
buildScrollTargetScript,
|
|
16
|
-
} from '../container/runtime-core/operations/selector-scripts.mjs';
|
|
17
|
-
import {
|
|
18
|
-
registerSession,
|
|
19
|
-
updateSession,
|
|
20
|
-
getSessionInfo,
|
|
21
|
-
unregisterSession,
|
|
22
|
-
listRegisteredSessions,
|
|
23
|
-
markSessionClosed,
|
|
24
|
-
cleanupStaleSessions,
|
|
25
|
-
resolveSessionTarget,
|
|
26
|
-
isSessionAliasTaken,
|
|
27
|
-
} from '../lifecycle/session-registry.mjs';
|
|
28
|
-
import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
29
|
-
|
|
30
|
-
const START_WINDOW_MIN_WIDTH = 960;
|
|
31
|
-
const START_WINDOW_MIN_HEIGHT = 700;
|
|
32
|
-
const START_WINDOW_MAX_RESERVE = 240;
|
|
33
|
-
const START_WINDOW_DEFAULT_RESERVE = 0;
|
|
34
|
-
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
-
const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
|
|
36
|
-
? ['Meta+Alt+I', 'F12']
|
|
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
|
-
);
|
|
42
|
-
|
|
43
|
-
function sleep(ms) {
|
|
44
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseNumber(value, fallback = 0) {
|
|
48
|
-
const parsed = Number(value);
|
|
49
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function clamp(value, min, max) {
|
|
53
|
-
return Math.min(Math.max(value, min), max);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function readFlagValue(args, names) {
|
|
57
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
58
|
-
if (!names.includes(args[i])) continue;
|
|
59
|
-
const value = args[i + 1];
|
|
60
|
-
if (!value || String(value).startsWith('-')) return null;
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function parseDurationMs(raw, fallbackMs) {
|
|
67
|
-
if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
|
|
68
|
-
const text = String(raw).trim().toLowerCase();
|
|
69
|
-
if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
|
|
70
|
-
const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
71
|
-
if (!matched) {
|
|
72
|
-
throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
|
|
73
|
-
}
|
|
74
|
-
const value = Number(matched[1]);
|
|
75
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
76
|
-
throw new Error('Invalid --idle-timeout value');
|
|
77
|
-
}
|
|
78
|
-
const unit = matched[2] || 'm';
|
|
79
|
-
const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
|
|
80
|
-
return Math.floor(value * factor);
|
|
81
|
-
}
|
|
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 = 'camo-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
|
-
await callAPI('mouse:wheel', { profileId, deltaX: 0, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
226
|
-
autoScrolled += 1;
|
|
227
|
-
if (settleMs > 0) {
|
|
228
|
-
await sleep(settleMs);
|
|
229
|
-
}
|
|
230
|
-
target = await resolveVisibleTargetPoint(profileId, selector, { highlight: false });
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
target,
|
|
235
|
-
autoScrolled,
|
|
236
|
-
targetFullyVisible: isTargetFullyInViewport(target),
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function validateAlias(alias) {
|
|
241
|
-
const text = String(alias || '').trim();
|
|
242
|
-
if (!text) return null;
|
|
243
|
-
if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
|
|
244
|
-
throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
|
|
245
|
-
}
|
|
246
|
-
return text.slice(0, 64);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function resolveHighlightEnabled(args) {
|
|
250
|
-
if (args.includes('--highlight')) return true;
|
|
251
|
-
if (args.includes('--no-highlight')) return false;
|
|
252
|
-
return getHighlightMode();
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function formatDurationMs(ms) {
|
|
256
|
-
const value = Number(ms);
|
|
257
|
-
if (!Number.isFinite(value) || value <= 0) return 'disabled';
|
|
258
|
-
if (value % 3600000 === 0) return `${value / 3600000}h`;
|
|
259
|
-
if (value % 60000 === 0) return `${value / 60000}m`;
|
|
260
|
-
if (value % 1000 === 0) return `${value / 1000}s`;
|
|
261
|
-
return `${value}ms`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function computeIdleState(session, now = Date.now()) {
|
|
265
|
-
const headless = session?.headless === true;
|
|
266
|
-
const timeoutMs = headless
|
|
267
|
-
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
268
|
-
: 0;
|
|
269
|
-
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
270
|
-
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
271
|
-
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
272
|
-
return { headless, timeoutMs, idleMs, idle };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function stopAndCleanupProfile(profileId, options = {}) {
|
|
276
|
-
const id = String(profileId || '').trim();
|
|
277
|
-
if (!id) return { profileId: id, ok: false, error: 'profile_required' };
|
|
278
|
-
const force = options.force === true;
|
|
279
|
-
const serviceUp = options.serviceUp === true;
|
|
280
|
-
let result = null;
|
|
281
|
-
let error = null;
|
|
282
|
-
if (serviceUp) {
|
|
283
|
-
try {
|
|
284
|
-
result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
|
|
285
|
-
} catch (err) {
|
|
286
|
-
error = err;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
stopSessionWatchdog(id);
|
|
290
|
-
releaseLock(id);
|
|
291
|
-
markSessionClosed(id);
|
|
292
|
-
return {
|
|
293
|
-
profileId: id,
|
|
294
|
-
ok: !error,
|
|
295
|
-
serviceUp,
|
|
296
|
-
result,
|
|
297
|
-
error: error ? (error.message || String(error)) : null,
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function loadResolvedSessions(serviceUp) {
|
|
302
|
-
if (!serviceUp) return [];
|
|
303
|
-
try {
|
|
304
|
-
return await getResolvedSessions();
|
|
305
|
-
} catch {
|
|
306
|
-
return [];
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function probeViewportSize(profileId) {
|
|
311
|
-
try {
|
|
312
|
-
const payload = await callAPI('evaluate', {
|
|
313
|
-
profileId,
|
|
314
|
-
script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
|
|
315
|
-
});
|
|
316
|
-
const size = payload?.result || payload?.data || payload || {};
|
|
317
|
-
const width = Number(size?.width || 0);
|
|
318
|
-
const height = Number(size?.height || 0);
|
|
319
|
-
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
320
|
-
return { width, height };
|
|
321
|
-
} catch {
|
|
322
|
-
return null;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
export async function requestDevtoolsOpen(profileId, options = {}) {
|
|
327
|
-
const id = String(profileId || '').trim();
|
|
328
|
-
if (!id) {
|
|
329
|
-
return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
|
|
333
|
-
? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
|
|
334
|
-
: DEVTOOLS_SHORTCUTS;
|
|
335
|
-
const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
|
|
336
|
-
const before = await probeViewportSize(id);
|
|
337
|
-
const attempts = [];
|
|
338
|
-
|
|
339
|
-
for (const key of shortcuts) {
|
|
340
|
-
try {
|
|
341
|
-
await callAPI('keyboard:press', { profileId: id, key });
|
|
342
|
-
attempts.push({ key, ok: true });
|
|
343
|
-
if (settleMs > 0) {
|
|
344
|
-
// Allow browser UI animation to settle after shortcut.
|
|
345
|
-
// eslint-disable-next-line no-await-in-loop
|
|
346
|
-
await sleep(settleMs);
|
|
347
|
-
}
|
|
348
|
-
} catch (err) {
|
|
349
|
-
attempts.push({ key, ok: false, error: err?.message || String(err) });
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const after = await probeViewportSize(id);
|
|
354
|
-
const beforeHeight = Number(before?.height || 0);
|
|
355
|
-
const afterHeight = Number(after?.height || 0);
|
|
356
|
-
const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
|
|
357
|
-
const successCount = attempts.filter((item) => item.ok).length;
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
ok: successCount > 0,
|
|
361
|
-
requested: true,
|
|
362
|
-
shortcuts,
|
|
363
|
-
attempts,
|
|
364
|
-
before,
|
|
365
|
-
after,
|
|
366
|
-
verified: viewportReduced,
|
|
367
|
-
verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export function computeTargetViewportFromWindowMetrics(measured) {
|
|
372
|
-
const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
|
|
373
|
-
const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
|
|
374
|
-
const outerWidth = Math.max(320, parseNumber(measured?.outerWidth, innerWidth));
|
|
375
|
-
const outerHeight = Math.max(240, parseNumber(measured?.outerHeight, innerHeight));
|
|
376
|
-
|
|
377
|
-
const rawDeltaW = Math.max(0, outerWidth - innerWidth);
|
|
378
|
-
const rawDeltaH = Math.max(0, outerHeight - innerHeight);
|
|
379
|
-
const frameW = rawDeltaW > 400 ? 16 : Math.min(rawDeltaW, 120);
|
|
380
|
-
const frameH = rawDeltaH > 400 ? 88 : Math.min(rawDeltaH, 180);
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
width: Math.max(320, outerWidth - frameW),
|
|
384
|
-
height: Math.max(240, outerHeight - frameH),
|
|
385
|
-
frameW,
|
|
386
|
-
frameH,
|
|
387
|
-
innerWidth,
|
|
388
|
-
innerHeight,
|
|
389
|
-
outerWidth,
|
|
390
|
-
outerHeight,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export function computeStartWindowSize(metrics, options = {}) {
|
|
395
|
-
const display = metrics?.metrics || metrics || {};
|
|
396
|
-
const reserveFromEnv = parseNumber(process.env.CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE, START_WINDOW_DEFAULT_RESERVE);
|
|
397
|
-
const reserve = clamp(
|
|
398
|
-
parseNumber(options.reservePx, reserveFromEnv),
|
|
399
|
-
0,
|
|
400
|
-
START_WINDOW_MAX_RESERVE,
|
|
401
|
-
);
|
|
402
|
-
|
|
403
|
-
const workWidth = parseNumber(display.workWidth, 0);
|
|
404
|
-
const workHeight = parseNumber(display.workHeight, 0);
|
|
405
|
-
const width = parseNumber(display.width, 0);
|
|
406
|
-
const height = parseNumber(display.height, 0);
|
|
407
|
-
const baseW = Math.floor(workWidth > 0 ? workWidth : width);
|
|
408
|
-
const baseH = Math.floor(workHeight > 0 ? workHeight : height);
|
|
409
|
-
|
|
410
|
-
if (baseW <= 0 || baseH <= 0) {
|
|
411
|
-
return {
|
|
412
|
-
width: 1920,
|
|
413
|
-
height: 1000,
|
|
414
|
-
reservePx: reserve,
|
|
415
|
-
source: 'fallback',
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return {
|
|
420
|
-
width: Math.max(START_WINDOW_MIN_WIDTH, baseW),
|
|
421
|
-
height: Math.max(START_WINDOW_MIN_HEIGHT, baseH - reserve),
|
|
422
|
-
reservePx: reserve,
|
|
423
|
-
source: workWidth > 0 || workHeight > 0 ? 'workArea' : 'screen',
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function probeWindowMetrics(profileId) {
|
|
428
|
-
const measured = await callAPI('evaluate', {
|
|
429
|
-
profileId,
|
|
430
|
-
script: '({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, outerHeight: window.outerHeight })',
|
|
431
|
-
});
|
|
432
|
-
return measured?.result || {};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export async function syncWindowViewportAfterResize(profileId, width, height, options = {}) {
|
|
436
|
-
const settleMs = Math.max(40, parseNumber(options.settleMs, 120));
|
|
437
|
-
const attempts = Math.max(1, Math.min(8, Math.floor(parseNumber(options.attempts, 4))));
|
|
438
|
-
const tolerancePx = Math.max(0, parseNumber(options.tolerancePx, 3));
|
|
439
|
-
|
|
440
|
-
const windowResult = await callAPI('window:resize', { profileId, width, height });
|
|
441
|
-
await sleep(settleMs);
|
|
442
|
-
|
|
443
|
-
let measured = {};
|
|
444
|
-
let verified = {};
|
|
445
|
-
let viewport = null;
|
|
446
|
-
let matched = false;
|
|
447
|
-
let target = { width: 1280, height: 720, frameW: 16, frameH: 88 };
|
|
448
|
-
|
|
449
|
-
for (let i = 0; i < attempts; i += 1) {
|
|
450
|
-
measured = await probeWindowMetrics(profileId);
|
|
451
|
-
target = computeTargetViewportFromWindowMetrics(measured);
|
|
452
|
-
viewport = await callAPI('page:setViewport', {
|
|
453
|
-
profileId,
|
|
454
|
-
width: target.width,
|
|
455
|
-
height: target.height,
|
|
456
|
-
});
|
|
457
|
-
await sleep(settleMs);
|
|
458
|
-
verified = await probeWindowMetrics(profileId);
|
|
459
|
-
const dw = Math.abs(parseNumber(verified?.innerWidth, 0) - target.width);
|
|
460
|
-
const dh = Math.abs(parseNumber(verified?.innerHeight, 0) - target.height);
|
|
461
|
-
if (dw <= tolerancePx && dh <= tolerancePx) {
|
|
462
|
-
matched = true;
|
|
463
|
-
break;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return {
|
|
468
|
-
window: windowResult,
|
|
469
|
-
measured,
|
|
470
|
-
verified,
|
|
471
|
-
targetViewport: {
|
|
472
|
-
width: target.width,
|
|
473
|
-
height: target.height,
|
|
474
|
-
frameW: target.frameW,
|
|
475
|
-
frameH: target.frameH,
|
|
476
|
-
matched,
|
|
477
|
-
},
|
|
478
|
-
viewport,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
export async function handleStartCommand(args) {
|
|
483
|
-
ensureCamoufox();
|
|
484
|
-
await ensureBrowserService();
|
|
485
|
-
cleanupStaleLocks();
|
|
486
|
-
cleanupStaleSessions();
|
|
487
|
-
|
|
488
|
-
const urlIdx = args.indexOf('--url');
|
|
489
|
-
const explicitUrl = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
|
|
490
|
-
const widthIdx = args.indexOf('--width');
|
|
491
|
-
const heightIdx = args.indexOf('--height');
|
|
492
|
-
const explicitWidth = widthIdx >= 0 ? parseNumber(args[widthIdx + 1], NaN) : NaN;
|
|
493
|
-
const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
|
|
494
|
-
const hasExplicitWidth = Number.isFinite(explicitWidth);
|
|
495
|
-
const hasExplicitHeight = Number.isFinite(explicitHeight);
|
|
496
|
-
const alias = validateAlias(readFlagValue(args, ['--alias']));
|
|
497
|
-
const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
|
|
498
|
-
const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
|
|
499
|
-
const wantsDevtools = args.includes('--devtools');
|
|
500
|
-
const wantsRecord = args.includes('--record');
|
|
501
|
-
const recordName = readFlagValue(args, ['--record-name']);
|
|
502
|
-
const recordOutputRaw = readFlagValue(args, ['--record-output']);
|
|
503
|
-
const recordOverlay = args.includes('--no-record-overlay')
|
|
504
|
-
? false
|
|
505
|
-
: args.includes('--record-overlay')
|
|
506
|
-
? true
|
|
507
|
-
: null;
|
|
508
|
-
if (hasExplicitWidth !== hasExplicitHeight) {
|
|
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>]');
|
|
510
|
-
}
|
|
511
|
-
if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
|
|
512
|
-
throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
|
|
513
|
-
}
|
|
514
|
-
if (args.includes('--record-name') && !recordName) {
|
|
515
|
-
throw new Error('Usage: camo start [profileId] --record-name <name>');
|
|
516
|
-
}
|
|
517
|
-
if (args.includes('--record-output') && !recordOutputRaw) {
|
|
518
|
-
throw new Error('Usage: camo start [profileId] --record-output <path>');
|
|
519
|
-
}
|
|
520
|
-
const recordOutput = recordOutputRaw ? path.resolve(recordOutputRaw) : null;
|
|
521
|
-
const hasExplicitWindowSize = hasExplicitWidth && hasExplicitHeight;
|
|
522
|
-
const profileSet = new Set(listProfiles());
|
|
523
|
-
let implicitUrl;
|
|
524
|
-
|
|
525
|
-
let profileId = null;
|
|
526
|
-
for (let i = 1; i < args.length; i++) {
|
|
527
|
-
const arg = args[i];
|
|
528
|
-
if (arg === '--url') { i++; continue; }
|
|
529
|
-
if (arg === '--width' || arg === '--height') { i++; continue; }
|
|
530
|
-
if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
|
|
531
|
-
if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
|
|
532
|
-
if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
|
|
533
|
-
if (arg.startsWith('--')) continue;
|
|
534
|
-
|
|
535
|
-
if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
|
|
536
|
-
implicitUrl = arg;
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
profileId = arg;
|
|
541
|
-
break;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (!profileId) {
|
|
545
|
-
profileId = getDefaultProfile();
|
|
546
|
-
if (!profileId) {
|
|
547
|
-
throw new Error('No default profile set. Run: camo profile default <profileId>');
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
assertExistingProfile(profileId, profileSet);
|
|
551
|
-
if (alias && isSessionAliasTaken(alias, profileId)) {
|
|
552
|
-
throw new Error(`Alias is already in use: ${alias}`);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Check for existing session in browser service
|
|
556
|
-
const existing = await getSessionByProfile(profileId);
|
|
557
|
-
if (existing) {
|
|
558
|
-
// Session exists in browser service - update registry and lock
|
|
559
|
-
acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
|
|
560
|
-
const saved = getSessionInfo(profileId);
|
|
561
|
-
const record = saved
|
|
562
|
-
? updateSession(profileId, {
|
|
563
|
-
sessionId: existing.session_id || existing.profileId,
|
|
564
|
-
url: existing.current_url,
|
|
565
|
-
mode: existing.mode,
|
|
566
|
-
alias: alias || saved.alias || null,
|
|
567
|
-
})
|
|
568
|
-
: registerSession(profileId, {
|
|
569
|
-
sessionId: existing.session_id || existing.profileId,
|
|
570
|
-
url: existing.current_url,
|
|
571
|
-
mode: existing.mode,
|
|
572
|
-
alias: alias || null,
|
|
573
|
-
});
|
|
574
|
-
const idleState = computeIdleState(record);
|
|
575
|
-
const payload = {
|
|
576
|
-
ok: true,
|
|
577
|
-
sessionId: existing.session_id || existing.profileId,
|
|
578
|
-
instanceId: record.instanceId,
|
|
579
|
-
profileId,
|
|
580
|
-
message: 'Session already running',
|
|
581
|
-
url: existing.current_url,
|
|
582
|
-
alias: record.alias || null,
|
|
583
|
-
idleTimeoutMs: idleState.timeoutMs,
|
|
584
|
-
idleTimeout: formatDurationMs(idleState.timeoutMs),
|
|
585
|
-
closeHint: {
|
|
586
|
-
byProfile: `camo stop ${profileId}`,
|
|
587
|
-
byId: `camo stop --id ${record.instanceId}`,
|
|
588
|
-
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
589
|
-
},
|
|
590
|
-
};
|
|
591
|
-
const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
|
|
592
|
-
const existingHeadless = existing?.headless === true || existingMode.includes('headless');
|
|
593
|
-
if (!existingHeadless && wantsDevtools) {
|
|
594
|
-
payload.devtools = await requestDevtoolsOpen(profileId);
|
|
595
|
-
}
|
|
596
|
-
if (wantsRecord) {
|
|
597
|
-
payload.recording = await callAPI('record:start', {
|
|
598
|
-
profileId,
|
|
599
|
-
...(recordName ? { name: recordName } : {}),
|
|
600
|
-
...(recordOutput ? { outputPath: recordOutput } : {}),
|
|
601
|
-
...(recordOverlay !== null ? { overlay: recordOverlay } : {}),
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
605
|
-
startSessionWatchdog(profileId);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// No session in browser service - check registry for recovery
|
|
610
|
-
const registryInfo = getSessionInfo(profileId);
|
|
611
|
-
if (registryInfo && registryInfo.status === 'active') {
|
|
612
|
-
// Session was active but browser service doesn't have it
|
|
613
|
-
// This means service was restarted - clean up and start fresh
|
|
614
|
-
unregisterSession(profileId);
|
|
615
|
-
releaseLock(profileId);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const headless = !args.includes('--no-headless') && !args.includes('--visible');
|
|
619
|
-
if (wantsDevtools && headless) {
|
|
620
|
-
throw new Error('--devtools requires --no-headless or --visible mode');
|
|
621
|
-
}
|
|
622
|
-
const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
|
|
623
|
-
const targetUrl = explicitUrl || implicitUrl;
|
|
624
|
-
const result = await callAPI('start', {
|
|
625
|
-
profileId,
|
|
626
|
-
url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
|
|
627
|
-
headless,
|
|
628
|
-
devtools: wantsDevtools,
|
|
629
|
-
...(wantsRecord ? { record: true } : {}),
|
|
630
|
-
...(recordName ? { recordName } : {}),
|
|
631
|
-
...(recordOutput ? { recordOutput } : {}),
|
|
632
|
-
...(recordOverlay !== null ? { recordOverlay } : {}),
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
if (result?.ok) {
|
|
636
|
-
const sessionId = result.sessionId || result.profileId || profileId;
|
|
637
|
-
acquireLock(profileId, { sessionId });
|
|
638
|
-
const record = registerSession(profileId, {
|
|
639
|
-
sessionId,
|
|
640
|
-
url: targetUrl,
|
|
641
|
-
headless,
|
|
642
|
-
alias,
|
|
643
|
-
idleTimeoutMs,
|
|
644
|
-
lastAction: 'start',
|
|
645
|
-
});
|
|
646
|
-
startSessionWatchdog(profileId);
|
|
647
|
-
result.instanceId = record.instanceId;
|
|
648
|
-
result.alias = record.alias || null;
|
|
649
|
-
result.idleTimeoutMs = idleTimeoutMs;
|
|
650
|
-
result.idleTimeout = formatDurationMs(idleTimeoutMs);
|
|
651
|
-
result.closeHint = {
|
|
652
|
-
byProfile: `camo stop ${profileId}`,
|
|
653
|
-
byId: `camo stop --id ${record.instanceId}`,
|
|
654
|
-
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
655
|
-
all: 'camo close all',
|
|
656
|
-
};
|
|
657
|
-
result.message = headless
|
|
658
|
-
? `Started session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
|
|
659
|
-
: 'Started visible session. Remember to stop it when finished.';
|
|
660
|
-
|
|
661
|
-
if (!headless) {
|
|
662
|
-
let windowTarget = null;
|
|
663
|
-
if (hasExplicitWindowSize) {
|
|
664
|
-
windowTarget = {
|
|
665
|
-
width: Math.floor(explicitWidth),
|
|
666
|
-
height: Math.floor(explicitHeight),
|
|
667
|
-
source: 'explicit',
|
|
668
|
-
};
|
|
669
|
-
} else {
|
|
670
|
-
const display = await callAPI('system:display', {}).catch(() => null);
|
|
671
|
-
const displayTarget = computeStartWindowSize(display);
|
|
672
|
-
const rememberedWindow = getProfileWindowSize(profileId);
|
|
673
|
-
if (rememberedWindow) {
|
|
674
|
-
const rememberedTarget = {
|
|
675
|
-
width: rememberedWindow.width,
|
|
676
|
-
height: rememberedWindow.height,
|
|
677
|
-
source: 'profile',
|
|
678
|
-
updatedAt: rememberedWindow.updatedAt,
|
|
679
|
-
};
|
|
680
|
-
const canTrustDisplayTarget = displayTarget?.source && displayTarget.source !== 'fallback';
|
|
681
|
-
const refreshFromDisplay = canTrustDisplayTarget
|
|
682
|
-
&& (
|
|
683
|
-
rememberedTarget.height < Math.floor(displayTarget.height * 0.92)
|
|
684
|
-
|| rememberedTarget.width < Math.floor(displayTarget.width * 0.92)
|
|
685
|
-
);
|
|
686
|
-
windowTarget = refreshFromDisplay ? {
|
|
687
|
-
...displayTarget,
|
|
688
|
-
source: 'display',
|
|
689
|
-
} : rememberedTarget;
|
|
690
|
-
} else {
|
|
691
|
-
windowTarget = displayTarget;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
result.startWindow = {
|
|
696
|
-
width: windowTarget.width,
|
|
697
|
-
height: windowTarget.height,
|
|
698
|
-
source: windowTarget.source,
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
const syncResult = await syncWindowViewportAfterResize(
|
|
702
|
-
profileId,
|
|
703
|
-
windowTarget.width,
|
|
704
|
-
windowTarget.height,
|
|
705
|
-
).catch((err) => ({ error: err?.message || String(err) }));
|
|
706
|
-
result.windowSync = syncResult;
|
|
707
|
-
|
|
708
|
-
const measuredOuterWidth = Number(syncResult?.verified?.outerWidth);
|
|
709
|
-
const measuredOuterHeight = Number(syncResult?.verified?.outerHeight);
|
|
710
|
-
const savedWindow = setProfileWindowSize(
|
|
711
|
-
profileId,
|
|
712
|
-
Number.isFinite(measuredOuterWidth) ? measuredOuterWidth : windowTarget.width,
|
|
713
|
-
Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
|
|
714
|
-
);
|
|
715
|
-
result.profileWindow = savedWindow?.window || null;
|
|
716
|
-
if (wantsDevtools) {
|
|
717
|
-
result.devtools = await requestDevtoolsOpen(profileId);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
console.log(JSON.stringify(result, null, 2));
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
export async function handleStopCommand(args) {
|
|
725
|
-
const rawTarget = String(args[1] || '').trim();
|
|
726
|
-
const target = rawTarget.toLowerCase();
|
|
727
|
-
const idTarget = readFlagValue(args, ['--id']);
|
|
728
|
-
const aliasTarget = readFlagValue(args, ['--alias']);
|
|
729
|
-
if (args.includes('--id') && !idTarget) {
|
|
730
|
-
throw new Error('Usage: camo stop --id <instanceId>');
|
|
731
|
-
}
|
|
732
|
-
if (args.includes('--alias') && !aliasTarget) {
|
|
733
|
-
throw new Error('Usage: camo stop --alias <alias>');
|
|
734
|
-
}
|
|
735
|
-
const stopIdle = target === 'idle' || args.includes('--idle');
|
|
736
|
-
const stopAll = target === 'all';
|
|
737
|
-
const serviceUp = await checkBrowserService();
|
|
738
|
-
const resolvedSessions = await loadResolvedSessions(serviceUp);
|
|
739
|
-
|
|
740
|
-
if (stopAll) {
|
|
741
|
-
const profileSet = new Set(resolvedSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
742
|
-
if (profileSet.size === 0) {
|
|
743
|
-
for (const session of listRegisteredSessions()) {
|
|
744
|
-
if (String(session?.status || '').trim() === 'closed') continue;
|
|
745
|
-
const profileId = String(session?.profileId || '').trim();
|
|
746
|
-
if (profileId) profileSet.add(profileId);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const results = [];
|
|
751
|
-
for (const profileId of profileSet) {
|
|
752
|
-
// eslint-disable-next-line no-await-in-loop
|
|
753
|
-
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
754
|
-
}
|
|
755
|
-
console.log(JSON.stringify({
|
|
756
|
-
ok: true,
|
|
757
|
-
mode: 'all',
|
|
758
|
-
serviceUp,
|
|
759
|
-
closed: results.filter((item) => item.ok).length,
|
|
760
|
-
failed: results.filter((item) => !item.ok).length,
|
|
761
|
-
results,
|
|
762
|
-
}, null, 2));
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
if (stopIdle) {
|
|
767
|
-
const now = Date.now();
|
|
768
|
-
const registeredSessions = listRegisteredSessions();
|
|
769
|
-
const regMap = new Map(
|
|
770
|
-
registeredSessions
|
|
771
|
-
.filter((item) => item && String(item?.status || '').trim() === 'active')
|
|
772
|
-
.map((item) => [String(item.profileId || '').trim(), item]),
|
|
773
|
-
);
|
|
774
|
-
const idleTargets = new Set(
|
|
775
|
-
registeredSessions
|
|
776
|
-
.filter((item) => String(item?.status || '').trim() === 'active')
|
|
777
|
-
.map((item) => ({ session: item, idle: computeIdleState(item, now) }))
|
|
778
|
-
.filter((item) => item.idle.idle)
|
|
779
|
-
.map((item) => item.session.profileId),
|
|
780
|
-
);
|
|
781
|
-
let orphanLiveHeadlessCount = 0;
|
|
782
|
-
for (const live of resolvedSessions.filter((item) => item.live)) {
|
|
783
|
-
const liveProfileId = String(live?.profileId || '').trim();
|
|
784
|
-
if (!liveProfileId) continue;
|
|
785
|
-
if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
|
|
786
|
-
const mode = String(live?.mode || '').toLowerCase();
|
|
787
|
-
const liveHeadless = live?.headless === true || mode.includes('headless');
|
|
788
|
-
// Live but unregistered headless sessions are treated as idle-orphan targets.
|
|
789
|
-
if (liveHeadless) {
|
|
790
|
-
idleTargets.add(liveProfileId);
|
|
791
|
-
orphanLiveHeadlessCount += 1;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
const results = [];
|
|
795
|
-
for (const profileId of idleTargets) {
|
|
796
|
-
// eslint-disable-next-line no-await-in-loop
|
|
797
|
-
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
798
|
-
}
|
|
799
|
-
console.log(JSON.stringify({
|
|
800
|
-
ok: true,
|
|
801
|
-
mode: 'idle',
|
|
802
|
-
serviceUp,
|
|
803
|
-
targetCount: idleTargets.size,
|
|
804
|
-
orphanLiveHeadlessCount,
|
|
805
|
-
closed: results.filter((item) => item.ok).length,
|
|
806
|
-
failed: results.filter((item) => !item.ok).length,
|
|
807
|
-
results,
|
|
808
|
-
}, null, 2));
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
let profileId = null;
|
|
813
|
-
let resolvedBy = 'profile';
|
|
814
|
-
if (idTarget) {
|
|
815
|
-
const resolved = resolveSessionTarget(idTarget);
|
|
816
|
-
if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
|
|
817
|
-
profileId = resolved.profileId;
|
|
818
|
-
resolvedBy = resolved.reason;
|
|
819
|
-
} else if (aliasTarget) {
|
|
820
|
-
const resolved = resolveSessionTarget(aliasTarget);
|
|
821
|
-
if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
|
|
822
|
-
profileId = resolved.profileId;
|
|
823
|
-
resolvedBy = resolved.reason;
|
|
824
|
-
} else {
|
|
825
|
-
const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
|
|
826
|
-
if (positional) {
|
|
827
|
-
const resolved = resolveSessionTarget(positional);
|
|
828
|
-
if (resolved) {
|
|
829
|
-
profileId = resolved.profileId;
|
|
830
|
-
resolvedBy = resolved.reason;
|
|
831
|
-
} else {
|
|
832
|
-
profileId = positional;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if (!profileId) {
|
|
838
|
-
profileId = getDefaultProfile();
|
|
839
|
-
}
|
|
840
|
-
if (!profileId) {
|
|
841
|
-
throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const result = await stopAndCleanupProfile(profileId, { serviceUp });
|
|
845
|
-
if (!result.ok && serviceUp) {
|
|
846
|
-
throw new Error(result.error || `stop failed for profile: ${profileId}`);
|
|
847
|
-
}
|
|
848
|
-
console.log(JSON.stringify({
|
|
849
|
-
ok: true,
|
|
850
|
-
profileId,
|
|
851
|
-
resolvedBy,
|
|
852
|
-
serviceUp,
|
|
853
|
-
warning: (!serviceUp && !result.ok) ? result.error : null,
|
|
854
|
-
result: result.result || null,
|
|
855
|
-
}, null, 2));
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
export async function handleStatusCommand(args) {
|
|
859
|
-
await ensureBrowserService();
|
|
860
|
-
const profileId = args[1];
|
|
861
|
-
if (profileId && args[0] === 'status') {
|
|
862
|
-
const sessions = await getResolvedSessions();
|
|
863
|
-
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
864
|
-
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
const sessions = await getResolvedSessions();
|
|
868
|
-
console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
export async function handleGotoCommand(args) {
|
|
872
|
-
await ensureBrowserService();
|
|
873
|
-
const positionals = getPositionals(args);
|
|
874
|
-
const profileSet = new Set(listProfiles());
|
|
875
|
-
|
|
876
|
-
let profileId;
|
|
877
|
-
let url;
|
|
878
|
-
|
|
879
|
-
if (positionals.length === 1) {
|
|
880
|
-
profileId = getDefaultProfile();
|
|
881
|
-
url = positionals[0];
|
|
882
|
-
} else {
|
|
883
|
-
profileId = resolveProfileId(positionals, 0, getDefaultProfile);
|
|
884
|
-
url = positionals[1];
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
|
|
888
|
-
if (!url) throw new Error('Usage: camo goto [profileId] <url>');
|
|
889
|
-
assertExistingProfile(profileId, profileSet);
|
|
890
|
-
const active = await getSessionByProfile(profileId);
|
|
891
|
-
if (!active) {
|
|
892
|
-
throw new Error(
|
|
893
|
-
`No active session for profile: ${profileId}. Start via "camo start ${profileId}" (or UI CLI startup) before goto.`,
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
|
|
898
|
-
updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
|
|
899
|
-
console.log(JSON.stringify(result, null, 2));
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
export async function handleBackCommand(args) {
|
|
903
|
-
await ensureBrowserService();
|
|
904
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
905
|
-
if (!profileId) throw new Error('Usage: camo back [profileId] (or set default profile first)');
|
|
906
|
-
const result = await callAPI('page:back', { profileId });
|
|
907
|
-
console.log(JSON.stringify(result, null, 2));
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
export async function handleScreenshotCommand(args) {
|
|
911
|
-
await ensureBrowserService();
|
|
912
|
-
const fullPage = args.includes('--full');
|
|
913
|
-
const outputIdx = args.indexOf('--output');
|
|
914
|
-
const output = outputIdx >= 0 ? args[outputIdx + 1] : null;
|
|
915
|
-
|
|
916
|
-
let profileId = null;
|
|
917
|
-
for (let i = 1; i < args.length; i++) {
|
|
918
|
-
const arg = args[i];
|
|
919
|
-
if (arg === '--full') continue;
|
|
920
|
-
if (arg === '--output') { i++; continue; }
|
|
921
|
-
if (arg.startsWith('--')) continue;
|
|
922
|
-
profileId = arg;
|
|
923
|
-
break;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
if (!profileId) profileId = getDefaultProfile();
|
|
927
|
-
if (!profileId) throw new Error('Usage: camo screenshot [profileId] [--output <file>] [--full]');
|
|
928
|
-
|
|
929
|
-
const result = await callAPI('screenshot', { profileId, fullPage });
|
|
930
|
-
|
|
931
|
-
if (output && result?.data) {
|
|
932
|
-
fs.writeFileSync(output, Buffer.from(result.data, 'base64'));
|
|
933
|
-
console.log(`Screenshot saved to ${output}`);
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
console.log(JSON.stringify(result, null, 2));
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
export async function handleScrollCommand(args) {
|
|
941
|
-
await ensureBrowserService();
|
|
942
|
-
const directionFlags = new Set(['--up', '--down', '--left', '--right']);
|
|
943
|
-
const isFlag = (arg) => arg?.startsWith('--');
|
|
944
|
-
const selectorIdx = args.indexOf('--selector');
|
|
945
|
-
const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
|
|
946
|
-
const highlightRequested = resolveHighlightEnabled(args);
|
|
947
|
-
const highlight = highlightRequested;
|
|
948
|
-
|
|
949
|
-
let profileId = null;
|
|
950
|
-
for (let i = 1; i < args.length; i++) {
|
|
951
|
-
const arg = args[i];
|
|
952
|
-
if (directionFlags.has(arg)) continue;
|
|
953
|
-
if (arg === '--amount') { i++; continue; }
|
|
954
|
-
if (arg === '--selector') { i++; continue; }
|
|
955
|
-
if (arg === '--highlight' || arg === '--no-highlight') continue;
|
|
956
|
-
if (isFlag(arg)) continue;
|
|
957
|
-
profileId = arg;
|
|
958
|
-
break;
|
|
959
|
-
}
|
|
960
|
-
if (!profileId) profileId = getDefaultProfile();
|
|
961
|
-
if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]');
|
|
962
|
-
if (selectorIdx >= 0 && !selector) {
|
|
963
|
-
throw new Error('Usage: camo scroll [profileId] --selector <css>');
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
|
|
967
|
-
const amountIdx = args.indexOf('--amount');
|
|
968
|
-
const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
|
|
969
|
-
|
|
970
|
-
const target = await callAPI('evaluate', {
|
|
971
|
-
profileId,
|
|
972
|
-
script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
|
|
973
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
974
|
-
const scrollTarget = target?.result || null;
|
|
975
|
-
if (!scrollTarget?.ok || !scrollTarget?.center) {
|
|
976
|
-
throw new Error(scrollTarget?.error || 'visible scroll container not found');
|
|
977
|
-
}
|
|
978
|
-
const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
|
979
|
-
const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
|
980
|
-
await callAPI('mouse:click', {
|
|
981
|
-
profileId,
|
|
982
|
-
x: scrollTarget.center.x,
|
|
983
|
-
y: scrollTarget.center.y,
|
|
984
|
-
button: 'left',
|
|
985
|
-
clicks: 1,
|
|
986
|
-
delay: 30,
|
|
987
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
988
|
-
const result = await callAPI('mouse:wheel', {
|
|
989
|
-
profileId,
|
|
990
|
-
deltaX,
|
|
991
|
-
deltaY,
|
|
992
|
-
anchorX: scrollTarget.center.x,
|
|
993
|
-
anchorY: scrollTarget.center.y,
|
|
994
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
995
|
-
console.log(JSON.stringify({
|
|
996
|
-
...result,
|
|
997
|
-
scrollTarget,
|
|
998
|
-
highlight,
|
|
999
|
-
}, null, 2));
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
export async function handleClickCommand(args) {
|
|
1003
|
-
await ensureBrowserService();
|
|
1004
|
-
const positionals = getPositionals(args);
|
|
1005
|
-
const highlight = resolveHighlightEnabled(args);
|
|
1006
|
-
let profileId;
|
|
1007
|
-
let selector;
|
|
1008
|
-
|
|
1009
|
-
if (positionals.length === 1) {
|
|
1010
|
-
profileId = getDefaultProfile();
|
|
1011
|
-
selector = positionals[0];
|
|
1012
|
-
} else {
|
|
1013
|
-
profileId = positionals[0];
|
|
1014
|
-
selector = positionals[1];
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
|
|
1018
|
-
if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
|
|
1019
|
-
|
|
1020
|
-
let target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
|
|
1021
|
-
const ensured = await ensureClickTargetInViewport(profileId, selector, target, {
|
|
1022
|
-
maxAutoScrollSteps: 3,
|
|
1023
|
-
});
|
|
1024
|
-
if (!ensured.targetFullyVisible) {
|
|
1025
|
-
throw new Error(`Click target not fully visible after ${ensured.autoScrolled} auto-scroll attempts: ${selector}`);
|
|
1026
|
-
}
|
|
1027
|
-
target = ensured.target;
|
|
1028
|
-
if (highlight) {
|
|
1029
|
-
target = await resolveVisibleTargetPoint(profileId, selector, { highlight: true });
|
|
1030
|
-
}
|
|
1031
|
-
const result = await callAPI('mouse:click', {
|
|
1032
|
-
profileId,
|
|
1033
|
-
x: target.center.x,
|
|
1034
|
-
y: target.center.y,
|
|
1035
|
-
button: 'left',
|
|
1036
|
-
clicks: 1,
|
|
1037
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1038
|
-
console.log(JSON.stringify({
|
|
1039
|
-
...result,
|
|
1040
|
-
selector,
|
|
1041
|
-
highlight,
|
|
1042
|
-
autoScrolled: ensured.autoScrolled,
|
|
1043
|
-
targetFullyVisible: ensured.targetFullyVisible,
|
|
1044
|
-
target,
|
|
1045
|
-
}, null, 2));
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
export async function handleTypeCommand(args) {
|
|
1049
|
-
await ensureBrowserService();
|
|
1050
|
-
const positionals = getPositionals(args);
|
|
1051
|
-
const highlight = resolveHighlightEnabled(args);
|
|
1052
|
-
let profileId;
|
|
1053
|
-
let selector;
|
|
1054
|
-
let text;
|
|
1055
|
-
|
|
1056
|
-
if (positionals.length === 2) {
|
|
1057
|
-
profileId = getDefaultProfile();
|
|
1058
|
-
selector = positionals[0];
|
|
1059
|
-
text = positionals[1];
|
|
1060
|
-
} else {
|
|
1061
|
-
profileId = positionals[0];
|
|
1062
|
-
selector = positionals[1];
|
|
1063
|
-
text = positionals[2];
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
|
|
1067
|
-
if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
|
|
1068
|
-
|
|
1069
|
-
const target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
|
|
1070
|
-
await callAPI('mouse:click', {
|
|
1071
|
-
profileId,
|
|
1072
|
-
x: target.center.x,
|
|
1073
|
-
y: target.center.y,
|
|
1074
|
-
button: 'left',
|
|
1075
|
-
clicks: 1,
|
|
1076
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1077
|
-
await callAPI('keyboard:press', {
|
|
1078
|
-
profileId,
|
|
1079
|
-
key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
|
|
1080
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1081
|
-
await callAPI('keyboard:press', { profileId, key: 'Backspace' }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1082
|
-
const result = await callAPI('keyboard:type', {
|
|
1083
|
-
profileId,
|
|
1084
|
-
text: String(text),
|
|
1085
|
-
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1086
|
-
console.log(JSON.stringify({
|
|
1087
|
-
...result,
|
|
1088
|
-
selector,
|
|
1089
|
-
typed: String(text).length,
|
|
1090
|
-
highlight,
|
|
1091
|
-
target,
|
|
1092
|
-
}, null, 2));
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
export async function handleHighlightCommand(args) {
|
|
1096
|
-
await ensureBrowserService();
|
|
1097
|
-
const positionals = getPositionals(args);
|
|
1098
|
-
let profileId;
|
|
1099
|
-
let selector;
|
|
1100
|
-
|
|
1101
|
-
if (positionals.length === 1) {
|
|
1102
|
-
profileId = getDefaultProfile();
|
|
1103
|
-
selector = positionals[0];
|
|
1104
|
-
} else {
|
|
1105
|
-
profileId = positionals[0];
|
|
1106
|
-
selector = positionals[1];
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
if (!profileId) throw new Error('Usage: camo highlight [profileId] <selector>');
|
|
1110
|
-
if (!selector) throw new Error('Usage: camo highlight [profileId] <selector>');
|
|
1111
|
-
|
|
1112
|
-
const result = await callAPI('browser:highlight', {
|
|
1113
|
-
profile: profileId,
|
|
1114
|
-
profileId,
|
|
1115
|
-
selector,
|
|
1116
|
-
});
|
|
1117
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
export async function handleClearHighlightCommand(args) {
|
|
1121
|
-
await ensureBrowserService();
|
|
1122
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1123
|
-
if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
|
|
1124
|
-
|
|
1125
|
-
const result = await callAPI('browser:clear-highlight', {
|
|
1126
|
-
profile: profileId,
|
|
1127
|
-
profileId,
|
|
1128
|
-
});
|
|
1129
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
export async function handleViewportCommand(args) {
|
|
1133
|
-
await ensureBrowserService();
|
|
1134
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1135
|
-
if (!profileId) throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
|
|
1136
|
-
|
|
1137
|
-
const widthIdx = args.indexOf('--width');
|
|
1138
|
-
const heightIdx = args.indexOf('--height');
|
|
1139
|
-
const width = widthIdx >= 0 ? Number(args[widthIdx + 1]) : 1280;
|
|
1140
|
-
const height = heightIdx >= 0 ? Number(args[heightIdx + 1]) : 800;
|
|
1141
|
-
|
|
1142
|
-
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
1143
|
-
throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
const result = await callAPI('page:setViewport', { profileId, width, height });
|
|
1147
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
export async function handleNewPageCommand(args) {
|
|
1151
|
-
await ensureBrowserService();
|
|
1152
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1153
|
-
if (!profileId) throw new Error('Usage: camo new-page [profileId] [--url <url>] (or set default profile first)');
|
|
1154
|
-
const urlIdx = args.indexOf('--url');
|
|
1155
|
-
const url = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
|
|
1156
|
-
const result = await callAPI('newPage', { profileId, ...(url ? { url: ensureUrlScheme(url) } : {}) });
|
|
1157
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
export async function handleClosePageCommand(args) {
|
|
1161
|
-
await ensureBrowserService();
|
|
1162
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1163
|
-
if (!profileId) throw new Error('Usage: camo close-page [profileId] [index] (or set default profile first)');
|
|
1164
|
-
|
|
1165
|
-
let index;
|
|
1166
|
-
for (let i = args.length - 1; i >= 1; i--) {
|
|
1167
|
-
const arg = args[i];
|
|
1168
|
-
if (arg.startsWith('--')) continue;
|
|
1169
|
-
const num = Number(arg);
|
|
1170
|
-
if (Number.isFinite(num)) { index = num; break; }
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const result = await callAPI('page:close', { profileId, ...(Number.isFinite(index) ? { index } : {}) });
|
|
1174
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
export async function handleSwitchPageCommand(args) {
|
|
1178
|
-
await ensureBrowserService();
|
|
1179
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1180
|
-
if (!profileId) throw new Error('Usage: camo switch-page [profileId] <index> (or set default profile first)');
|
|
1181
|
-
|
|
1182
|
-
let index;
|
|
1183
|
-
for (let i = args.length - 1; i >= 1; i--) {
|
|
1184
|
-
const arg = args[i];
|
|
1185
|
-
if (arg.startsWith('--')) continue;
|
|
1186
|
-
const num = Number(arg);
|
|
1187
|
-
if (Number.isFinite(num)) { index = num; break; }
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
if (!Number.isFinite(index)) throw new Error('Usage: camo switch-page [profileId] <index>');
|
|
1191
|
-
const result = await callAPI('page:switch', { profileId, index });
|
|
1192
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
export async function handleListPagesCommand(args) {
|
|
1196
|
-
await ensureBrowserService();
|
|
1197
|
-
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1198
|
-
if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
|
|
1199
|
-
const sessions = await getResolvedSessions();
|
|
1200
|
-
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
1201
|
-
if (!session?.live) {
|
|
1202
|
-
throw new Error(`Profile session not live: ${profileId}. Start via "camo start ${profileId}" first.`);
|
|
1203
|
-
}
|
|
1204
|
-
const result = await callAPI('page:list', { profileId });
|
|
1205
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
export async function handleShutdownCommand() {
|
|
1209
|
-
await ensureBrowserService();
|
|
1210
|
-
|
|
1211
|
-
// Get all active sessions
|
|
1212
|
-
const status = await callAPI('getStatus', {});
|
|
1213
|
-
const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
1214
|
-
|
|
1215
|
-
// Stop each session and cleanup registry
|
|
1216
|
-
for (const session of sessions) {
|
|
1217
|
-
try {
|
|
1218
|
-
await callAPI('stop', { profileId: session.profileId });
|
|
1219
|
-
} catch {
|
|
1220
|
-
// Best effort cleanup
|
|
1221
|
-
}
|
|
1222
|
-
stopSessionWatchdog(session.profileId);
|
|
1223
|
-
releaseLock(session.profileId);
|
|
1224
|
-
markSessionClosed(session.profileId);
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
// Cleanup any remaining registry entries
|
|
1228
|
-
const registered = listRegisteredSessions();
|
|
1229
|
-
for (const reg of registered) {
|
|
1230
|
-
if (reg.status !== 'closed') {
|
|
1231
|
-
stopSessionWatchdog(reg.profileId);
|
|
1232
|
-
markSessionClosed(reg.profileId);
|
|
1233
|
-
releaseLock(reg.profileId);
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
stopAllSessionWatchdogs();
|
|
1237
|
-
|
|
1238
|
-
const result = await callAPI('service:shutdown', {});
|
|
1239
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
export async function handleSessionsCommand(args) {
|
|
1243
|
-
const serviceUp = await checkBrowserService();
|
|
1244
|
-
const merged = await loadResolvedSessions(serviceUp);
|
|
1245
|
-
const registeredSessions = listRegisteredSessions();
|
|
1246
|
-
|
|
1247
|
-
console.log(JSON.stringify({
|
|
1248
|
-
ok: true,
|
|
1249
|
-
serviceUp,
|
|
1250
|
-
sessions: merged,
|
|
1251
|
-
count: merged.length,
|
|
1252
|
-
registered: registeredSessions.length,
|
|
1253
|
-
live: liveSessions.length,
|
|
1254
|
-
}, null, 2));
|
|
1255
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
listProfiles,
|
|
5
|
+
getDefaultProfile,
|
|
6
|
+
getHighlightMode,
|
|
7
|
+
getProfileWindowSize,
|
|
8
|
+
setProfileWindowSize,
|
|
9
|
+
} from '../utils/config.mjs';
|
|
10
|
+
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
11
|
+
import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
|
|
12
|
+
import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
|
|
13
|
+
import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
|
|
14
|
+
import {
|
|
15
|
+
buildScrollTargetScript,
|
|
16
|
+
} from '../container/runtime-core/operations/selector-scripts.mjs';
|
|
17
|
+
import {
|
|
18
|
+
registerSession,
|
|
19
|
+
updateSession,
|
|
20
|
+
getSessionInfo,
|
|
21
|
+
unregisterSession,
|
|
22
|
+
listRegisteredSessions,
|
|
23
|
+
markSessionClosed,
|
|
24
|
+
cleanupStaleSessions,
|
|
25
|
+
resolveSessionTarget,
|
|
26
|
+
isSessionAliasTaken,
|
|
27
|
+
} from '../lifecycle/session-registry.mjs';
|
|
28
|
+
import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
29
|
+
|
|
30
|
+
const START_WINDOW_MIN_WIDTH = 960;
|
|
31
|
+
const START_WINDOW_MIN_HEIGHT = 700;
|
|
32
|
+
const START_WINDOW_MAX_RESERVE = 240;
|
|
33
|
+
const START_WINDOW_DEFAULT_RESERVE = 0;
|
|
34
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
+
const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
|
|
36
|
+
? ['Meta+Alt+I', 'F12']
|
|
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
|
+
);
|
|
42
|
+
|
|
43
|
+
function sleep(ms) {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseNumber(value, fallback = 0) {
|
|
48
|
+
const parsed = Number(value);
|
|
49
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp(value, min, max) {
|
|
53
|
+
return Math.min(Math.max(value, min), max);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readFlagValue(args, names) {
|
|
57
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
58
|
+
if (!names.includes(args[i])) continue;
|
|
59
|
+
const value = args[i + 1];
|
|
60
|
+
if (!value || String(value).startsWith('-')) return null;
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseDurationMs(raw, fallbackMs) {
|
|
67
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
|
|
68
|
+
const text = String(raw).trim().toLowerCase();
|
|
69
|
+
if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
|
|
70
|
+
const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
71
|
+
if (!matched) {
|
|
72
|
+
throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
|
|
73
|
+
}
|
|
74
|
+
const value = Number(matched[1]);
|
|
75
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
76
|
+
throw new Error('Invalid --idle-timeout value');
|
|
77
|
+
}
|
|
78
|
+
const unit = matched[2] || 'm';
|
|
79
|
+
const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
|
|
80
|
+
return Math.floor(value * factor);
|
|
81
|
+
}
|
|
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 = 'camo-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
|
+
await callAPI('mouse:wheel', { profileId, deltaX: 0, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
226
|
+
autoScrolled += 1;
|
|
227
|
+
if (settleMs > 0) {
|
|
228
|
+
await sleep(settleMs);
|
|
229
|
+
}
|
|
230
|
+
target = await resolveVisibleTargetPoint(profileId, selector, { highlight: false });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
target,
|
|
235
|
+
autoScrolled,
|
|
236
|
+
targetFullyVisible: isTargetFullyInViewport(target),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateAlias(alias) {
|
|
241
|
+
const text = String(alias || '').trim();
|
|
242
|
+
if (!text) return null;
|
|
243
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
|
|
244
|
+
throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
|
|
245
|
+
}
|
|
246
|
+
return text.slice(0, 64);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveHighlightEnabled(args) {
|
|
250
|
+
if (args.includes('--highlight')) return true;
|
|
251
|
+
if (args.includes('--no-highlight')) return false;
|
|
252
|
+
return getHighlightMode();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatDurationMs(ms) {
|
|
256
|
+
const value = Number(ms);
|
|
257
|
+
if (!Number.isFinite(value) || value <= 0) return 'disabled';
|
|
258
|
+
if (value % 3600000 === 0) return `${value / 3600000}h`;
|
|
259
|
+
if (value % 60000 === 0) return `${value / 60000}m`;
|
|
260
|
+
if (value % 1000 === 0) return `${value / 1000}s`;
|
|
261
|
+
return `${value}ms`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function computeIdleState(session, now = Date.now()) {
|
|
265
|
+
const headless = session?.headless === true;
|
|
266
|
+
const timeoutMs = headless
|
|
267
|
+
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
268
|
+
: 0;
|
|
269
|
+
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
270
|
+
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
271
|
+
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
272
|
+
return { headless, timeoutMs, idleMs, idle };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function stopAndCleanupProfile(profileId, options = {}) {
|
|
276
|
+
const id = String(profileId || '').trim();
|
|
277
|
+
if (!id) return { profileId: id, ok: false, error: 'profile_required' };
|
|
278
|
+
const force = options.force === true;
|
|
279
|
+
const serviceUp = options.serviceUp === true;
|
|
280
|
+
let result = null;
|
|
281
|
+
let error = null;
|
|
282
|
+
if (serviceUp) {
|
|
283
|
+
try {
|
|
284
|
+
result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
error = err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
stopSessionWatchdog(id);
|
|
290
|
+
releaseLock(id);
|
|
291
|
+
markSessionClosed(id);
|
|
292
|
+
return {
|
|
293
|
+
profileId: id,
|
|
294
|
+
ok: !error,
|
|
295
|
+
serviceUp,
|
|
296
|
+
result,
|
|
297
|
+
error: error ? (error.message || String(error)) : null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function loadResolvedSessions(serviceUp) {
|
|
302
|
+
if (!serviceUp) return [];
|
|
303
|
+
try {
|
|
304
|
+
return await getResolvedSessions();
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function probeViewportSize(profileId) {
|
|
311
|
+
try {
|
|
312
|
+
const payload = await callAPI('evaluate', {
|
|
313
|
+
profileId,
|
|
314
|
+
script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
|
|
315
|
+
});
|
|
316
|
+
const size = payload?.result || payload?.data || payload || {};
|
|
317
|
+
const width = Number(size?.width || 0);
|
|
318
|
+
const height = Number(size?.height || 0);
|
|
319
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
320
|
+
return { width, height };
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function requestDevtoolsOpen(profileId, options = {}) {
|
|
327
|
+
const id = String(profileId || '').trim();
|
|
328
|
+
if (!id) {
|
|
329
|
+
return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
|
|
333
|
+
? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
|
|
334
|
+
: DEVTOOLS_SHORTCUTS;
|
|
335
|
+
const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
|
|
336
|
+
const before = await probeViewportSize(id);
|
|
337
|
+
const attempts = [];
|
|
338
|
+
|
|
339
|
+
for (const key of shortcuts) {
|
|
340
|
+
try {
|
|
341
|
+
await callAPI('keyboard:press', { profileId: id, key });
|
|
342
|
+
attempts.push({ key, ok: true });
|
|
343
|
+
if (settleMs > 0) {
|
|
344
|
+
// Allow browser UI animation to settle after shortcut.
|
|
345
|
+
// eslint-disable-next-line no-await-in-loop
|
|
346
|
+
await sleep(settleMs);
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
attempts.push({ key, ok: false, error: err?.message || String(err) });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const after = await probeViewportSize(id);
|
|
354
|
+
const beforeHeight = Number(before?.height || 0);
|
|
355
|
+
const afterHeight = Number(after?.height || 0);
|
|
356
|
+
const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
|
|
357
|
+
const successCount = attempts.filter((item) => item.ok).length;
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ok: successCount > 0,
|
|
361
|
+
requested: true,
|
|
362
|
+
shortcuts,
|
|
363
|
+
attempts,
|
|
364
|
+
before,
|
|
365
|
+
after,
|
|
366
|
+
verified: viewportReduced,
|
|
367
|
+
verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function computeTargetViewportFromWindowMetrics(measured) {
|
|
372
|
+
const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
|
|
373
|
+
const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
|
|
374
|
+
const outerWidth = Math.max(320, parseNumber(measured?.outerWidth, innerWidth));
|
|
375
|
+
const outerHeight = Math.max(240, parseNumber(measured?.outerHeight, innerHeight));
|
|
376
|
+
|
|
377
|
+
const rawDeltaW = Math.max(0, outerWidth - innerWidth);
|
|
378
|
+
const rawDeltaH = Math.max(0, outerHeight - innerHeight);
|
|
379
|
+
const frameW = rawDeltaW > 400 ? 16 : Math.min(rawDeltaW, 120);
|
|
380
|
+
const frameH = rawDeltaH > 400 ? 88 : Math.min(rawDeltaH, 180);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
width: Math.max(320, outerWidth - frameW),
|
|
384
|
+
height: Math.max(240, outerHeight - frameH),
|
|
385
|
+
frameW,
|
|
386
|
+
frameH,
|
|
387
|
+
innerWidth,
|
|
388
|
+
innerHeight,
|
|
389
|
+
outerWidth,
|
|
390
|
+
outerHeight,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function computeStartWindowSize(metrics, options = {}) {
|
|
395
|
+
const display = metrics?.metrics || metrics || {};
|
|
396
|
+
const reserveFromEnv = parseNumber(process.env.CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE, START_WINDOW_DEFAULT_RESERVE);
|
|
397
|
+
const reserve = clamp(
|
|
398
|
+
parseNumber(options.reservePx, reserveFromEnv),
|
|
399
|
+
0,
|
|
400
|
+
START_WINDOW_MAX_RESERVE,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const workWidth = parseNumber(display.workWidth, 0);
|
|
404
|
+
const workHeight = parseNumber(display.workHeight, 0);
|
|
405
|
+
const width = parseNumber(display.width, 0);
|
|
406
|
+
const height = parseNumber(display.height, 0);
|
|
407
|
+
const baseW = Math.floor(workWidth > 0 ? workWidth : width);
|
|
408
|
+
const baseH = Math.floor(workHeight > 0 ? workHeight : height);
|
|
409
|
+
|
|
410
|
+
if (baseW <= 0 || baseH <= 0) {
|
|
411
|
+
return {
|
|
412
|
+
width: 1920,
|
|
413
|
+
height: 1000,
|
|
414
|
+
reservePx: reserve,
|
|
415
|
+
source: 'fallback',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
width: Math.max(START_WINDOW_MIN_WIDTH, baseW),
|
|
421
|
+
height: Math.max(START_WINDOW_MIN_HEIGHT, baseH - reserve),
|
|
422
|
+
reservePx: reserve,
|
|
423
|
+
source: workWidth > 0 || workHeight > 0 ? 'workArea' : 'screen',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function probeWindowMetrics(profileId) {
|
|
428
|
+
const measured = await callAPI('evaluate', {
|
|
429
|
+
profileId,
|
|
430
|
+
script: '({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, outerHeight: window.outerHeight })',
|
|
431
|
+
});
|
|
432
|
+
return measured?.result || {};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function syncWindowViewportAfterResize(profileId, width, height, options = {}) {
|
|
436
|
+
const settleMs = Math.max(40, parseNumber(options.settleMs, 120));
|
|
437
|
+
const attempts = Math.max(1, Math.min(8, Math.floor(parseNumber(options.attempts, 4))));
|
|
438
|
+
const tolerancePx = Math.max(0, parseNumber(options.tolerancePx, 3));
|
|
439
|
+
|
|
440
|
+
const windowResult = await callAPI('window:resize', { profileId, width, height });
|
|
441
|
+
await sleep(settleMs);
|
|
442
|
+
|
|
443
|
+
let measured = {};
|
|
444
|
+
let verified = {};
|
|
445
|
+
let viewport = null;
|
|
446
|
+
let matched = false;
|
|
447
|
+
let target = { width: 1280, height: 720, frameW: 16, frameH: 88 };
|
|
448
|
+
|
|
449
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
450
|
+
measured = await probeWindowMetrics(profileId);
|
|
451
|
+
target = computeTargetViewportFromWindowMetrics(measured);
|
|
452
|
+
viewport = await callAPI('page:setViewport', {
|
|
453
|
+
profileId,
|
|
454
|
+
width: target.width,
|
|
455
|
+
height: target.height,
|
|
456
|
+
});
|
|
457
|
+
await sleep(settleMs);
|
|
458
|
+
verified = await probeWindowMetrics(profileId);
|
|
459
|
+
const dw = Math.abs(parseNumber(verified?.innerWidth, 0) - target.width);
|
|
460
|
+
const dh = Math.abs(parseNumber(verified?.innerHeight, 0) - target.height);
|
|
461
|
+
if (dw <= tolerancePx && dh <= tolerancePx) {
|
|
462
|
+
matched = true;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
window: windowResult,
|
|
469
|
+
measured,
|
|
470
|
+
verified,
|
|
471
|
+
targetViewport: {
|
|
472
|
+
width: target.width,
|
|
473
|
+
height: target.height,
|
|
474
|
+
frameW: target.frameW,
|
|
475
|
+
frameH: target.frameH,
|
|
476
|
+
matched,
|
|
477
|
+
},
|
|
478
|
+
viewport,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function handleStartCommand(args) {
|
|
483
|
+
ensureCamoufox();
|
|
484
|
+
await ensureBrowserService();
|
|
485
|
+
cleanupStaleLocks();
|
|
486
|
+
cleanupStaleSessions();
|
|
487
|
+
|
|
488
|
+
const urlIdx = args.indexOf('--url');
|
|
489
|
+
const explicitUrl = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
|
|
490
|
+
const widthIdx = args.indexOf('--width');
|
|
491
|
+
const heightIdx = args.indexOf('--height');
|
|
492
|
+
const explicitWidth = widthIdx >= 0 ? parseNumber(args[widthIdx + 1], NaN) : NaN;
|
|
493
|
+
const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
|
|
494
|
+
const hasExplicitWidth = Number.isFinite(explicitWidth);
|
|
495
|
+
const hasExplicitHeight = Number.isFinite(explicitHeight);
|
|
496
|
+
const alias = validateAlias(readFlagValue(args, ['--alias']));
|
|
497
|
+
const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
|
|
498
|
+
const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
|
|
499
|
+
const wantsDevtools = args.includes('--devtools');
|
|
500
|
+
const wantsRecord = args.includes('--record');
|
|
501
|
+
const recordName = readFlagValue(args, ['--record-name']);
|
|
502
|
+
const recordOutputRaw = readFlagValue(args, ['--record-output']);
|
|
503
|
+
const recordOverlay = args.includes('--no-record-overlay')
|
|
504
|
+
? false
|
|
505
|
+
: args.includes('--record-overlay')
|
|
506
|
+
? true
|
|
507
|
+
: null;
|
|
508
|
+
if (hasExplicitWidth !== hasExplicitHeight) {
|
|
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>]');
|
|
510
|
+
}
|
|
511
|
+
if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
|
|
512
|
+
throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
|
|
513
|
+
}
|
|
514
|
+
if (args.includes('--record-name') && !recordName) {
|
|
515
|
+
throw new Error('Usage: camo start [profileId] --record-name <name>');
|
|
516
|
+
}
|
|
517
|
+
if (args.includes('--record-output') && !recordOutputRaw) {
|
|
518
|
+
throw new Error('Usage: camo start [profileId] --record-output <path>');
|
|
519
|
+
}
|
|
520
|
+
const recordOutput = recordOutputRaw ? path.resolve(recordOutputRaw) : null;
|
|
521
|
+
const hasExplicitWindowSize = hasExplicitWidth && hasExplicitHeight;
|
|
522
|
+
const profileSet = new Set(listProfiles());
|
|
523
|
+
let implicitUrl;
|
|
524
|
+
|
|
525
|
+
let profileId = null;
|
|
526
|
+
for (let i = 1; i < args.length; i++) {
|
|
527
|
+
const arg = args[i];
|
|
528
|
+
if (arg === '--url') { i++; continue; }
|
|
529
|
+
if (arg === '--width' || arg === '--height') { i++; continue; }
|
|
530
|
+
if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
|
|
531
|
+
if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
|
|
532
|
+
if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
|
|
533
|
+
if (arg.startsWith('--')) continue;
|
|
534
|
+
|
|
535
|
+
if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
|
|
536
|
+
implicitUrl = arg;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
profileId = arg;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!profileId) {
|
|
545
|
+
profileId = getDefaultProfile();
|
|
546
|
+
if (!profileId) {
|
|
547
|
+
throw new Error('No default profile set. Run: camo profile default <profileId>');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
assertExistingProfile(profileId, profileSet);
|
|
551
|
+
if (alias && isSessionAliasTaken(alias, profileId)) {
|
|
552
|
+
throw new Error(`Alias is already in use: ${alias}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Check for existing session in browser service
|
|
556
|
+
const existing = await getSessionByProfile(profileId);
|
|
557
|
+
if (existing) {
|
|
558
|
+
// Session exists in browser service - update registry and lock
|
|
559
|
+
acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
|
|
560
|
+
const saved = getSessionInfo(profileId);
|
|
561
|
+
const record = saved
|
|
562
|
+
? updateSession(profileId, {
|
|
563
|
+
sessionId: existing.session_id || existing.profileId,
|
|
564
|
+
url: existing.current_url,
|
|
565
|
+
mode: existing.mode,
|
|
566
|
+
alias: alias || saved.alias || null,
|
|
567
|
+
})
|
|
568
|
+
: registerSession(profileId, {
|
|
569
|
+
sessionId: existing.session_id || existing.profileId,
|
|
570
|
+
url: existing.current_url,
|
|
571
|
+
mode: existing.mode,
|
|
572
|
+
alias: alias || null,
|
|
573
|
+
});
|
|
574
|
+
const idleState = computeIdleState(record);
|
|
575
|
+
const payload = {
|
|
576
|
+
ok: true,
|
|
577
|
+
sessionId: existing.session_id || existing.profileId,
|
|
578
|
+
instanceId: record.instanceId,
|
|
579
|
+
profileId,
|
|
580
|
+
message: 'Session already running',
|
|
581
|
+
url: existing.current_url,
|
|
582
|
+
alias: record.alias || null,
|
|
583
|
+
idleTimeoutMs: idleState.timeoutMs,
|
|
584
|
+
idleTimeout: formatDurationMs(idleState.timeoutMs),
|
|
585
|
+
closeHint: {
|
|
586
|
+
byProfile: `camo stop ${profileId}`,
|
|
587
|
+
byId: `camo stop --id ${record.instanceId}`,
|
|
588
|
+
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
|
|
592
|
+
const existingHeadless = existing?.headless === true || existingMode.includes('headless');
|
|
593
|
+
if (!existingHeadless && wantsDevtools) {
|
|
594
|
+
payload.devtools = await requestDevtoolsOpen(profileId);
|
|
595
|
+
}
|
|
596
|
+
if (wantsRecord) {
|
|
597
|
+
payload.recording = await callAPI('record:start', {
|
|
598
|
+
profileId,
|
|
599
|
+
...(recordName ? { name: recordName } : {}),
|
|
600
|
+
...(recordOutput ? { outputPath: recordOutput } : {}),
|
|
601
|
+
...(recordOverlay !== null ? { overlay: recordOverlay } : {}),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
605
|
+
startSessionWatchdog(profileId);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// No session in browser service - check registry for recovery
|
|
610
|
+
const registryInfo = getSessionInfo(profileId);
|
|
611
|
+
if (registryInfo && registryInfo.status === 'active') {
|
|
612
|
+
// Session was active but browser service doesn't have it
|
|
613
|
+
// This means service was restarted - clean up and start fresh
|
|
614
|
+
unregisterSession(profileId);
|
|
615
|
+
releaseLock(profileId);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const headless = !args.includes('--no-headless') && !args.includes('--visible');
|
|
619
|
+
if (wantsDevtools && headless) {
|
|
620
|
+
throw new Error('--devtools requires --no-headless or --visible mode');
|
|
621
|
+
}
|
|
622
|
+
const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
|
|
623
|
+
const targetUrl = explicitUrl || implicitUrl;
|
|
624
|
+
const result = await callAPI('start', {
|
|
625
|
+
profileId,
|
|
626
|
+
url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
|
|
627
|
+
headless,
|
|
628
|
+
devtools: wantsDevtools,
|
|
629
|
+
...(wantsRecord ? { record: true } : {}),
|
|
630
|
+
...(recordName ? { recordName } : {}),
|
|
631
|
+
...(recordOutput ? { recordOutput } : {}),
|
|
632
|
+
...(recordOverlay !== null ? { recordOverlay } : {}),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
if (result?.ok) {
|
|
636
|
+
const sessionId = result.sessionId || result.profileId || profileId;
|
|
637
|
+
acquireLock(profileId, { sessionId });
|
|
638
|
+
const record = registerSession(profileId, {
|
|
639
|
+
sessionId,
|
|
640
|
+
url: targetUrl,
|
|
641
|
+
headless,
|
|
642
|
+
alias,
|
|
643
|
+
idleTimeoutMs,
|
|
644
|
+
lastAction: 'start',
|
|
645
|
+
});
|
|
646
|
+
startSessionWatchdog(profileId);
|
|
647
|
+
result.instanceId = record.instanceId;
|
|
648
|
+
result.alias = record.alias || null;
|
|
649
|
+
result.idleTimeoutMs = idleTimeoutMs;
|
|
650
|
+
result.idleTimeout = formatDurationMs(idleTimeoutMs);
|
|
651
|
+
result.closeHint = {
|
|
652
|
+
byProfile: `camo stop ${profileId}`,
|
|
653
|
+
byId: `camo stop --id ${record.instanceId}`,
|
|
654
|
+
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
655
|
+
all: 'camo close all',
|
|
656
|
+
};
|
|
657
|
+
result.message = headless
|
|
658
|
+
? `Started session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
|
|
659
|
+
: 'Started visible session. Remember to stop it when finished.';
|
|
660
|
+
|
|
661
|
+
if (!headless) {
|
|
662
|
+
let windowTarget = null;
|
|
663
|
+
if (hasExplicitWindowSize) {
|
|
664
|
+
windowTarget = {
|
|
665
|
+
width: Math.floor(explicitWidth),
|
|
666
|
+
height: Math.floor(explicitHeight),
|
|
667
|
+
source: 'explicit',
|
|
668
|
+
};
|
|
669
|
+
} else {
|
|
670
|
+
const display = await callAPI('system:display', {}).catch(() => null);
|
|
671
|
+
const displayTarget = computeStartWindowSize(display);
|
|
672
|
+
const rememberedWindow = getProfileWindowSize(profileId);
|
|
673
|
+
if (rememberedWindow) {
|
|
674
|
+
const rememberedTarget = {
|
|
675
|
+
width: rememberedWindow.width,
|
|
676
|
+
height: rememberedWindow.height,
|
|
677
|
+
source: 'profile',
|
|
678
|
+
updatedAt: rememberedWindow.updatedAt,
|
|
679
|
+
};
|
|
680
|
+
const canTrustDisplayTarget = displayTarget?.source && displayTarget.source !== 'fallback';
|
|
681
|
+
const refreshFromDisplay = canTrustDisplayTarget
|
|
682
|
+
&& (
|
|
683
|
+
rememberedTarget.height < Math.floor(displayTarget.height * 0.92)
|
|
684
|
+
|| rememberedTarget.width < Math.floor(displayTarget.width * 0.92)
|
|
685
|
+
);
|
|
686
|
+
windowTarget = refreshFromDisplay ? {
|
|
687
|
+
...displayTarget,
|
|
688
|
+
source: 'display',
|
|
689
|
+
} : rememberedTarget;
|
|
690
|
+
} else {
|
|
691
|
+
windowTarget = displayTarget;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
result.startWindow = {
|
|
696
|
+
width: windowTarget.width,
|
|
697
|
+
height: windowTarget.height,
|
|
698
|
+
source: windowTarget.source,
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const syncResult = await syncWindowViewportAfterResize(
|
|
702
|
+
profileId,
|
|
703
|
+
windowTarget.width,
|
|
704
|
+
windowTarget.height,
|
|
705
|
+
).catch((err) => ({ error: err?.message || String(err) }));
|
|
706
|
+
result.windowSync = syncResult;
|
|
707
|
+
|
|
708
|
+
const measuredOuterWidth = Number(syncResult?.verified?.outerWidth);
|
|
709
|
+
const measuredOuterHeight = Number(syncResult?.verified?.outerHeight);
|
|
710
|
+
const savedWindow = setProfileWindowSize(
|
|
711
|
+
profileId,
|
|
712
|
+
Number.isFinite(measuredOuterWidth) ? measuredOuterWidth : windowTarget.width,
|
|
713
|
+
Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
|
|
714
|
+
);
|
|
715
|
+
result.profileWindow = savedWindow?.window || null;
|
|
716
|
+
if (wantsDevtools) {
|
|
717
|
+
result.devtools = await requestDevtoolsOpen(profileId);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
console.log(JSON.stringify(result, null, 2));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export async function handleStopCommand(args) {
|
|
725
|
+
const rawTarget = String(args[1] || '').trim();
|
|
726
|
+
const target = rawTarget.toLowerCase();
|
|
727
|
+
const idTarget = readFlagValue(args, ['--id']);
|
|
728
|
+
const aliasTarget = readFlagValue(args, ['--alias']);
|
|
729
|
+
if (args.includes('--id') && !idTarget) {
|
|
730
|
+
throw new Error('Usage: camo stop --id <instanceId>');
|
|
731
|
+
}
|
|
732
|
+
if (args.includes('--alias') && !aliasTarget) {
|
|
733
|
+
throw new Error('Usage: camo stop --alias <alias>');
|
|
734
|
+
}
|
|
735
|
+
const stopIdle = target === 'idle' || args.includes('--idle');
|
|
736
|
+
const stopAll = target === 'all';
|
|
737
|
+
const serviceUp = await checkBrowserService();
|
|
738
|
+
const resolvedSessions = await loadResolvedSessions(serviceUp);
|
|
739
|
+
|
|
740
|
+
if (stopAll) {
|
|
741
|
+
const profileSet = new Set(resolvedSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
742
|
+
if (profileSet.size === 0) {
|
|
743
|
+
for (const session of listRegisteredSessions()) {
|
|
744
|
+
if (String(session?.status || '').trim() === 'closed') continue;
|
|
745
|
+
const profileId = String(session?.profileId || '').trim();
|
|
746
|
+
if (profileId) profileSet.add(profileId);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const results = [];
|
|
751
|
+
for (const profileId of profileSet) {
|
|
752
|
+
// eslint-disable-next-line no-await-in-loop
|
|
753
|
+
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
754
|
+
}
|
|
755
|
+
console.log(JSON.stringify({
|
|
756
|
+
ok: true,
|
|
757
|
+
mode: 'all',
|
|
758
|
+
serviceUp,
|
|
759
|
+
closed: results.filter((item) => item.ok).length,
|
|
760
|
+
failed: results.filter((item) => !item.ok).length,
|
|
761
|
+
results,
|
|
762
|
+
}, null, 2));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (stopIdle) {
|
|
767
|
+
const now = Date.now();
|
|
768
|
+
const registeredSessions = listRegisteredSessions();
|
|
769
|
+
const regMap = new Map(
|
|
770
|
+
registeredSessions
|
|
771
|
+
.filter((item) => item && String(item?.status || '').trim() === 'active')
|
|
772
|
+
.map((item) => [String(item.profileId || '').trim(), item]),
|
|
773
|
+
);
|
|
774
|
+
const idleTargets = new Set(
|
|
775
|
+
registeredSessions
|
|
776
|
+
.filter((item) => String(item?.status || '').trim() === 'active')
|
|
777
|
+
.map((item) => ({ session: item, idle: computeIdleState(item, now) }))
|
|
778
|
+
.filter((item) => item.idle.idle)
|
|
779
|
+
.map((item) => item.session.profileId),
|
|
780
|
+
);
|
|
781
|
+
let orphanLiveHeadlessCount = 0;
|
|
782
|
+
for (const live of resolvedSessions.filter((item) => item.live)) {
|
|
783
|
+
const liveProfileId = String(live?.profileId || '').trim();
|
|
784
|
+
if (!liveProfileId) continue;
|
|
785
|
+
if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
|
|
786
|
+
const mode = String(live?.mode || '').toLowerCase();
|
|
787
|
+
const liveHeadless = live?.headless === true || mode.includes('headless');
|
|
788
|
+
// Live but unregistered headless sessions are treated as idle-orphan targets.
|
|
789
|
+
if (liveHeadless) {
|
|
790
|
+
idleTargets.add(liveProfileId);
|
|
791
|
+
orphanLiveHeadlessCount += 1;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const results = [];
|
|
795
|
+
for (const profileId of idleTargets) {
|
|
796
|
+
// eslint-disable-next-line no-await-in-loop
|
|
797
|
+
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
798
|
+
}
|
|
799
|
+
console.log(JSON.stringify({
|
|
800
|
+
ok: true,
|
|
801
|
+
mode: 'idle',
|
|
802
|
+
serviceUp,
|
|
803
|
+
targetCount: idleTargets.size,
|
|
804
|
+
orphanLiveHeadlessCount,
|
|
805
|
+
closed: results.filter((item) => item.ok).length,
|
|
806
|
+
failed: results.filter((item) => !item.ok).length,
|
|
807
|
+
results,
|
|
808
|
+
}, null, 2));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let profileId = null;
|
|
813
|
+
let resolvedBy = 'profile';
|
|
814
|
+
if (idTarget) {
|
|
815
|
+
const resolved = resolveSessionTarget(idTarget);
|
|
816
|
+
if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
|
|
817
|
+
profileId = resolved.profileId;
|
|
818
|
+
resolvedBy = resolved.reason;
|
|
819
|
+
} else if (aliasTarget) {
|
|
820
|
+
const resolved = resolveSessionTarget(aliasTarget);
|
|
821
|
+
if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
|
|
822
|
+
profileId = resolved.profileId;
|
|
823
|
+
resolvedBy = resolved.reason;
|
|
824
|
+
} else {
|
|
825
|
+
const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
|
|
826
|
+
if (positional) {
|
|
827
|
+
const resolved = resolveSessionTarget(positional);
|
|
828
|
+
if (resolved) {
|
|
829
|
+
profileId = resolved.profileId;
|
|
830
|
+
resolvedBy = resolved.reason;
|
|
831
|
+
} else {
|
|
832
|
+
profileId = positional;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!profileId) {
|
|
838
|
+
profileId = getDefaultProfile();
|
|
839
|
+
}
|
|
840
|
+
if (!profileId) {
|
|
841
|
+
throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const result = await stopAndCleanupProfile(profileId, { serviceUp });
|
|
845
|
+
if (!result.ok && serviceUp) {
|
|
846
|
+
throw new Error(result.error || `stop failed for profile: ${profileId}`);
|
|
847
|
+
}
|
|
848
|
+
console.log(JSON.stringify({
|
|
849
|
+
ok: true,
|
|
850
|
+
profileId,
|
|
851
|
+
resolvedBy,
|
|
852
|
+
serviceUp,
|
|
853
|
+
warning: (!serviceUp && !result.ok) ? result.error : null,
|
|
854
|
+
result: result.result || null,
|
|
855
|
+
}, null, 2));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export async function handleStatusCommand(args) {
|
|
859
|
+
await ensureBrowserService();
|
|
860
|
+
const profileId = args[1];
|
|
861
|
+
if (profileId && args[0] === 'status') {
|
|
862
|
+
const sessions = await getResolvedSessions();
|
|
863
|
+
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
864
|
+
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const sessions = await getResolvedSessions();
|
|
868
|
+
console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export async function handleGotoCommand(args) {
|
|
872
|
+
await ensureBrowserService();
|
|
873
|
+
const positionals = getPositionals(args);
|
|
874
|
+
const profileSet = new Set(listProfiles());
|
|
875
|
+
|
|
876
|
+
let profileId;
|
|
877
|
+
let url;
|
|
878
|
+
|
|
879
|
+
if (positionals.length === 1) {
|
|
880
|
+
profileId = getDefaultProfile();
|
|
881
|
+
url = positionals[0];
|
|
882
|
+
} else {
|
|
883
|
+
profileId = resolveProfileId(positionals, 0, getDefaultProfile);
|
|
884
|
+
url = positionals[1];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
|
|
888
|
+
if (!url) throw new Error('Usage: camo goto [profileId] <url>');
|
|
889
|
+
assertExistingProfile(profileId, profileSet);
|
|
890
|
+
const active = await getSessionByProfile(profileId);
|
|
891
|
+
if (!active) {
|
|
892
|
+
throw new Error(
|
|
893
|
+
`No active session for profile: ${profileId}. Start via "camo start ${profileId}" (or UI CLI startup) before goto.`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
|
|
898
|
+
updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
|
|
899
|
+
console.log(JSON.stringify(result, null, 2));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export async function handleBackCommand(args) {
|
|
903
|
+
await ensureBrowserService();
|
|
904
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
905
|
+
if (!profileId) throw new Error('Usage: camo back [profileId] (or set default profile first)');
|
|
906
|
+
const result = await callAPI('page:back', { profileId });
|
|
907
|
+
console.log(JSON.stringify(result, null, 2));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export async function handleScreenshotCommand(args) {
|
|
911
|
+
await ensureBrowserService();
|
|
912
|
+
const fullPage = args.includes('--full');
|
|
913
|
+
const outputIdx = args.indexOf('--output');
|
|
914
|
+
const output = outputIdx >= 0 ? args[outputIdx + 1] : null;
|
|
915
|
+
|
|
916
|
+
let profileId = null;
|
|
917
|
+
for (let i = 1; i < args.length; i++) {
|
|
918
|
+
const arg = args[i];
|
|
919
|
+
if (arg === '--full') continue;
|
|
920
|
+
if (arg === '--output') { i++; continue; }
|
|
921
|
+
if (arg.startsWith('--')) continue;
|
|
922
|
+
profileId = arg;
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (!profileId) profileId = getDefaultProfile();
|
|
927
|
+
if (!profileId) throw new Error('Usage: camo screenshot [profileId] [--output <file>] [--full]');
|
|
928
|
+
|
|
929
|
+
const result = await callAPI('screenshot', { profileId, fullPage });
|
|
930
|
+
|
|
931
|
+
if (output && result?.data) {
|
|
932
|
+
fs.writeFileSync(output, Buffer.from(result.data, 'base64'));
|
|
933
|
+
console.log(`Screenshot saved to ${output}`);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
console.log(JSON.stringify(result, null, 2));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export async function handleScrollCommand(args) {
|
|
941
|
+
await ensureBrowserService();
|
|
942
|
+
const directionFlags = new Set(['--up', '--down', '--left', '--right']);
|
|
943
|
+
const isFlag = (arg) => arg?.startsWith('--');
|
|
944
|
+
const selectorIdx = args.indexOf('--selector');
|
|
945
|
+
const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
|
|
946
|
+
const highlightRequested = resolveHighlightEnabled(args);
|
|
947
|
+
const highlight = highlightRequested;
|
|
948
|
+
|
|
949
|
+
let profileId = null;
|
|
950
|
+
for (let i = 1; i < args.length; i++) {
|
|
951
|
+
const arg = args[i];
|
|
952
|
+
if (directionFlags.has(arg)) continue;
|
|
953
|
+
if (arg === '--amount') { i++; continue; }
|
|
954
|
+
if (arg === '--selector') { i++; continue; }
|
|
955
|
+
if (arg === '--highlight' || arg === '--no-highlight') continue;
|
|
956
|
+
if (isFlag(arg)) continue;
|
|
957
|
+
profileId = arg;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
if (!profileId) profileId = getDefaultProfile();
|
|
961
|
+
if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]');
|
|
962
|
+
if (selectorIdx >= 0 && !selector) {
|
|
963
|
+
throw new Error('Usage: camo scroll [profileId] --selector <css>');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
|
|
967
|
+
const amountIdx = args.indexOf('--amount');
|
|
968
|
+
const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
|
|
969
|
+
|
|
970
|
+
const target = await callAPI('evaluate', {
|
|
971
|
+
profileId,
|
|
972
|
+
script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
|
|
973
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
974
|
+
const scrollTarget = target?.result || null;
|
|
975
|
+
if (!scrollTarget?.ok || !scrollTarget?.center) {
|
|
976
|
+
throw new Error(scrollTarget?.error || 'visible scroll container not found');
|
|
977
|
+
}
|
|
978
|
+
const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
|
979
|
+
const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
|
980
|
+
await callAPI('mouse:click', {
|
|
981
|
+
profileId,
|
|
982
|
+
x: scrollTarget.center.x,
|
|
983
|
+
y: scrollTarget.center.y,
|
|
984
|
+
button: 'left',
|
|
985
|
+
clicks: 1,
|
|
986
|
+
delay: 30,
|
|
987
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
988
|
+
const result = await callAPI('mouse:wheel', {
|
|
989
|
+
profileId,
|
|
990
|
+
deltaX,
|
|
991
|
+
deltaY,
|
|
992
|
+
anchorX: scrollTarget.center.x,
|
|
993
|
+
anchorY: scrollTarget.center.y,
|
|
994
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
995
|
+
console.log(JSON.stringify({
|
|
996
|
+
...result,
|
|
997
|
+
scrollTarget,
|
|
998
|
+
highlight,
|
|
999
|
+
}, null, 2));
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export async function handleClickCommand(args) {
|
|
1003
|
+
await ensureBrowserService();
|
|
1004
|
+
const positionals = getPositionals(args);
|
|
1005
|
+
const highlight = resolveHighlightEnabled(args);
|
|
1006
|
+
let profileId;
|
|
1007
|
+
let selector;
|
|
1008
|
+
|
|
1009
|
+
if (positionals.length === 1) {
|
|
1010
|
+
profileId = getDefaultProfile();
|
|
1011
|
+
selector = positionals[0];
|
|
1012
|
+
} else {
|
|
1013
|
+
profileId = positionals[0];
|
|
1014
|
+
selector = positionals[1];
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
|
|
1018
|
+
if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
|
|
1019
|
+
|
|
1020
|
+
let target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
|
|
1021
|
+
const ensured = await ensureClickTargetInViewport(profileId, selector, target, {
|
|
1022
|
+
maxAutoScrollSteps: 3,
|
|
1023
|
+
});
|
|
1024
|
+
if (!ensured.targetFullyVisible) {
|
|
1025
|
+
throw new Error(`Click target not fully visible after ${ensured.autoScrolled} auto-scroll attempts: ${selector}`);
|
|
1026
|
+
}
|
|
1027
|
+
target = ensured.target;
|
|
1028
|
+
if (highlight) {
|
|
1029
|
+
target = await resolveVisibleTargetPoint(profileId, selector, { highlight: true });
|
|
1030
|
+
}
|
|
1031
|
+
const result = await callAPI('mouse:click', {
|
|
1032
|
+
profileId,
|
|
1033
|
+
x: target.center.x,
|
|
1034
|
+
y: target.center.y,
|
|
1035
|
+
button: 'left',
|
|
1036
|
+
clicks: 1,
|
|
1037
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1038
|
+
console.log(JSON.stringify({
|
|
1039
|
+
...result,
|
|
1040
|
+
selector,
|
|
1041
|
+
highlight,
|
|
1042
|
+
autoScrolled: ensured.autoScrolled,
|
|
1043
|
+
targetFullyVisible: ensured.targetFullyVisible,
|
|
1044
|
+
target,
|
|
1045
|
+
}, null, 2));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export async function handleTypeCommand(args) {
|
|
1049
|
+
await ensureBrowserService();
|
|
1050
|
+
const positionals = getPositionals(args);
|
|
1051
|
+
const highlight = resolveHighlightEnabled(args);
|
|
1052
|
+
let profileId;
|
|
1053
|
+
let selector;
|
|
1054
|
+
let text;
|
|
1055
|
+
|
|
1056
|
+
if (positionals.length === 2) {
|
|
1057
|
+
profileId = getDefaultProfile();
|
|
1058
|
+
selector = positionals[0];
|
|
1059
|
+
text = positionals[1];
|
|
1060
|
+
} else {
|
|
1061
|
+
profileId = positionals[0];
|
|
1062
|
+
selector = positionals[1];
|
|
1063
|
+
text = positionals[2];
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
|
|
1067
|
+
if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
|
|
1068
|
+
|
|
1069
|
+
const target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
|
|
1070
|
+
await callAPI('mouse:click', {
|
|
1071
|
+
profileId,
|
|
1072
|
+
x: target.center.x,
|
|
1073
|
+
y: target.center.y,
|
|
1074
|
+
button: 'left',
|
|
1075
|
+
clicks: 1,
|
|
1076
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1077
|
+
await callAPI('keyboard:press', {
|
|
1078
|
+
profileId,
|
|
1079
|
+
key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
|
|
1080
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1081
|
+
await callAPI('keyboard:press', { profileId, key: 'Backspace' }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1082
|
+
const result = await callAPI('keyboard:type', {
|
|
1083
|
+
profileId,
|
|
1084
|
+
text: String(text),
|
|
1085
|
+
}, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
|
|
1086
|
+
console.log(JSON.stringify({
|
|
1087
|
+
...result,
|
|
1088
|
+
selector,
|
|
1089
|
+
typed: String(text).length,
|
|
1090
|
+
highlight,
|
|
1091
|
+
target,
|
|
1092
|
+
}, null, 2));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export async function handleHighlightCommand(args) {
|
|
1096
|
+
await ensureBrowserService();
|
|
1097
|
+
const positionals = getPositionals(args);
|
|
1098
|
+
let profileId;
|
|
1099
|
+
let selector;
|
|
1100
|
+
|
|
1101
|
+
if (positionals.length === 1) {
|
|
1102
|
+
profileId = getDefaultProfile();
|
|
1103
|
+
selector = positionals[0];
|
|
1104
|
+
} else {
|
|
1105
|
+
profileId = positionals[0];
|
|
1106
|
+
selector = positionals[1];
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (!profileId) throw new Error('Usage: camo highlight [profileId] <selector>');
|
|
1110
|
+
if (!selector) throw new Error('Usage: camo highlight [profileId] <selector>');
|
|
1111
|
+
|
|
1112
|
+
const result = await callAPI('browser:highlight', {
|
|
1113
|
+
profile: profileId,
|
|
1114
|
+
profileId,
|
|
1115
|
+
selector,
|
|
1116
|
+
});
|
|
1117
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export async function handleClearHighlightCommand(args) {
|
|
1121
|
+
await ensureBrowserService();
|
|
1122
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1123
|
+
if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
|
|
1124
|
+
|
|
1125
|
+
const result = await callAPI('browser:clear-highlight', {
|
|
1126
|
+
profile: profileId,
|
|
1127
|
+
profileId,
|
|
1128
|
+
});
|
|
1129
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export async function handleViewportCommand(args) {
|
|
1133
|
+
await ensureBrowserService();
|
|
1134
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1135
|
+
if (!profileId) throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
|
|
1136
|
+
|
|
1137
|
+
const widthIdx = args.indexOf('--width');
|
|
1138
|
+
const heightIdx = args.indexOf('--height');
|
|
1139
|
+
const width = widthIdx >= 0 ? Number(args[widthIdx + 1]) : 1280;
|
|
1140
|
+
const height = heightIdx >= 0 ? Number(args[heightIdx + 1]) : 800;
|
|
1141
|
+
|
|
1142
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
1143
|
+
throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const result = await callAPI('page:setViewport', { profileId, width, height });
|
|
1147
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export async function handleNewPageCommand(args) {
|
|
1151
|
+
await ensureBrowserService();
|
|
1152
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1153
|
+
if (!profileId) throw new Error('Usage: camo new-page [profileId] [--url <url>] (or set default profile first)');
|
|
1154
|
+
const urlIdx = args.indexOf('--url');
|
|
1155
|
+
const url = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
|
|
1156
|
+
const result = await callAPI('newPage', { profileId, ...(url ? { url: ensureUrlScheme(url) } : {}) });
|
|
1157
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export async function handleClosePageCommand(args) {
|
|
1161
|
+
await ensureBrowserService();
|
|
1162
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1163
|
+
if (!profileId) throw new Error('Usage: camo close-page [profileId] [index] (or set default profile first)');
|
|
1164
|
+
|
|
1165
|
+
let index;
|
|
1166
|
+
for (let i = args.length - 1; i >= 1; i--) {
|
|
1167
|
+
const arg = args[i];
|
|
1168
|
+
if (arg.startsWith('--')) continue;
|
|
1169
|
+
const num = Number(arg);
|
|
1170
|
+
if (Number.isFinite(num)) { index = num; break; }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const result = await callAPI('page:close', { profileId, ...(Number.isFinite(index) ? { index } : {}) });
|
|
1174
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export async function handleSwitchPageCommand(args) {
|
|
1178
|
+
await ensureBrowserService();
|
|
1179
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1180
|
+
if (!profileId) throw new Error('Usage: camo switch-page [profileId] <index> (or set default profile first)');
|
|
1181
|
+
|
|
1182
|
+
let index;
|
|
1183
|
+
for (let i = args.length - 1; i >= 1; i--) {
|
|
1184
|
+
const arg = args[i];
|
|
1185
|
+
if (arg.startsWith('--')) continue;
|
|
1186
|
+
const num = Number(arg);
|
|
1187
|
+
if (Number.isFinite(num)) { index = num; break; }
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (!Number.isFinite(index)) throw new Error('Usage: camo switch-page [profileId] <index>');
|
|
1191
|
+
const result = await callAPI('page:switch', { profileId, index });
|
|
1192
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export async function handleListPagesCommand(args) {
|
|
1196
|
+
await ensureBrowserService();
|
|
1197
|
+
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1198
|
+
if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
|
|
1199
|
+
const sessions = await getResolvedSessions();
|
|
1200
|
+
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
1201
|
+
if (!session?.live) {
|
|
1202
|
+
throw new Error(`Profile session not live: ${profileId}. Start via "camo start ${profileId}" first.`);
|
|
1203
|
+
}
|
|
1204
|
+
const result = await callAPI('page:list', { profileId });
|
|
1205
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
export async function handleShutdownCommand() {
|
|
1209
|
+
await ensureBrowserService();
|
|
1210
|
+
|
|
1211
|
+
// Get all active sessions
|
|
1212
|
+
const status = await callAPI('getStatus', {});
|
|
1213
|
+
const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
1214
|
+
|
|
1215
|
+
// Stop each session and cleanup registry
|
|
1216
|
+
for (const session of sessions) {
|
|
1217
|
+
try {
|
|
1218
|
+
await callAPI('stop', { profileId: session.profileId });
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Best effort cleanup
|
|
1221
|
+
}
|
|
1222
|
+
stopSessionWatchdog(session.profileId);
|
|
1223
|
+
releaseLock(session.profileId);
|
|
1224
|
+
markSessionClosed(session.profileId);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Cleanup any remaining registry entries
|
|
1228
|
+
const registered = listRegisteredSessions();
|
|
1229
|
+
for (const reg of registered) {
|
|
1230
|
+
if (reg.status !== 'closed') {
|
|
1231
|
+
stopSessionWatchdog(reg.profileId);
|
|
1232
|
+
markSessionClosed(reg.profileId);
|
|
1233
|
+
releaseLock(reg.profileId);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
stopAllSessionWatchdogs();
|
|
1237
|
+
|
|
1238
|
+
const result = await callAPI('service:shutdown', {});
|
|
1239
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export async function handleSessionsCommand(args) {
|
|
1243
|
+
const serviceUp = await checkBrowserService();
|
|
1244
|
+
const merged = await loadResolvedSessions(serviceUp);
|
|
1245
|
+
const registeredSessions = listRegisteredSessions();
|
|
1246
|
+
|
|
1247
|
+
console.log(JSON.stringify({
|
|
1248
|
+
ok: true,
|
|
1249
|
+
serviceUp,
|
|
1250
|
+
sessions: merged,
|
|
1251
|
+
count: merged.length,
|
|
1252
|
+
registered: registeredSessions.length,
|
|
1253
|
+
live: liveSessions.length,
|
|
1254
|
+
}, null, 2));
|
|
1255
|
+
}
|