@web-auto/camo 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -6
- package/package.json +1 -1
- package/scripts/bump-version.mjs +5 -9
- package/src/commands/browser.mjs +50 -56
- package/src/commands/lifecycle.mjs +47 -83
- package/src/container/runtime-core/operations/index.mjs +19 -1
- package/src/container/runtime-core/operations/selector-scripts.mjs +22 -4
- package/src/container/runtime-core/search.mjs +190 -0
- package/src/lifecycle/session-registry.mjs +21 -5
- package/src/lifecycle/session-view.mjs +76 -0
- package/src/services/browser-service/index.js +8 -2
- package/src/services/browser-service/internal/BrowserSession.input.test.js +36 -2
- package/src/services/browser-service/internal/browser-session/input-ops.js +10 -4
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +11 -2
- package/src/services/browser-service/internal/browser-session/page-management.js +26 -18
- package/src/services/browser-service/internal/browser-session/utils.js +21 -1
- package/src/utils/browser-service.mjs +18 -6
- package/src/utils/help.mjs +29 -6
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ camo stop --id <instanceId>
|
|
|
176
176
|
camo stop --alias <alias>
|
|
177
177
|
camo stop idle
|
|
178
178
|
camo stop all
|
|
179
|
-
camo status [profileId]
|
|
179
|
+
camo status [profileId] # Show resolved per-profile session view
|
|
180
180
|
camo shutdown # Shutdown browser-service (all sessions)
|
|
181
181
|
```
|
|
182
182
|
|
|
@@ -186,19 +186,53 @@ Use `--width/--height` to override and update the saved profile size.
|
|
|
186
186
|
For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
|
|
187
187
|
Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
|
|
188
188
|
Use `--record` to auto-enable JSONL recording at startup; `--record-name`, `--record-output`, and `--record-overlay` customize file naming/output and floating toggle UI.
|
|
189
|
+
Set `CAMO_BRING_TO_FRONT_MODE=never` to keep protocol-level input and page lifecycle operations from forcing the browser window to front during headed runs.
|
|
190
|
+
`CAMO_SKIP_BRING_TO_FRONT=1` remains supported as a legacy alias.
|
|
189
191
|
|
|
190
192
|
### Lifecycle & Cleanup
|
|
191
193
|
|
|
192
194
|
```bash
|
|
193
|
-
camo instances # List
|
|
194
|
-
camo sessions # List
|
|
195
|
-
camo cleanup [profileId] # Cleanup
|
|
195
|
+
camo instances # List resolved session view (live + registered + idle state)
|
|
196
|
+
camo sessions # List resolved session view for all profiles
|
|
197
|
+
camo cleanup [profileId] # Cleanup only one profile (remote stop + local registry/lock/watchdog)
|
|
196
198
|
camo cleanup all # Cleanup all active sessions
|
|
197
199
|
camo cleanup locks # Cleanup stale lock files
|
|
198
|
-
camo force-stop [profileId] # Force stop
|
|
200
|
+
camo force-stop [profileId] # Force stop only one profile (no alias/id targeting)
|
|
199
201
|
camo lock list # List active session locks
|
|
200
202
|
```
|
|
201
203
|
|
|
204
|
+
Session isolation rules:
|
|
205
|
+
- `profileId` is the lifecycle primary key across browser-service session, local registry, watchdog, and lock.
|
|
206
|
+
- `camo start/stop/cleanup/force-stop <profileId>` only target that exact profile and must not affect other profiles.
|
|
207
|
+
- `camo stop --id` and `camo stop --alias` are stop-only convenience selectors; `cleanup` and `force-stop` intentionally reject indirect targeting.
|
|
208
|
+
- `camo status`, `camo sessions`, and `camo instances` share the same resolved session view fields:
|
|
209
|
+
- `live`: browser-service currently has this profile session
|
|
210
|
+
- `registered`: local registry has metadata for this profile
|
|
211
|
+
- `orphaned`: registry exists but the service session is gone
|
|
212
|
+
- `needsRecovery`: registry still says active but browser-service no longer has that profile
|
|
213
|
+
|
|
214
|
+
Isolation examples:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Observe both profiles independently
|
|
218
|
+
camo sessions
|
|
219
|
+
camo status finger
|
|
220
|
+
camo status xhs-qa-1
|
|
221
|
+
|
|
222
|
+
# Stop only finger; xhs-qa-1 must remain live
|
|
223
|
+
camo stop finger
|
|
224
|
+
camo status finger
|
|
225
|
+
camo status xhs-qa-1
|
|
226
|
+
|
|
227
|
+
# cleanup / force-stop require direct profile targeting
|
|
228
|
+
camo cleanup finger
|
|
229
|
+
camo force-stop finger
|
|
230
|
+
|
|
231
|
+
# Invalid on purpose: indirect targeting is rejected
|
|
232
|
+
camo cleanup --alias shard1
|
|
233
|
+
camo force-stop --id inst_xxxxxxxx
|
|
234
|
+
```
|
|
235
|
+
|
|
202
236
|
### Navigation
|
|
203
237
|
|
|
204
238
|
```bash
|
|
@@ -252,7 +286,7 @@ Recorder JSONL events include:
|
|
|
252
286
|
camo new-page [profileId] [--url <url>]
|
|
253
287
|
camo close-page [profileId] [index]
|
|
254
288
|
camo switch-page [profileId] <index>
|
|
255
|
-
camo list-pages [profileId]
|
|
289
|
+
camo list-pages [profileId] # Requires live=true for that profile
|
|
256
290
|
```
|
|
257
291
|
|
|
258
292
|
### Cookies
|
package/package.json
CHANGED
package/scripts/bump-version.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Version bumper for camo CLI
|
|
4
|
-
* Increments patch version
|
|
3
|
+
* Version bumper for camo CLI.
|
|
4
|
+
* Increments standard semver patch version (0.1.21 -> 0.1.22).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { readFileSync, writeFileSync } from 'fs';
|
|
@@ -15,18 +15,14 @@ const packageJsonPath = join(__dirname, '..', 'package.json');
|
|
|
15
15
|
function bumpVersion() {
|
|
16
16
|
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
17
17
|
const currentVersion = pkg.version;
|
|
18
|
-
|
|
19
|
-
// Parse version: 0.1.0001 -> [0, 1, 1]
|
|
20
18
|
const parts = currentVersion.split('.');
|
|
21
19
|
const major = parseInt(parts[0], 10);
|
|
22
20
|
const minor = parseInt(parts[1], 10);
|
|
23
21
|
let patch = parseInt(parts[2], 10);
|
|
24
|
-
|
|
25
|
-
// Increment patch
|
|
22
|
+
|
|
26
23
|
patch += 1;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const newVersion = `${major}.${minor}.${patch.toString().padStart(4, '0')}`;
|
|
24
|
+
|
|
25
|
+
const newVersion = `${major}.${minor}.${patch}`;
|
|
30
26
|
|
|
31
27
|
pkg.version = newVersion;
|
|
32
28
|
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
|
package/src/commands/browser.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getProfileWindowSize,
|
|
8
8
|
setProfileWindowSize,
|
|
9
9
|
} from '../utils/config.mjs';
|
|
10
|
-
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
|
|
10
|
+
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
11
11
|
import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
|
|
12
12
|
import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
|
|
13
13
|
import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
|
|
@@ -298,6 +298,15 @@ async function stopAndCleanupProfile(profileId, options = {}) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function loadResolvedSessions(serviceUp) {
|
|
302
|
+
if (!serviceUp) return [];
|
|
303
|
+
try {
|
|
304
|
+
return await getResolvedSessions();
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
301
310
|
async function probeViewportSize(profileId) {
|
|
302
311
|
try {
|
|
303
312
|
const payload = await callAPI('evaluate', {
|
|
@@ -726,23 +735,17 @@ export async function handleStopCommand(args) {
|
|
|
726
735
|
const stopIdle = target === 'idle' || args.includes('--idle');
|
|
727
736
|
const stopAll = target === 'all';
|
|
728
737
|
const serviceUp = await checkBrowserService();
|
|
738
|
+
const resolvedSessions = await loadResolvedSessions(serviceUp);
|
|
729
739
|
|
|
730
740
|
if (stopAll) {
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
// Ignore and fallback to local registry.
|
|
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);
|
|
738
747
|
}
|
|
739
748
|
}
|
|
740
|
-
const profileSet = new Set(liveSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
741
|
-
for (const session of listRegisteredSessions()) {
|
|
742
|
-
if (String(session?.status || '').trim() === 'closed') continue;
|
|
743
|
-
const profileId = String(session?.profileId || '').trim();
|
|
744
|
-
if (profileId) profileSet.add(profileId);
|
|
745
|
-
}
|
|
746
749
|
|
|
747
750
|
const results = [];
|
|
748
751
|
for (const profileId of profileSet) {
|
|
@@ -763,15 +766,6 @@ export async function handleStopCommand(args) {
|
|
|
763
766
|
if (stopIdle) {
|
|
764
767
|
const now = Date.now();
|
|
765
768
|
const registeredSessions = listRegisteredSessions();
|
|
766
|
-
let liveSessions = [];
|
|
767
|
-
if (serviceUp) {
|
|
768
|
-
try {
|
|
769
|
-
const status = await callAPI('getStatus', {});
|
|
770
|
-
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
771
|
-
} catch {
|
|
772
|
-
// Ignore and fallback to local registry.
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
769
|
const regMap = new Map(
|
|
776
770
|
registeredSessions
|
|
777
771
|
.filter((item) => item && String(item?.status || '').trim() === 'active')
|
|
@@ -785,7 +779,7 @@ export async function handleStopCommand(args) {
|
|
|
785
779
|
.map((item) => item.session.profileId),
|
|
786
780
|
);
|
|
787
781
|
let orphanLiveHeadlessCount = 0;
|
|
788
|
-
for (const live of
|
|
782
|
+
for (const live of resolvedSessions.filter((item) => item.live)) {
|
|
789
783
|
const liveProfileId = String(live?.profileId || '').trim();
|
|
790
784
|
if (!liveProfileId) continue;
|
|
791
785
|
if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
|
|
@@ -863,14 +857,15 @@ export async function handleStopCommand(args) {
|
|
|
863
857
|
|
|
864
858
|
export async function handleStatusCommand(args) {
|
|
865
859
|
await ensureBrowserService();
|
|
866
|
-
const result = await callAPI('getStatus', {});
|
|
867
860
|
const profileId = args[1];
|
|
868
861
|
if (profileId && args[0] === 'status') {
|
|
869
|
-
const
|
|
862
|
+
const sessions = await getResolvedSessions();
|
|
863
|
+
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
870
864
|
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
871
865
|
return;
|
|
872
866
|
}
|
|
873
|
-
|
|
867
|
+
const sessions = await getResolvedSessions();
|
|
868
|
+
console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
|
|
874
869
|
}
|
|
875
870
|
|
|
876
871
|
export async function handleGotoCommand(args) {
|
|
@@ -974,14 +969,32 @@ export async function handleScrollCommand(args) {
|
|
|
974
969
|
|
|
975
970
|
const target = await callAPI('evaluate', {
|
|
976
971
|
profileId,
|
|
977
|
-
script: buildScrollTargetScript({ selector, highlight }),
|
|
972
|
+
script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
|
|
978
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
|
+
}
|
|
979
978
|
const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
|
980
979
|
const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
|
981
|
-
|
|
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 });
|
|
982
995
|
console.log(JSON.stringify({
|
|
983
996
|
...result,
|
|
984
|
-
scrollTarget
|
|
997
|
+
scrollTarget,
|
|
985
998
|
highlight,
|
|
986
999
|
}, null, 2));
|
|
987
1000
|
}
|
|
@@ -1183,6 +1196,11 @@ export async function handleListPagesCommand(args) {
|
|
|
1183
1196
|
await ensureBrowserService();
|
|
1184
1197
|
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1185
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
|
+
}
|
|
1186
1204
|
const result = await callAPI('page:list', { profileId });
|
|
1187
1205
|
console.log(JSON.stringify(result, null, 2));
|
|
1188
1206
|
}
|
|
@@ -1223,33 +1241,9 @@ export async function handleShutdownCommand() {
|
|
|
1223
1241
|
|
|
1224
1242
|
export async function handleSessionsCommand(args) {
|
|
1225
1243
|
const serviceUp = await checkBrowserService();
|
|
1244
|
+
const merged = await loadResolvedSessions(serviceUp);
|
|
1226
1245
|
const registeredSessions = listRegisteredSessions();
|
|
1227
|
-
|
|
1228
|
-
let liveSessions = [];
|
|
1229
|
-
if (serviceUp) {
|
|
1230
|
-
try {
|
|
1231
|
-
const status = await callAPI('getStatus', {});
|
|
1232
|
-
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
1233
|
-
} catch {
|
|
1234
|
-
// Service may have just become unavailable
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Merge live and registered sessions
|
|
1239
|
-
const liveProfileIds = new Set(liveSessions.map(s => s.profileId));
|
|
1240
|
-
const merged = [...liveSessions];
|
|
1241
|
-
|
|
1242
|
-
// Add registered sessions that are not in live sessions (need recovery)
|
|
1243
|
-
for (const reg of registeredSessions) {
|
|
1244
|
-
if (!liveProfileIds.has(reg.profileId) && reg.status === 'active') {
|
|
1245
|
-
merged.push({
|
|
1246
|
-
...reg,
|
|
1247
|
-
live: false,
|
|
1248
|
-
needsRecovery: true,
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1246
|
+
|
|
1253
1247
|
console.log(JSON.stringify({
|
|
1254
1248
|
ok: true,
|
|
1255
1249
|
serviceUp,
|
|
@@ -3,84 +3,26 @@
|
|
|
3
3
|
* Lifecycle commands: cleanup, force-stop, lock management, session recovery
|
|
4
4
|
*/
|
|
5
5
|
import { getDefaultProfile } from '../utils/config.mjs';
|
|
6
|
-
import { callAPI, ensureBrowserService, checkBrowserService } from '../utils/browser-service.mjs';
|
|
6
|
+
import { callAPI, ensureBrowserService, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
7
7
|
import { resolveProfileId } from '../utils/args.mjs';
|
|
8
8
|
import { acquireLock, getLockInfo, releaseLock, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
10
|
getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
|
|
11
11
|
listRegisteredSessions, updateSession
|
|
12
12
|
} from '../lifecycle/session-registry.mjs';
|
|
13
13
|
import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const headless = session?.headless === true;
|
|
19
|
-
const timeoutMs = headless
|
|
20
|
-
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
21
|
-
: 0;
|
|
22
|
-
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
23
|
-
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
24
|
-
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
25
|
-
return { headless, timeoutMs, idleMs, idle };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function buildMergedSessionRows(liveSessions, registeredSessions) {
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const regMap = new Map(registeredSessions.map((item) => [item.profileId, item]));
|
|
31
|
-
const rows = [];
|
|
32
|
-
const liveMap = new Map(liveSessions.map((item) => [item.profileId, item]));
|
|
33
|
-
|
|
34
|
-
for (const live of liveSessions) {
|
|
35
|
-
const reg = regMap.get(live.profileId);
|
|
36
|
-
const idle = computeIdleState(reg || {}, now);
|
|
37
|
-
rows.push({
|
|
38
|
-
profileId: live.profileId,
|
|
39
|
-
sessionId: live.session_id || live.profileId,
|
|
40
|
-
instanceId: reg?.instanceId || live.session_id || live.profileId,
|
|
41
|
-
alias: reg?.alias || null,
|
|
42
|
-
url: live.current_url,
|
|
43
|
-
mode: live.mode,
|
|
44
|
-
headless: idle.headless,
|
|
45
|
-
idleTimeoutMs: idle.timeoutMs,
|
|
46
|
-
idleMs: idle.idleMs,
|
|
47
|
-
idle: idle.idle,
|
|
48
|
-
live: true,
|
|
49
|
-
registered: !!reg,
|
|
50
|
-
registryStatus: reg?.status,
|
|
51
|
-
lastSeen: reg?.lastSeen || null,
|
|
52
|
-
lastActivityAt: reg?.lastActivityAt || null,
|
|
53
|
-
});
|
|
15
|
+
function rejectIndirectSessionTargeting(commandName, args) {
|
|
16
|
+
if (args.includes('--id')) {
|
|
17
|
+
throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --id is only supported by "camo stop")`);
|
|
54
18
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (liveMap.has(reg.profileId)) continue;
|
|
58
|
-
if (reg.status === 'closed') continue;
|
|
59
|
-
const idle = computeIdleState(reg, now);
|
|
60
|
-
rows.push({
|
|
61
|
-
profileId: reg.profileId,
|
|
62
|
-
sessionId: reg.sessionId || reg.profileId,
|
|
63
|
-
instanceId: reg.instanceId || reg.sessionId || reg.profileId,
|
|
64
|
-
alias: reg.alias || null,
|
|
65
|
-
url: reg.url || null,
|
|
66
|
-
mode: reg.mode || null,
|
|
67
|
-
headless: idle.headless,
|
|
68
|
-
idleTimeoutMs: idle.timeoutMs,
|
|
69
|
-
idleMs: idle.idleMs,
|
|
70
|
-
idle: idle.idle,
|
|
71
|
-
live: false,
|
|
72
|
-
orphaned: true,
|
|
73
|
-
needsRecovery: reg.status === 'active',
|
|
74
|
-
registered: true,
|
|
75
|
-
registryStatus: reg.status,
|
|
76
|
-
lastSeen: reg.lastSeen || null,
|
|
77
|
-
lastActivityAt: reg.lastActivityAt || null,
|
|
78
|
-
});
|
|
19
|
+
if (args.includes('--alias')) {
|
|
20
|
+
throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --alias is only supported by "camo stop")`);
|
|
79
21
|
}
|
|
80
|
-
return rows;
|
|
81
22
|
}
|
|
82
23
|
|
|
83
24
|
export async function handleCleanupCommand(args) {
|
|
25
|
+
rejectIndirectSessionTargeting('cleanup', args);
|
|
84
26
|
const sub = args[1];
|
|
85
27
|
|
|
86
28
|
if (sub === 'locks') {
|
|
@@ -158,6 +100,7 @@ export async function handleCleanupCommand(args) {
|
|
|
158
100
|
}
|
|
159
101
|
|
|
160
102
|
export async function handleForceStopCommand(args) {
|
|
103
|
+
rejectIndirectSessionTargeting('force-stop', args);
|
|
161
104
|
await ensureBrowserService();
|
|
162
105
|
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
163
106
|
if (!profileId) throw new Error('Usage: camo force-stop [profileId]');
|
|
@@ -217,24 +160,45 @@ export async function handleUnlockCommand(args) {
|
|
|
217
160
|
export async function handleSessionsCommand(args) {
|
|
218
161
|
const serviceUp = await checkBrowserService();
|
|
219
162
|
const registeredSessions = listRegisteredSessions();
|
|
220
|
-
|
|
221
|
-
let liveSessions = [];
|
|
163
|
+
let merged = [];
|
|
222
164
|
if (serviceUp) {
|
|
223
165
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
166
|
+
merged = await getResolvedSessions();
|
|
167
|
+
} catch {
|
|
168
|
+
merged = [];
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
merged = registeredSessions
|
|
172
|
+
.filter((item) => String(item?.status || '').trim() !== 'closed')
|
|
173
|
+
.map((item) => ({
|
|
174
|
+
profileId: item.profileId,
|
|
175
|
+
sessionId: item.sessionId || item.profileId,
|
|
176
|
+
instanceId: item.instanceId || item.sessionId || item.profileId,
|
|
177
|
+
alias: item.alias || null,
|
|
178
|
+
url: item.url || null,
|
|
179
|
+
mode: item.mode || null,
|
|
180
|
+
ownerPid: Number(item?.ownerPid || item?.pid || 0) || null,
|
|
181
|
+
headless: item.headless === true,
|
|
182
|
+
idleTimeoutMs: Number(item.idleTimeoutMs || 0) || 0,
|
|
183
|
+
idleMs: 0,
|
|
184
|
+
idle: false,
|
|
185
|
+
live: false,
|
|
186
|
+
registered: true,
|
|
187
|
+
orphaned: true,
|
|
188
|
+
needsRecovery: String(item.status || '').trim() === 'active',
|
|
189
|
+
registryStatus: item.status || null,
|
|
190
|
+
lastSeen: item.lastSeen || null,
|
|
191
|
+
lastActivityAt: item.lastActivityAt || null,
|
|
192
|
+
}));
|
|
227
193
|
}
|
|
228
|
-
|
|
229
|
-
const merged = buildMergedSessionRows(liveSessions, registeredSessions);
|
|
230
|
-
|
|
194
|
+
|
|
231
195
|
console.log(JSON.stringify({
|
|
232
196
|
ok: true,
|
|
233
197
|
serviceUp,
|
|
234
198
|
sessions: merged,
|
|
235
199
|
count: merged.length,
|
|
236
200
|
registered: registeredSessions.length,
|
|
237
|
-
live:
|
|
201
|
+
live: merged.filter((item) => item.live).length,
|
|
238
202
|
orphaned: merged.filter(s => s.orphaned).length,
|
|
239
203
|
}, null, 2));
|
|
240
204
|
}
|
|
@@ -272,24 +236,24 @@ export async function handleRecoverCommand(args) {
|
|
|
272
236
|
|
|
273
237
|
// Service is up - check if session is still there
|
|
274
238
|
try {
|
|
275
|
-
const
|
|
276
|
-
const existing =
|
|
277
|
-
|
|
239
|
+
const sessions = await getResolvedSessions();
|
|
240
|
+
const existing = sessions.find((item) => item.profileId === profileId && item.live);
|
|
241
|
+
|
|
278
242
|
if (existing) {
|
|
279
243
|
// Session is alive - update registry
|
|
280
244
|
updateSession(profileId, {
|
|
281
|
-
sessionId: existing.
|
|
282
|
-
url: existing.
|
|
245
|
+
sessionId: existing.sessionId || existing.profileId,
|
|
246
|
+
url: existing.url,
|
|
283
247
|
status: 'active',
|
|
284
248
|
recoveredAt: Date.now(),
|
|
285
249
|
});
|
|
286
|
-
acquireLock(profileId, { sessionId: existing.
|
|
250
|
+
acquireLock(profileId, { sessionId: existing.sessionId || existing.profileId });
|
|
287
251
|
console.log(JSON.stringify({
|
|
288
252
|
ok: true,
|
|
289
253
|
recovered: true,
|
|
290
254
|
profileId,
|
|
291
|
-
sessionId: existing.
|
|
292
|
-
url: existing.
|
|
255
|
+
sessionId: existing.sessionId || existing.profileId,
|
|
256
|
+
url: existing.url,
|
|
293
257
|
message: 'Session reconnected successfully',
|
|
294
258
|
}, null, 2));
|
|
295
259
|
} else {
|
|
@@ -626,7 +626,24 @@ export async function executeOperation({ profileId, operation, context = {} }) {
|
|
|
626
626
|
selector: anchorSelector,
|
|
627
627
|
filterMode,
|
|
628
628
|
});
|
|
629
|
-
|
|
629
|
+
if (!anchor?.ok || !anchor?.center) {
|
|
630
|
+
return asErrorPayload('OPERATION_FAILED', 'visible scroll container not found');
|
|
631
|
+
}
|
|
632
|
+
await callAPI('mouse:click', {
|
|
633
|
+
profileId: resolvedProfile,
|
|
634
|
+
x: anchor.center.x,
|
|
635
|
+
y: anchor.center.y,
|
|
636
|
+
button: 'left',
|
|
637
|
+
clicks: 1,
|
|
638
|
+
delay: 30,
|
|
639
|
+
});
|
|
640
|
+
const result = await callAPI('mouse:wheel', {
|
|
641
|
+
profileId: resolvedProfile,
|
|
642
|
+
deltaX,
|
|
643
|
+
deltaY,
|
|
644
|
+
anchorX: anchor.center.x,
|
|
645
|
+
anchorY: anchor.center.y,
|
|
646
|
+
});
|
|
630
647
|
return {
|
|
631
648
|
ok: true,
|
|
632
649
|
code: 'OPERATION_DONE',
|
|
@@ -638,6 +655,7 @@ export async function executeOperation({ profileId, operation, context = {} }) {
|
|
|
638
655
|
deltaY,
|
|
639
656
|
filterMode,
|
|
640
657
|
anchorSource: String(anchor?.source || 'document'),
|
|
658
|
+
anchorCenter: anchor?.center || null,
|
|
641
659
|
modalLocked: anchor?.modalLocked === true,
|
|
642
660
|
result,
|
|
643
661
|
},
|
|
@@ -159,11 +159,13 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
|
|
|
159
159
|
})()`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
export function buildScrollTargetScript({ selector, highlight }) {
|
|
162
|
+
export function buildScrollTargetScript({ selector, highlight, requireVisibleContainer = false }) {
|
|
163
163
|
const selectorLiteral = JSON.stringify(String(selector || '').trim() || null);
|
|
164
164
|
const highlightLiteral = asBoolLiteral(highlight);
|
|
165
|
+
const requireVisibleContainerLiteral = asBoolLiteral(requireVisibleContainer);
|
|
165
166
|
return `(() => {
|
|
166
167
|
const selector = ${selectorLiteral};
|
|
168
|
+
const requireVisibleContainer = ${requireVisibleContainerLiteral};
|
|
167
169
|
const isVisible = (node) => {
|
|
168
170
|
if (!(node instanceof Element)) return false;
|
|
169
171
|
const rect = node.getBoundingClientRect?.();
|
|
@@ -183,11 +185,13 @@ export function buildScrollTargetScript({ selector, highlight }) {
|
|
|
183
185
|
const style = window.getComputedStyle(node);
|
|
184
186
|
const overflowY = String(style.overflowY || '');
|
|
185
187
|
const overflowX = String(style.overflowX || '');
|
|
188
|
+
const scrollableSelectors = ['.comments-container', '.comment-list', '.comments-el', '.note-scroller'];
|
|
189
|
+
const selectorScrollable = scrollableSelectors.some((sel) => typeof node.matches === 'function' && node.matches(sel));
|
|
186
190
|
const yScrollable = (overflowY.includes('auto') || overflowY.includes('scroll') || overflowY.includes('overlay'))
|
|
187
191
|
&& (node.scrollHeight - node.clientHeight > 2);
|
|
188
192
|
const xScrollable = (overflowX.includes('auto') || overflowX.includes('scroll') || overflowX.includes('overlay'))
|
|
189
193
|
&& (node.scrollWidth - node.clientWidth > 2);
|
|
190
|
-
return yScrollable || xScrollable;
|
|
194
|
+
return yScrollable || xScrollable || selectorScrollable;
|
|
191
195
|
};
|
|
192
196
|
const findScrollableAncestor = (node) => {
|
|
193
197
|
let cursor = node instanceof Element ? node : null;
|
|
@@ -203,9 +207,12 @@ export function buildScrollTargetScript({ selector, highlight }) {
|
|
|
203
207
|
if (selector) {
|
|
204
208
|
const list = Array.from(document.querySelectorAll(selector));
|
|
205
209
|
target = list.find((node) => isVisible(node) && isScrollable(node))
|
|
206
|
-
|| list.find((node) => isVisible(node))
|
|
210
|
+
|| list.map((node) => findScrollableAncestor(node)).find((node) => isVisible(node))
|
|
207
211
|
|| null;
|
|
208
212
|
if (target) source = 'selector';
|
|
213
|
+
if (!target && requireVisibleContainer) {
|
|
214
|
+
return { ok: false, error: 'visible_scroll_container_not_found', selector };
|
|
215
|
+
}
|
|
209
216
|
}
|
|
210
217
|
if (!target) {
|
|
211
218
|
const active = document.activeElement instanceof Element ? document.activeElement : null;
|
|
@@ -250,9 +257,20 @@ export function buildScrollTargetScript({ selector, highlight }) {
|
|
|
250
257
|
source,
|
|
251
258
|
highlight: ${highlightLiteral},
|
|
252
259
|
center: { x: centerX, y: centerY },
|
|
260
|
+
rect: {
|
|
261
|
+
left: Number(rect.left || 0),
|
|
262
|
+
top: Number(rect.top || 0),
|
|
263
|
+
width: Number(rect.width || 0),
|
|
264
|
+
height: Number(rect.height || 0)
|
|
265
|
+
},
|
|
253
266
|
target: {
|
|
254
267
|
tag: String(target.tagName || '').toLowerCase(),
|
|
255
|
-
id: target.id || null
|
|
268
|
+
id: target.id || null,
|
|
269
|
+
className: typeof target.className === 'string' ? target.className : null,
|
|
270
|
+
scrollHeight: Number(target.scrollHeight || 0),
|
|
271
|
+
clientHeight: Number(target.clientHeight || 0),
|
|
272
|
+
scrollWidth: Number(target.scrollWidth || 0),
|
|
273
|
+
clientWidth: Number(target.clientWidth || 0)
|
|
256
274
|
}
|
|
257
275
|
};
|
|
258
276
|
})()`;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { buildSelectorCheck } from './utils.mjs';
|
|
2
|
+
|
|
3
|
+
function normalizeQuery(raw) {
|
|
4
|
+
const text = String(raw || '').trim();
|
|
5
|
+
if (!text) return { query: '', queryLower: '' };
|
|
6
|
+
return { query: text, queryLower: text.toLowerCase() };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeDirection(raw) {
|
|
10
|
+
const text = String(raw || 'down').trim().toLowerCase();
|
|
11
|
+
if (text === 'up' || text === 'down' || text === 'both') return text;
|
|
12
|
+
return 'down';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeLimit(raw) {
|
|
16
|
+
const num = Number(raw);
|
|
17
|
+
if (!Number.isFinite(num) || num <= 0) return 1;
|
|
18
|
+
return Math.max(1, Math.floor(num));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeRect(node) {
|
|
22
|
+
const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
|
|
23
|
+
if (!rect) return null;
|
|
24
|
+
const left = Number(rect.left ?? rect.x ?? 0);
|
|
25
|
+
const top = Number(rect.top ?? rect.y ?? 0);
|
|
26
|
+
const width = Number(rect.width ?? 0);
|
|
27
|
+
const height = Number(rect.height ?? 0);
|
|
28
|
+
if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
29
|
+
if (width <= 0 || height <= 0) return null;
|
|
30
|
+
return { left, top, width, height, right: left + width, bottom: top + height };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function computeCenter(rect) {
|
|
34
|
+
if (!rect) return null;
|
|
35
|
+
return {
|
|
36
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
37
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildSearchText(node) {
|
|
42
|
+
if (!node || typeof node !== 'object') return '';
|
|
43
|
+
const parts = [];
|
|
44
|
+
const snippet = typeof node.textSnippet === 'string' ? node.textSnippet : '';
|
|
45
|
+
if (snippet) parts.push(snippet);
|
|
46
|
+
const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : null;
|
|
47
|
+
if (attrs) {
|
|
48
|
+
const candidates = [
|
|
49
|
+
attrs['aria-label'],
|
|
50
|
+
attrs['aria-label'.toLowerCase()],
|
|
51
|
+
attrs.title,
|
|
52
|
+
attrs.alt,
|
|
53
|
+
attrs.placeholder,
|
|
54
|
+
];
|
|
55
|
+
for (const item of candidates) {
|
|
56
|
+
const text = typeof item === 'string' ? item.trim() : '';
|
|
57
|
+
if (text) parts.push(text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPathWithin(path, parentPath) {
|
|
64
|
+
const child = String(path || '').trim();
|
|
65
|
+
const parent = String(parentPath || '').trim();
|
|
66
|
+
if (!child || !parent) return false;
|
|
67
|
+
return child === parent || child.startsWith(`${parent}/`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collectMatches(node, options, path = 'root', out = []) {
|
|
71
|
+
if (!node) return out;
|
|
72
|
+
const { queryLower, visibleOnly } = options;
|
|
73
|
+
const visible = node.visible === true;
|
|
74
|
+
if (visibleOnly && !visible) {
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
{
|
|
78
|
+
const searchText = buildSearchText(node);
|
|
79
|
+
if (searchText && searchText.toLowerCase().includes(queryLower)) {
|
|
80
|
+
out.push({ node, path, searchText });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(node.children)) {
|
|
84
|
+
for (let i = 0; i < node.children.length; i += 1) {
|
|
85
|
+
collectMatches(node.children[i], options, `${path}/${i}`, out);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sortMatches(matches, direction) {
|
|
92
|
+
const sorted = [...matches].sort((a, b) => {
|
|
93
|
+
const ra = normalizeRect(a.targetNode);
|
|
94
|
+
const rb = normalizeRect(b.targetNode);
|
|
95
|
+
const ta = ra ? ra.top : Number.POSITIVE_INFINITY;
|
|
96
|
+
const tb = rb ? rb.top : Number.POSITIVE_INFINITY;
|
|
97
|
+
if (ta !== tb) return ta - tb;
|
|
98
|
+
const la = ra ? ra.left : Number.POSITIVE_INFINITY;
|
|
99
|
+
const lb = rb ? rb.left : Number.POSITIVE_INFINITY;
|
|
100
|
+
return la - lb;
|
|
101
|
+
});
|
|
102
|
+
if (direction === 'up') return sorted.reverse();
|
|
103
|
+
return sorted;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function applyStartAfter(matches, startAfterPath) {
|
|
107
|
+
if (!startAfterPath) return matches;
|
|
108
|
+
const idx = matches.findIndex((item) => item.targetPath === startAfterPath || item.matchPath === startAfterPath);
|
|
109
|
+
if (idx < 0) return matches;
|
|
110
|
+
return matches.slice(idx + 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function searchSnapshot(snapshot, rawOptions = {}) {
|
|
114
|
+
const { query, queryLower } = normalizeQuery(rawOptions.query || rawOptions.keyword || rawOptions.text);
|
|
115
|
+
if (!query) {
|
|
116
|
+
return { ok: false, code: 'QUERY_REQUIRED', message: 'search requires query keyword', data: { query } };
|
|
117
|
+
}
|
|
118
|
+
const direction = normalizeDirection(rawOptions.direction || 'down');
|
|
119
|
+
const limit = normalizeLimit(rawOptions.limit ?? rawOptions.maxResults ?? 1);
|
|
120
|
+
const visibleOnly = rawOptions.visibleOnly !== false;
|
|
121
|
+
const containerSelector = String(rawOptions.containerSelector || rawOptions.selector || '').trim() || null;
|
|
122
|
+
const startAfterPath = String(rawOptions.startAfterPath || rawOptions.afterPath || '').trim() || null;
|
|
123
|
+
|
|
124
|
+
const containerNodes = containerSelector
|
|
125
|
+
? buildSelectorCheck(snapshot, { css: containerSelector, visible: visibleOnly })
|
|
126
|
+
: [];
|
|
127
|
+
const containerPaths = containerNodes.map((node) => node.path).filter(Boolean);
|
|
128
|
+
|
|
129
|
+
const matches = collectMatches(snapshot, { queryLower, visibleOnly }, 'root', []);
|
|
130
|
+
const enriched = matches.map((match) => {
|
|
131
|
+
let containerNode = null;
|
|
132
|
+
let containerPath = null;
|
|
133
|
+
if (containerPaths.length > 0) {
|
|
134
|
+
for (const path of containerPaths) {
|
|
135
|
+
if (isPathWithin(match.path, path)) {
|
|
136
|
+
containerPath = path;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (containerPath) {
|
|
141
|
+
containerNode = containerNodes.find((node) => node.path === containerPath) || null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const targetNode = containerNode || match.node;
|
|
145
|
+
const rect = normalizeRect(targetNode);
|
|
146
|
+
const center = computeCenter(rect);
|
|
147
|
+
return {
|
|
148
|
+
matchPath: match.path,
|
|
149
|
+
targetPath: containerPath || match.path,
|
|
150
|
+
targetNode,
|
|
151
|
+
matchNode: match.node,
|
|
152
|
+
containerPath,
|
|
153
|
+
rect,
|
|
154
|
+
center,
|
|
155
|
+
searchText: match.searchText,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const filtered = containerSelector
|
|
160
|
+
? enriched.filter((item) => item.containerPath)
|
|
161
|
+
: enriched;
|
|
162
|
+
const ordered = sortMatches(filtered, direction);
|
|
163
|
+
const sliced = applyStartAfter(ordered, startAfterPath).slice(0, limit);
|
|
164
|
+
const results = sliced.map((item) => ({
|
|
165
|
+
matchPath: item.matchPath,
|
|
166
|
+
targetPath: item.targetPath,
|
|
167
|
+
containerPath: item.containerPath,
|
|
168
|
+
rect: item.rect,
|
|
169
|
+
center: item.center,
|
|
170
|
+
text: item.searchText,
|
|
171
|
+
}));
|
|
172
|
+
const nextCursor = results.length > 0 ? results[results.length - 1].targetPath : startAfterPath;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
code: 'SEARCH_OK',
|
|
177
|
+
message: 'search done',
|
|
178
|
+
data: {
|
|
179
|
+
query,
|
|
180
|
+
direction,
|
|
181
|
+
limit,
|
|
182
|
+
visibleOnly,
|
|
183
|
+
containerSelector,
|
|
184
|
+
totalMatches: filtered.length,
|
|
185
|
+
returned: results.length,
|
|
186
|
+
nextCursor,
|
|
187
|
+
results,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -25,6 +25,11 @@ function normalizeAlias(value) {
|
|
|
25
25
|
return text.slice(0, 64);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function isAliasProtectedStatus(status) {
|
|
29
|
+
const normalized = String(status || '').trim().toLowerCase();
|
|
30
|
+
return normalized === 'active' || normalized === 'reconnecting';
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
function normalizeTimeoutMs(value) {
|
|
29
34
|
const ms = Number(value);
|
|
30
35
|
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
@@ -159,10 +164,21 @@ export function resolveSessionTarget(target) {
|
|
|
159
164
|
const sessions = listRegisteredSessions();
|
|
160
165
|
const byProfile = sessions.find((item) => String(item?.profileId || '').trim() === value);
|
|
161
166
|
if (byProfile) return { profileId: byProfile.profileId, reason: 'profile', session: byProfile };
|
|
162
|
-
const byInstanceId = sessions.
|
|
163
|
-
if (byInstanceId
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
const byInstanceId = sessions.filter((item) => String(item?.instanceId || '').trim() === value);
|
|
168
|
+
if (byInstanceId.length > 1) {
|
|
169
|
+
throw new Error(`Ambiguous instance id target: ${value}`);
|
|
170
|
+
}
|
|
171
|
+
if (byInstanceId.length === 1) {
|
|
172
|
+
return { profileId: byInstanceId[0].profileId, reason: 'instanceId', session: byInstanceId[0] };
|
|
173
|
+
}
|
|
174
|
+
const normalizedAlias = normalizeAlias(value);
|
|
175
|
+
const byAlias = sessions.filter((item) => normalizeAlias(item?.alias) === normalizedAlias && isAliasProtectedStatus(item?.status));
|
|
176
|
+
if (byAlias.length > 1) {
|
|
177
|
+
throw new Error(`Ambiguous alias target: ${value}`);
|
|
178
|
+
}
|
|
179
|
+
if (byAlias.length === 1) {
|
|
180
|
+
return { profileId: byAlias[0].profileId, reason: 'alias', session: byAlias[0] };
|
|
181
|
+
}
|
|
166
182
|
return null;
|
|
167
183
|
}
|
|
168
184
|
|
|
@@ -173,7 +189,7 @@ export function isSessionAliasTaken(alias, exceptProfileId = '') {
|
|
|
173
189
|
return listRegisteredSessions().some((item) => {
|
|
174
190
|
if (!item) return false;
|
|
175
191
|
if (except && String(item.profileId || '').trim() === except) return false;
|
|
176
|
-
if (
|
|
192
|
+
if (!isAliasProtectedStatus(item.status)) return false;
|
|
177
193
|
return normalizeAlias(item.alias) === target;
|
|
178
194
|
});
|
|
179
195
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { listRegisteredSessions } from './session-registry.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function computeIdleState(session, now = Date.now()) {
|
|
7
|
+
const headless = session?.headless === true;
|
|
8
|
+
const timeoutMs = headless
|
|
9
|
+
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
10
|
+
: 0;
|
|
11
|
+
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
12
|
+
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
13
|
+
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
14
|
+
return { headless, timeoutMs, idleMs, idle };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeLiveSession(session) {
|
|
18
|
+
if (!session) return null;
|
|
19
|
+
const profileId = String(session.profileId || session.session_id || '').trim();
|
|
20
|
+
if (!profileId) return null;
|
|
21
|
+
return {
|
|
22
|
+
profileId,
|
|
23
|
+
sessionId: session.session_id || session.sessionId || profileId,
|
|
24
|
+
url: session.current_url || session.url || null,
|
|
25
|
+
mode: session.mode || null,
|
|
26
|
+
ownerPid: Number(session.owner_pid || session.ownerPid || 0) || null,
|
|
27
|
+
live: true,
|
|
28
|
+
raw: session,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildResolvedSessionView(liveSessions = [], registeredSessions = listRegisteredSessions()) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const regMap = new Map(registeredSessions.map((item) => [String(item?.profileId || '').trim(), item]).filter(([key]) => key));
|
|
35
|
+
const liveMap = new Map(liveSessions.map(normalizeLiveSession).filter(Boolean).map((item) => [item.profileId, item]));
|
|
36
|
+
const profileIds = new Set([...liveMap.keys(), ...regMap.keys()]);
|
|
37
|
+
const rows = [];
|
|
38
|
+
|
|
39
|
+
for (const profileId of profileIds) {
|
|
40
|
+
const live = liveMap.get(profileId) || null;
|
|
41
|
+
const reg = regMap.get(profileId) || null;
|
|
42
|
+
if (!live && reg && String(reg.status || '').trim() === 'closed') continue;
|
|
43
|
+
const idle = computeIdleState(reg || live || {}, now);
|
|
44
|
+
const registryStatus = reg?.status || null;
|
|
45
|
+
const row = {
|
|
46
|
+
profileId,
|
|
47
|
+
sessionId: live?.sessionId || reg?.sessionId || reg?.instanceId || profileId,
|
|
48
|
+
instanceId: reg?.instanceId || live?.sessionId || profileId,
|
|
49
|
+
alias: reg?.alias || null,
|
|
50
|
+
url: live?.url || reg?.url || null,
|
|
51
|
+
mode: live?.mode || reg?.mode || null,
|
|
52
|
+
ownerPid: live?.ownerPid || Number(reg?.ownerPid || reg?.pid || 0) || null,
|
|
53
|
+
headless: idle.headless,
|
|
54
|
+
idleTimeoutMs: idle.timeoutMs,
|
|
55
|
+
idleMs: idle.idleMs,
|
|
56
|
+
idle: idle.idle,
|
|
57
|
+
live: Boolean(live),
|
|
58
|
+
registered: Boolean(reg),
|
|
59
|
+
orphaned: Boolean(reg) && !live,
|
|
60
|
+
needsRecovery: Boolean(reg) && !live && registryStatus === 'active',
|
|
61
|
+
registryStatus,
|
|
62
|
+
lastSeen: reg?.lastSeen || null,
|
|
63
|
+
lastActivityAt: reg?.lastActivityAt || null,
|
|
64
|
+
};
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return rows.sort((a, b) => String(a.profileId).localeCompare(String(b.profileId)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveSessionViewByProfile(profileId, liveSessions = [], registeredSessions = listRegisteredSessions()) {
|
|
72
|
+
const id = String(profileId || '').trim();
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
return buildResolvedSessionView(liveSessions, registeredSessions).find((item) => item.profileId === id) || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -582,8 +582,14 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
|
|
|
582
582
|
const session = manager.getSession(profileId);
|
|
583
583
|
if (!session)
|
|
584
584
|
throw new Error(`session for profile ${profileId} not started`);
|
|
585
|
-
const { deltaY, deltaX } = args;
|
|
586
|
-
await session.mouseWheel({
|
|
585
|
+
const { deltaY, deltaX, anchorX, anchorY } = args;
|
|
586
|
+
await session.mouseWheel({
|
|
587
|
+
deltaY: Number(deltaY) || 0,
|
|
588
|
+
deltaX: Number(deltaX) || 0,
|
|
589
|
+
...(Number.isFinite(Number(anchorX)) && Number.isFinite(Number(anchorY))
|
|
590
|
+
? { anchorX: Number(anchorX), anchorY: Number(anchorY) }
|
|
591
|
+
: {}),
|
|
592
|
+
});
|
|
587
593
|
return { ok: true, body: { ok: true } };
|
|
588
594
|
}
|
|
589
595
|
case 'keyboard:type': {
|
|
@@ -196,10 +196,38 @@ test('ensureInputReady brings page to front even when document reports focus', a
|
|
|
196
196
|
isClosed: () => false,
|
|
197
197
|
};
|
|
198
198
|
const session = new BrowserSession({ profileId: `test-input-ready-${Date.now()}` });
|
|
199
|
-
await session.ensureInputReady(page);
|
|
199
|
+
await session.inputPipeline.ensureInputReady(page);
|
|
200
200
|
assert.equal(page.bringToFrontCount, 1);
|
|
201
201
|
assert.equal(page.waitCount, 1);
|
|
202
202
|
});
|
|
203
|
+
test('ensureInputReady skips bringToFront when CAMO_BRING_TO_FRONT_MODE=never', async () => {
|
|
204
|
+
const restoreSkip = setEnv('CAMO_BRING_TO_FRONT_MODE', 'never');
|
|
205
|
+
try {
|
|
206
|
+
const page = {
|
|
207
|
+
bringToFrontCount: 0,
|
|
208
|
+
waitCount: 0,
|
|
209
|
+
bringToFront: async function bringToFront() {
|
|
210
|
+
this.bringToFrontCount += 1;
|
|
211
|
+
},
|
|
212
|
+
waitForTimeout: async function waitForTimeout() {
|
|
213
|
+
this.waitCount += 1;
|
|
214
|
+
},
|
|
215
|
+
evaluate: async () => ({
|
|
216
|
+
hasFocus: false,
|
|
217
|
+
hidden: false,
|
|
218
|
+
visibilityState: 'visible',
|
|
219
|
+
}),
|
|
220
|
+
isClosed: () => false,
|
|
221
|
+
};
|
|
222
|
+
const session = new BrowserSession({ profileId: `test-input-ready-skip-${Date.now()}` });
|
|
223
|
+
await session.inputPipeline.ensureInputReady(page);
|
|
224
|
+
assert.equal(page.bringToFrontCount, 0);
|
|
225
|
+
assert.equal(page.waitCount, 1);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
restoreSkip();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
203
231
|
test('mouseWheel retries with refreshed active page after timeout', async () => {
|
|
204
232
|
const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
|
|
205
233
|
const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '2');
|
|
@@ -210,17 +238,21 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
|
|
|
210
238
|
const calls = [];
|
|
211
239
|
const page1 = {
|
|
212
240
|
isClosed: () => false,
|
|
241
|
+
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
213
242
|
bringToFront: async () => { },
|
|
214
243
|
waitForTimeout: async () => { },
|
|
215
244
|
mouse: {
|
|
245
|
+
move: async () => { },
|
|
216
246
|
wheel: async () => new Promise(() => { }),
|
|
217
247
|
},
|
|
218
248
|
};
|
|
219
249
|
const page2 = {
|
|
220
250
|
isClosed: () => false,
|
|
251
|
+
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
221
252
|
bringToFront: async () => { },
|
|
222
253
|
waitForTimeout: async () => { },
|
|
223
254
|
mouse: {
|
|
255
|
+
move: async () => { },
|
|
224
256
|
wheel: async () => {
|
|
225
257
|
calls.push('wheel_ok');
|
|
226
258
|
},
|
|
@@ -255,9 +287,11 @@ test('mouseWheel falls back to keyboard paging when wheel keeps timing out', asy
|
|
|
255
287
|
const calls = [];
|
|
256
288
|
const page = {
|
|
257
289
|
isClosed: () => false,
|
|
290
|
+
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
258
291
|
bringToFront: async () => { },
|
|
259
292
|
waitForTimeout: async () => { },
|
|
260
293
|
mouse: {
|
|
294
|
+
move: async () => { },
|
|
261
295
|
wheel: async () => new Promise(() => { }),
|
|
262
296
|
},
|
|
263
297
|
keyboard: {
|
|
@@ -319,4 +353,4 @@ test('mouseWheel uses keyboard mode directly when CAMO_SCROLL_INPUT_MODE=keyboar
|
|
|
319
353
|
restoreMode();
|
|
320
354
|
}
|
|
321
355
|
});
|
|
322
|
-
//# sourceMappingURL=BrowserSession.input.test.js.map
|
|
356
|
+
//# sourceMappingURL=BrowserSession.input.test.js.map
|
|
@@ -66,9 +66,11 @@ export class BrowserSessionInputOps {
|
|
|
66
66
|
const page = await this.ensurePrimaryPage();
|
|
67
67
|
await this.withInputActionLock(async () => {
|
|
68
68
|
await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
|
|
69
|
-
const { deltaX = 0, deltaY } = opts;
|
|
69
|
+
const { deltaX = 0, deltaY, anchorX, anchorY } = opts;
|
|
70
70
|
const normalizedDeltaX = Number(deltaX) || 0;
|
|
71
71
|
const normalizedDeltaY = Number(deltaY) || 0;
|
|
72
|
+
const normalizedAnchorX = Number(anchorX);
|
|
73
|
+
const normalizedAnchorY = Number(anchorY);
|
|
72
74
|
if (normalizedDeltaY === 0 && normalizedDeltaX === 0)
|
|
73
75
|
return;
|
|
74
76
|
const keyboardKey = normalizedDeltaY > 0 ? 'PageDown' : 'PageUp';
|
|
@@ -88,8 +90,12 @@ export class BrowserSessionInputOps {
|
|
|
88
90
|
try {
|
|
89
91
|
await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
|
|
90
92
|
const viewport = activePage.viewportSize();
|
|
91
|
-
const moveX =
|
|
92
|
-
|
|
93
|
+
const moveX = Number.isFinite(normalizedAnchorX)
|
|
94
|
+
? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
|
|
95
|
+
: Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
|
|
96
|
+
const moveY = Number.isFinite(normalizedAnchorY)
|
|
97
|
+
? Math.max(1, Math.min(Math.max(1, Number(viewport?.height || 720) - 1), Math.round(normalizedAnchorY)))
|
|
98
|
+
: Math.max(1, Math.floor(((viewport?.height || 720) * 0.5)));
|
|
93
99
|
await activePage.mouse.move(moveX, moveY, { steps: 1 }).catch(() => { });
|
|
94
100
|
await activePage.mouse.wheel(normalizedDeltaX, normalizedDeltaY);
|
|
95
101
|
});
|
|
@@ -124,4 +130,4 @@ export class BrowserSessionInputOps {
|
|
|
124
130
|
});
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
|
-
//# sourceMappingURL=input-ops.js.map
|
|
133
|
+
//# sourceMappingURL=input-ops.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs } from './utils.js';
|
|
1
|
+
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
|
|
2
2
|
import { ensurePageRuntime } from '../pageRuntime.js';
|
|
3
3
|
export class BrowserInputPipeline {
|
|
4
4
|
ensurePrimaryPage;
|
|
@@ -11,6 +11,13 @@ export class BrowserInputPipeline {
|
|
|
11
11
|
async ensureInputReady(page) {
|
|
12
12
|
if (this.isHeadless())
|
|
13
13
|
return;
|
|
14
|
+
if (shouldSkipBringToFront()) {
|
|
15
|
+
const settleMs = resolveInputReadySettleMs();
|
|
16
|
+
if (settleMs > 0) {
|
|
17
|
+
await page.waitForTimeout(settleMs).catch(() => { });
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
14
21
|
const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
|
|
15
22
|
let bringToFrontTimer = null;
|
|
16
23
|
try {
|
|
@@ -67,6 +74,7 @@ export class BrowserInputPipeline {
|
|
|
67
74
|
}
|
|
68
75
|
async recoverInputPipeline(page) {
|
|
69
76
|
const activePage = await this.resolveInputPage(page).catch(() => page);
|
|
77
|
+
if (!shouldSkipBringToFront()) {
|
|
70
78
|
const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
|
|
71
79
|
let bringToFrontTimer = null;
|
|
72
80
|
try {
|
|
@@ -86,6 +94,7 @@ export class BrowserInputPipeline {
|
|
|
86
94
|
if (bringToFrontTimer)
|
|
87
95
|
clearTimeout(bringToFrontTimer);
|
|
88
96
|
}
|
|
97
|
+
}
|
|
89
98
|
const delayMs = resolveInputRecoveryDelayMs();
|
|
90
99
|
if (delayMs > 0) {
|
|
91
100
|
try {
|
|
@@ -130,4 +139,4 @@ export class BrowserInputPipeline {
|
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
|
-
//# sourceMappingURL=input-pipeline.js.map
|
|
142
|
+
//# sourceMappingURL=input-pipeline.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { ensurePageRuntime } from '../pageRuntime.js';
|
|
3
|
-
import { resolveNavigationWaitUntil, normalizeUrl } from './utils.js';
|
|
3
|
+
import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
|
|
4
4
|
export class BrowserSessionPageManagement {
|
|
5
5
|
deps;
|
|
6
6
|
constructor(deps) {
|
|
@@ -74,7 +74,9 @@ export class BrowserSessionPageManagement {
|
|
|
74
74
|
const opener = this.deps.getActivePage() || ctx.pages()[0];
|
|
75
75
|
if (!opener)
|
|
76
76
|
throw new Error('no_opener_page');
|
|
77
|
-
|
|
77
|
+
if (!shouldSkipBringToFront()) {
|
|
78
|
+
await opener.bringToFront().catch(() => null);
|
|
79
|
+
}
|
|
78
80
|
const before = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
79
81
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
80
82
|
const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
|
|
@@ -136,11 +138,13 @@ export class BrowserSessionPageManagement {
|
|
|
136
138
|
catch {
|
|
137
139
|
/* ignore */
|
|
138
140
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
if (!shouldSkipBringToFront()) {
|
|
142
|
+
try {
|
|
143
|
+
await page.bringToFront();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
144
148
|
}
|
|
145
149
|
if (url) {
|
|
146
150
|
await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
|
|
@@ -165,11 +169,13 @@ export class BrowserSessionPageManagement {
|
|
|
165
169
|
catch {
|
|
166
170
|
/* ignore */
|
|
167
171
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
if (!shouldSkipBringToFront()) {
|
|
173
|
+
try {
|
|
174
|
+
await page.bringToFront();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
173
179
|
}
|
|
174
180
|
await ensurePageRuntime(page, true).catch(() => { });
|
|
175
181
|
this.deps.recordLastKnownUrl(page.url());
|
|
@@ -194,11 +200,13 @@ export class BrowserSessionPageManagement {
|
|
|
194
200
|
if (nextIndex >= 0) {
|
|
195
201
|
const nextPage = remaining[nextIndex];
|
|
196
202
|
this.deps.setActivePage(nextPage);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
if (!shouldSkipBringToFront()) {
|
|
204
|
+
try {
|
|
205
|
+
await nextPage.bringToFront();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* ignore */
|
|
209
|
+
}
|
|
202
210
|
}
|
|
203
211
|
await ensurePageRuntime(nextPage, true).catch(() => { });
|
|
204
212
|
this.deps.recordLastKnownUrl(nextPage.url());
|
|
@@ -209,4 +217,4 @@ export class BrowserSessionPageManagement {
|
|
|
209
217
|
return { closedIndex, activeIndex: nextIndex, total: remaining.length };
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
|
-
//# sourceMappingURL=page-management.js.map
|
|
220
|
+
//# sourceMappingURL=page-management.js.map
|
|
@@ -28,6 +28,20 @@ export function resolveInputReadySettleMs() {
|
|
|
28
28
|
const raw = Number(process.env.CAMO_INPUT_READY_SETTLE_MS ?? 80);
|
|
29
29
|
return Math.max(0, Number.isFinite(raw) ? Math.floor(raw) : 80);
|
|
30
30
|
}
|
|
31
|
+
export function resolveBringToFrontMode() {
|
|
32
|
+
const mode = String(process.env.CAMO_BRING_TO_FRONT_MODE ?? '').trim().toLowerCase();
|
|
33
|
+
if (mode === 'never' || mode === 'off' || mode === 'disabled')
|
|
34
|
+
return 'never';
|
|
35
|
+
if (mode === 'always' || mode === 'on' || mode === 'auto')
|
|
36
|
+
return 'auto';
|
|
37
|
+
const legacy = String(process.env.CAMO_SKIP_BRING_TO_FRONT ?? '').trim().toLowerCase();
|
|
38
|
+
if (legacy === '1' || legacy === 'true' || legacy === 'yes' || legacy === 'on')
|
|
39
|
+
return 'never';
|
|
40
|
+
return 'auto';
|
|
41
|
+
}
|
|
42
|
+
export function shouldSkipBringToFront() {
|
|
43
|
+
return resolveBringToFrontMode() === 'never';
|
|
44
|
+
}
|
|
31
45
|
export function isTimeoutLikeError(error) {
|
|
32
46
|
const message = String(error?.message || error || '').toLowerCase();
|
|
33
47
|
return message.includes('timed out') || message.includes('timeout');
|
|
@@ -44,6 +58,12 @@ export function normalizeUrl(raw) {
|
|
|
44
58
|
export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeoutMs, settleMs) {
|
|
45
59
|
if (headless)
|
|
46
60
|
return;
|
|
61
|
+
if (shouldSkipBringToFront()) {
|
|
62
|
+
if (settleMs > 0) {
|
|
63
|
+
await page.waitForTimeout(settleMs).catch(() => { });
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
47
67
|
let bringToFrontTimer = null;
|
|
48
68
|
try {
|
|
49
69
|
await Promise.race([
|
|
@@ -66,4 +86,4 @@ export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeout
|
|
|
66
86
|
await page.waitForTimeout(settleMs).catch(() => { });
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
|
-
//# sourceMappingURL=utils.js.map
|
|
89
|
+
//# sourceMappingURL=utils.js.map
|
|
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
9
9
|
import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
|
|
10
|
+
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
@@ -151,16 +152,21 @@ export async function callAPI(action, payload = {}, options = {}) {
|
|
|
151
152
|
|
|
152
153
|
export async function getSessionByProfile(profileId) {
|
|
153
154
|
const status = await callAPI('getStatus', {});
|
|
154
|
-
const activeSession = status?.sessions?.find((s) => s.profileId === profileId) || null;
|
|
155
|
-
if (activeSession) {
|
|
156
|
-
return activeSession;
|
|
157
|
-
}
|
|
158
155
|
if (!profileId) {
|
|
159
156
|
return null;
|
|
160
157
|
}
|
|
158
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
159
|
+
const resolved = resolveSessionViewByProfile(profileId, liveSessions);
|
|
160
|
+
if (resolved?.live) {
|
|
161
|
+
const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
|
|
162
|
+
if (activeSession) return activeSession;
|
|
163
|
+
}
|
|
164
|
+
if (!resolved?.live) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
161
167
|
|
|
162
|
-
// Some browser-service builds do not populate
|
|
163
|
-
// Fallback to page:list
|
|
168
|
+
// Some browser-service builds do not populate current_url reliably.
|
|
169
|
+
// Fallback to page:list only to enrich an already-live profile.
|
|
164
170
|
try {
|
|
165
171
|
const pagePayload = await callAPI('page:list', { profileId });
|
|
166
172
|
const pages = Array.isArray(pagePayload?.pages)
|
|
@@ -185,6 +191,12 @@ export async function getSessionByProfile(profileId) {
|
|
|
185
191
|
}
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
export async function getResolvedSessions() {
|
|
195
|
+
const status = await callAPI('getStatus', {});
|
|
196
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
197
|
+
return buildResolvedSessionView(liveSessions);
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
function buildDomSnapshotScript(maxDepth, maxChildren) {
|
|
189
201
|
return `(() => {
|
|
190
202
|
const MAX_DEPTH = ${maxDepth};
|
package/src/utils/help.mjs
CHANGED
|
@@ -32,16 +32,16 @@ BROWSER CONTROL:
|
|
|
32
32
|
stop --alias <alias> Stop by alias
|
|
33
33
|
stop idle Stop all idle sessions
|
|
34
34
|
stop all Stop all sessions
|
|
35
|
-
status [profileId]
|
|
35
|
+
status [profileId] Show resolved per-profile session view
|
|
36
36
|
list Alias of status
|
|
37
37
|
|
|
38
38
|
LIFECYCLE & CLEANUP:
|
|
39
|
-
instances List
|
|
40
|
-
sessions List
|
|
41
|
-
cleanup [profileId] Cleanup
|
|
39
|
+
instances List resolved session view (live + registered + idle state)
|
|
40
|
+
sessions List resolved session view for all profiles
|
|
41
|
+
cleanup [profileId] Cleanup only one profile (remote stop + local registry/lock/watchdog)
|
|
42
42
|
cleanup all Cleanup all active sessions
|
|
43
43
|
cleanup locks Cleanup stale lock files
|
|
44
|
-
force-stop [profileId] Force stop session (
|
|
44
|
+
force-stop [profileId] Force stop only one profile session (no alias/id targeting)
|
|
45
45
|
lock list List active session locks
|
|
46
46
|
lock [profileId] Show lock info for profile
|
|
47
47
|
unlock [profileId] Release lock for profile
|
|
@@ -63,7 +63,7 @@ PAGES:
|
|
|
63
63
|
new-page [profileId] [--url <url>]
|
|
64
64
|
close-page [profileId] [index]
|
|
65
65
|
switch-page [profileId] <index>
|
|
66
|
-
list-pages [profileId]
|
|
66
|
+
list-pages [profileId] List pages only when that profile is live
|
|
67
67
|
|
|
68
68
|
DEVTOOLS:
|
|
69
69
|
devtools logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
|
|
@@ -180,8 +180,31 @@ ENV:
|
|
|
180
180
|
CAMO_ROOT Legacy data root (auto-appends .camo if needed)
|
|
181
181
|
CAMO_WS_URL Optional ws://host:port override
|
|
182
182
|
CAMO_WS_HOST / CAMO_WS_PORT WS host/port for browser-service
|
|
183
|
+
CAMO_BRING_TO_FRONT_MODE Bring-to-front policy: auto (default) | never
|
|
184
|
+
CAMO_SKIP_BRING_TO_FRONT Legacy alias for CAMO_BRING_TO_FRONT_MODE=never
|
|
183
185
|
CAMO_PROGRESS_EVENTS_FILE Optional path override for progress jsonl
|
|
184
186
|
CAMO_PROGRESS_WS_HOST / CAMO_PROGRESS_WS_PORT Progress daemon host/port (defaults: 127.0.0.1:7788)
|
|
187
|
+
|
|
188
|
+
SESSION ISOLATION:
|
|
189
|
+
- profile is the lifecycle primary key. browser-service, lock, watchdog, registry all bind to the same profileId.
|
|
190
|
+
- camo start/stop/cleanup/force-stop <profileId> only targets that exact profile and must not affect other profiles.
|
|
191
|
+
- stop --id / stop --alias are convenience selectors for stop only; cleanup/force-stop never accept alias or instance id.
|
|
192
|
+
- status/sessions/instances all read the same resolved session view with these fields:
|
|
193
|
+
live: browser-service currently has this profile session
|
|
194
|
+
registered: local registry has metadata for this profile
|
|
195
|
+
orphaned: registry exists but service session is gone
|
|
196
|
+
needsRecovery: registry says active but service no longer has that profile
|
|
197
|
+
- list-pages requires live=true for that profile; otherwise camo will fail fast instead of probing other profiles.
|
|
198
|
+
|
|
199
|
+
ISOLATION EXAMPLES:
|
|
200
|
+
camo sessions
|
|
201
|
+
camo status finger
|
|
202
|
+
camo stop finger
|
|
203
|
+
camo status xhs-qa-1
|
|
204
|
+
camo cleanup finger
|
|
205
|
+
camo force-stop finger
|
|
206
|
+
invalid: camo cleanup --alias shard1
|
|
207
|
+
invalid: camo force-stop --id inst_xxxxxxxx
|
|
185
208
|
`);
|
|
186
209
|
}
|
|
187
210
|
|