@web-auto/camo 0.1.5 → 0.1.7
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 +17 -4
- package/package.json +1 -1
- package/src/autoscript/action-providers/xhs/comments.mjs +38 -2
- package/src/autoscript/action-providers/xhs/interaction.mjs +47 -2
- package/src/cli.mjs +13 -2
- package/src/commands/browser.mjs +374 -46
- package/src/commands/lifecycle.mjs +75 -34
- package/src/container/runtime-core/operations/selector-scripts.mjs +71 -4
- package/src/lifecycle/session-registry.mjs +99 -3
- package/src/lifecycle/session-watchdog.mjs +18 -4
- package/src/utils/browser-service.mjs +14 -0
- package/src/utils/help.mjs +14 -2
package/README.md
CHANGED
|
@@ -33,8 +33,14 @@ camo profile create myprofile
|
|
|
33
33
|
# Set as default
|
|
34
34
|
camo profile default myprofile
|
|
35
35
|
|
|
36
|
-
# Start browser
|
|
37
|
-
camo start --url https://example.com
|
|
36
|
+
# Start browser (with alias)
|
|
37
|
+
camo start --url https://example.com --alias main
|
|
38
|
+
|
|
39
|
+
# Start headless worker (auto-kill after idle timeout)
|
|
40
|
+
camo start worker-1 --headless --alias shard1 --idle-timeout 30m
|
|
41
|
+
|
|
42
|
+
# Start with devtools (headful only)
|
|
43
|
+
camo start worker-1 --devtools
|
|
38
44
|
|
|
39
45
|
# Navigate
|
|
40
46
|
camo goto https://www.xiaohongshu.com
|
|
@@ -68,8 +74,12 @@ camo create fingerprint --os <os> --region <region>
|
|
|
68
74
|
### Browser Control
|
|
69
75
|
|
|
70
76
|
```bash
|
|
71
|
-
camo start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]
|
|
77
|
+
camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
|
|
72
78
|
camo stop [profileId]
|
|
79
|
+
camo stop --id <instanceId>
|
|
80
|
+
camo stop --alias <alias>
|
|
81
|
+
camo stop idle
|
|
82
|
+
camo stop all
|
|
73
83
|
camo status [profileId]
|
|
74
84
|
camo shutdown # Shutdown browser-service (all sessions)
|
|
75
85
|
```
|
|
@@ -77,10 +87,13 @@ camo shutdown # Shutdown browser-service (all sessions)
|
|
|
77
87
|
`camo start` in headful mode now persists window size per profile and reuses that size on next start.
|
|
78
88
|
If no saved size exists, it defaults to near-fullscreen (full width, slight vertical reserve).
|
|
79
89
|
Use `--width/--height` to override and update the saved profile size.
|
|
90
|
+
For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
|
|
91
|
+
Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
|
|
80
92
|
|
|
81
93
|
### Lifecycle & Cleanup
|
|
82
94
|
|
|
83
95
|
```bash
|
|
96
|
+
camo instances # List global camoufox instances (live + orphaned + idle state)
|
|
84
97
|
camo sessions # List active browser sessions
|
|
85
98
|
camo cleanup [profileId] # Cleanup session (release lock + stop)
|
|
86
99
|
camo cleanup all # Cleanup all active sessions
|
|
@@ -332,7 +345,7 @@ Condition types:
|
|
|
332
345
|
Camo CLI persists session information locally:
|
|
333
346
|
|
|
334
347
|
- Sessions are registered in `~/.webauto/sessions/`
|
|
335
|
-
- On restart, `camo sessions` shows
|
|
348
|
+
- On restart, `camo sessions` / `camo instances` shows live + orphaned sessions
|
|
336
349
|
- Use `camo recover <profileId>` to reconnect or cleanup orphaned sessions
|
|
337
350
|
- Stale sessions (>7 days) are automatically cleaned up
|
|
338
351
|
|
package/package.json
CHANGED
|
@@ -86,6 +86,43 @@ export function buildCommentsHarvestScript(params = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
return null;
|
|
88
88
|
};
|
|
89
|
+
const normalizeInlineText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
|
90
|
+
const sanitizeAuthorText = (raw, commentText = '') => {
|
|
91
|
+
const text = normalizeInlineText(raw);
|
|
92
|
+
if (!text) return '';
|
|
93
|
+
if (commentText && text === commentText) return '';
|
|
94
|
+
if (text.length > 40) return '';
|
|
95
|
+
if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
|
|
96
|
+
return text;
|
|
97
|
+
};
|
|
98
|
+
const readAuthor = (item, commentText = '') => {
|
|
99
|
+
const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
|
|
100
|
+
for (const attr of attrNames) {
|
|
101
|
+
const value = sanitizeAuthorText(item.getAttribute?.(attr), commentText);
|
|
102
|
+
if (value) return value;
|
|
103
|
+
}
|
|
104
|
+
const selectors = [
|
|
105
|
+
'.comment-user .name',
|
|
106
|
+
'.comment-user .username',
|
|
107
|
+
'.comment-user .user-name',
|
|
108
|
+
'.author .name',
|
|
109
|
+
'.author',
|
|
110
|
+
'.user-name',
|
|
111
|
+
'.username',
|
|
112
|
+
'.name',
|
|
113
|
+
'a[href*="/user/profile/"]',
|
|
114
|
+
'a[href*="/user/"]',
|
|
115
|
+
];
|
|
116
|
+
for (const selector of selectors) {
|
|
117
|
+
const node = item.querySelector(selector);
|
|
118
|
+
if (!node) continue;
|
|
119
|
+
const title = sanitizeAuthorText(node.getAttribute?.('title'), commentText);
|
|
120
|
+
if (title) return title;
|
|
121
|
+
const text = sanitizeAuthorText(node.textContent, commentText);
|
|
122
|
+
if (text) return text;
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
};
|
|
89
126
|
|
|
90
127
|
const scroller = document.querySelector('.note-scroller')
|
|
91
128
|
|| document.querySelector('.comments-el')
|
|
@@ -105,9 +142,8 @@ export function buildCommentsHarvestScript(params = {}) {
|
|
|
105
142
|
const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
|
|
106
143
|
for (const item of nodes) {
|
|
107
144
|
const textNode = item.querySelector('.content, .comment-content, p');
|
|
108
|
-
const authorNode = item.querySelector('.name, .author, .user-name, [class*="author"], [class*="name"]');
|
|
109
145
|
const text = String((textNode && textNode.textContent) || '').trim();
|
|
110
|
-
const author =
|
|
146
|
+
const author = readAuthor(item, text);
|
|
111
147
|
if (!text) continue;
|
|
112
148
|
const key = author + '::' + text;
|
|
113
149
|
if (commentMap.has(key)) continue;
|
|
@@ -74,6 +74,42 @@ function buildCollectLikeTargetsScript() {
|
|
|
74
74
|
}
|
|
75
75
|
return '';
|
|
76
76
|
};
|
|
77
|
+
const sanitizeUserName = (raw, commentText = '') => {
|
|
78
|
+
const text = String(raw || '').replace(/\\s+/g, ' ').trim();
|
|
79
|
+
if (!text) return '';
|
|
80
|
+
if (commentText && text === commentText) return '';
|
|
81
|
+
if (text.length > 40) return '';
|
|
82
|
+
if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
|
|
83
|
+
return text;
|
|
84
|
+
};
|
|
85
|
+
const readUserName = (item, commentText = '') => {
|
|
86
|
+
const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
|
|
87
|
+
for (const attr of attrNames) {
|
|
88
|
+
const value = sanitizeUserName(item.getAttribute?.(attr), commentText);
|
|
89
|
+
if (value) return value;
|
|
90
|
+
}
|
|
91
|
+
const selectors = [
|
|
92
|
+
'.comment-user .name',
|
|
93
|
+
'.comment-user .username',
|
|
94
|
+
'.comment-user .user-name',
|
|
95
|
+
'.author .name',
|
|
96
|
+
'.author',
|
|
97
|
+
'.user-name',
|
|
98
|
+
'.username',
|
|
99
|
+
'.name',
|
|
100
|
+
'a[href*="/user/profile/"]',
|
|
101
|
+
'a[href*="/user/"]',
|
|
102
|
+
];
|
|
103
|
+
for (const selector of selectors) {
|
|
104
|
+
const node = item.querySelector(selector);
|
|
105
|
+
if (!node) continue;
|
|
106
|
+
const title = sanitizeUserName(node.getAttribute?.('title'), commentText);
|
|
107
|
+
if (title) return title;
|
|
108
|
+
const text = sanitizeUserName(node.textContent, commentText);
|
|
109
|
+
if (text) return text;
|
|
110
|
+
}
|
|
111
|
+
return '';
|
|
112
|
+
};
|
|
77
113
|
const readAttr = (item, attrNames) => {
|
|
78
114
|
for (const attr of attrNames) {
|
|
79
115
|
const value = String(item.getAttribute?.(attr) || '').trim();
|
|
@@ -81,6 +117,15 @@ function buildCollectLikeTargetsScript() {
|
|
|
81
117
|
}
|
|
82
118
|
return '';
|
|
83
119
|
};
|
|
120
|
+
const readUserId = (item) => {
|
|
121
|
+
const value = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
|
|
122
|
+
if (value) return value;
|
|
123
|
+
const anchor = item.querySelector('a[href*="/user/profile/"], a[href*="/user/"]');
|
|
124
|
+
const href = String(anchor?.getAttribute?.('href') || '').trim();
|
|
125
|
+
if (!href) return '';
|
|
126
|
+
const matched = href.match(/\\/user\\/(?:profile\\/)?([a-zA-Z0-9_-]+)/);
|
|
127
|
+
return matched && matched[1] ? matched[1] : '';
|
|
128
|
+
};
|
|
84
129
|
|
|
85
130
|
const matchedSet = new Set(
|
|
86
131
|
Array.isArray(state.matchedComments)
|
|
@@ -92,8 +137,8 @@ function buildCollectLikeTargetsScript() {
|
|
|
92
137
|
const item = items[index];
|
|
93
138
|
const text = readText(item, ['.content', '.comment-content', 'p']);
|
|
94
139
|
if (!text) continue;
|
|
95
|
-
const userName =
|
|
96
|
-
const userId =
|
|
140
|
+
const userName = readUserName(item, text);
|
|
141
|
+
const userId = readUserId(item);
|
|
97
142
|
const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
|
|
98
143
|
const likeControl = findLikeControl(item);
|
|
99
144
|
rows.push({
|
package/src/cli.mjs
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listProfiles, getDefaultProfile, loadConfig, hasStartScript, setRepoRoot } from './utils/config.mjs';
|
|
5
|
-
import { ensureCamoufox, ensureBrowserService } from './utils/browser-service.mjs';
|
|
6
5
|
import { printHelp, printProfilesAndHint } from './utils/help.mjs';
|
|
7
6
|
import { handleProfileCommand } from './commands/profile.mjs';
|
|
8
7
|
import { handleInitCommand } from './commands/init.mjs';
|
|
@@ -24,7 +23,7 @@ import {
|
|
|
24
23
|
} from './commands/browser.mjs';
|
|
25
24
|
import {
|
|
26
25
|
handleCleanupCommand, handleForceStopCommand, handleLockCommand,
|
|
27
|
-
handleUnlockCommand, handleSessionsCommand
|
|
26
|
+
handleUnlockCommand, handleSessionsCommand, handleInstancesCommand
|
|
28
27
|
} from './commands/lifecycle.mjs';
|
|
29
28
|
import { handleSessionWatchdogCommand } from './lifecycle/session-watchdog.mjs';
|
|
30
29
|
import { safeAppendProgressEvent } from './events/progress-log.mjs';
|
|
@@ -55,6 +54,13 @@ function inferProfileId(cmd, args) {
|
|
|
55
54
|
'new-page', 'close-page', 'switch-page', 'list-pages',
|
|
56
55
|
'cleanup', 'force-stop', 'lock', 'unlock', 'sessions',
|
|
57
56
|
].includes(cmd)) {
|
|
57
|
+
if ((cmd === 'stop' || cmd === 'close') && (args.includes('--id') || args.includes('--alias'))) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const first = positionals[0] || null;
|
|
61
|
+
if ((cmd === 'stop' || cmd === 'close') && (first === 'all' || first === 'idle')) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
58
64
|
return positionals[0] || null;
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -212,6 +218,11 @@ async function main() {
|
|
|
212
218
|
return;
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
if (cmd === 'instances') {
|
|
222
|
+
await runTrackedCommand(cmd, args, () => handleInstancesCommand(args));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
215
226
|
if (cmd === 'container') {
|
|
216
227
|
await runTrackedCommand(cmd, args, () => handleContainerCommand(args));
|
|
217
228
|
return;
|
package/src/commands/browser.mjs
CHANGED
|
@@ -7,14 +7,32 @@ import {
|
|
|
7
7
|
} from '../utils/config.mjs';
|
|
8
8
|
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
|
|
9
9
|
import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
|
|
10
|
-
import { acquireLock, releaseLock,
|
|
11
|
-
import {
|
|
10
|
+
import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
|
|
11
|
+
import {
|
|
12
|
+
buildSelectorClickScript,
|
|
13
|
+
buildSelectorTypeScript,
|
|
14
|
+
} from '../container/runtime-core/operations/selector-scripts.mjs';
|
|
15
|
+
import {
|
|
16
|
+
registerSession,
|
|
17
|
+
updateSession,
|
|
18
|
+
getSessionInfo,
|
|
19
|
+
unregisterSession,
|
|
20
|
+
listRegisteredSessions,
|
|
21
|
+
markSessionClosed,
|
|
22
|
+
cleanupStaleSessions,
|
|
23
|
+
resolveSessionTarget,
|
|
24
|
+
isSessionAliasTaken,
|
|
25
|
+
} from '../lifecycle/session-registry.mjs';
|
|
12
26
|
import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
13
27
|
|
|
14
28
|
const START_WINDOW_MIN_WIDTH = 960;
|
|
15
29
|
const START_WINDOW_MIN_HEIGHT = 700;
|
|
16
30
|
const START_WINDOW_MAX_RESERVE = 240;
|
|
17
31
|
const START_WINDOW_DEFAULT_RESERVE = 72;
|
|
32
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
|
+
const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
|
|
34
|
+
? ['Meta+Alt+I', 'F12']
|
|
35
|
+
: ['F12', 'Control+Shift+I'];
|
|
18
36
|
|
|
19
37
|
function sleep(ms) {
|
|
20
38
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -29,6 +47,149 @@ function clamp(value, min, max) {
|
|
|
29
47
|
return Math.min(Math.max(value, min), max);
|
|
30
48
|
}
|
|
31
49
|
|
|
50
|
+
function readFlagValue(args, names) {
|
|
51
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
52
|
+
if (!names.includes(args[i])) continue;
|
|
53
|
+
const value = args[i + 1];
|
|
54
|
+
if (!value || String(value).startsWith('-')) return null;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseDurationMs(raw, fallbackMs) {
|
|
61
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
|
|
62
|
+
const text = String(raw).trim().toLowerCase();
|
|
63
|
+
if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
|
|
64
|
+
const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
65
|
+
if (!matched) {
|
|
66
|
+
throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
|
|
67
|
+
}
|
|
68
|
+
const value = Number(matched[1]);
|
|
69
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
70
|
+
throw new Error('Invalid --idle-timeout value');
|
|
71
|
+
}
|
|
72
|
+
const unit = matched[2] || 'm';
|
|
73
|
+
const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
|
|
74
|
+
return Math.floor(value * factor);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateAlias(alias) {
|
|
78
|
+
const text = String(alias || '').trim();
|
|
79
|
+
if (!text) return null;
|
|
80
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
|
|
81
|
+
throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
|
|
82
|
+
}
|
|
83
|
+
return text.slice(0, 64);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatDurationMs(ms) {
|
|
87
|
+
const value = Number(ms);
|
|
88
|
+
if (!Number.isFinite(value) || value <= 0) return 'disabled';
|
|
89
|
+
if (value % 3600000 === 0) return `${value / 3600000}h`;
|
|
90
|
+
if (value % 60000 === 0) return `${value / 60000}m`;
|
|
91
|
+
if (value % 1000 === 0) return `${value / 1000}s`;
|
|
92
|
+
return `${value}ms`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function computeIdleState(session, now = Date.now()) {
|
|
96
|
+
const headless = session?.headless === true;
|
|
97
|
+
const timeoutMs = headless
|
|
98
|
+
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
99
|
+
: 0;
|
|
100
|
+
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
101
|
+
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
102
|
+
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
103
|
+
return { headless, timeoutMs, idleMs, idle };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function stopAndCleanupProfile(profileId, options = {}) {
|
|
107
|
+
const id = String(profileId || '').trim();
|
|
108
|
+
if (!id) return { profileId: id, ok: false, error: 'profile_required' };
|
|
109
|
+
const force = options.force === true;
|
|
110
|
+
const serviceUp = options.serviceUp === true;
|
|
111
|
+
let result = null;
|
|
112
|
+
let error = null;
|
|
113
|
+
if (serviceUp) {
|
|
114
|
+
try {
|
|
115
|
+
result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
error = err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
stopSessionWatchdog(id);
|
|
121
|
+
releaseLock(id);
|
|
122
|
+
markSessionClosed(id);
|
|
123
|
+
return {
|
|
124
|
+
profileId: id,
|
|
125
|
+
ok: !error,
|
|
126
|
+
serviceUp,
|
|
127
|
+
result,
|
|
128
|
+
error: error ? (error.message || String(error)) : null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function probeViewportSize(profileId) {
|
|
133
|
+
try {
|
|
134
|
+
const payload = await callAPI('evaluate', {
|
|
135
|
+
profileId,
|
|
136
|
+
script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
|
|
137
|
+
});
|
|
138
|
+
const size = payload?.result || payload?.data || payload || {};
|
|
139
|
+
const width = Number(size?.width || 0);
|
|
140
|
+
const height = Number(size?.height || 0);
|
|
141
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
142
|
+
return { width, height };
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function requestDevtoolsOpen(profileId, options = {}) {
|
|
149
|
+
const id = String(profileId || '').trim();
|
|
150
|
+
if (!id) {
|
|
151
|
+
return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
|
|
155
|
+
? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
|
|
156
|
+
: DEVTOOLS_SHORTCUTS;
|
|
157
|
+
const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
|
|
158
|
+
const before = await probeViewportSize(id);
|
|
159
|
+
const attempts = [];
|
|
160
|
+
|
|
161
|
+
for (const key of shortcuts) {
|
|
162
|
+
try {
|
|
163
|
+
await callAPI('keyboard:press', { profileId: id, key });
|
|
164
|
+
attempts.push({ key, ok: true });
|
|
165
|
+
if (settleMs > 0) {
|
|
166
|
+
// Allow browser UI animation to settle after shortcut.
|
|
167
|
+
// eslint-disable-next-line no-await-in-loop
|
|
168
|
+
await sleep(settleMs);
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
attempts.push({ key, ok: false, error: err?.message || String(err) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const after = await probeViewportSize(id);
|
|
176
|
+
const beforeHeight = Number(before?.height || 0);
|
|
177
|
+
const afterHeight = Number(after?.height || 0);
|
|
178
|
+
const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
|
|
179
|
+
const successCount = attempts.filter((item) => item.ok).length;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
ok: successCount > 0,
|
|
183
|
+
requested: true,
|
|
184
|
+
shortcuts,
|
|
185
|
+
attempts,
|
|
186
|
+
before,
|
|
187
|
+
after,
|
|
188
|
+
verified: viewportReduced,
|
|
189
|
+
verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
32
193
|
export function computeTargetViewportFromWindowMetrics(measured) {
|
|
33
194
|
const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
|
|
34
195
|
const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
|
|
@@ -154,8 +315,12 @@ export async function handleStartCommand(args) {
|
|
|
154
315
|
const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
|
|
155
316
|
const hasExplicitWidth = Number.isFinite(explicitWidth);
|
|
156
317
|
const hasExplicitHeight = Number.isFinite(explicitHeight);
|
|
318
|
+
const alias = validateAlias(readFlagValue(args, ['--alias']));
|
|
319
|
+
const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
|
|
320
|
+
const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
|
|
321
|
+
const wantsDevtools = args.includes('--devtools');
|
|
157
322
|
if (hasExplicitWidth !== hasExplicitHeight) {
|
|
158
|
-
throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]');
|
|
323
|
+
throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
|
|
159
324
|
}
|
|
160
325
|
if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
|
|
161
326
|
throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
|
|
@@ -169,6 +334,7 @@ export async function handleStartCommand(args) {
|
|
|
169
334
|
const arg = args[i];
|
|
170
335
|
if (arg === '--url') { i++; continue; }
|
|
171
336
|
if (arg === '--width' || arg === '--height') { i++; continue; }
|
|
337
|
+
if (arg === '--alias' || arg === '--idle-timeout') { i++; continue; }
|
|
172
338
|
if (arg === '--headless') continue;
|
|
173
339
|
if (arg.startsWith('--')) continue;
|
|
174
340
|
|
|
@@ -187,24 +353,52 @@ export async function handleStartCommand(args) {
|
|
|
187
353
|
throw new Error('No default profile set. Run: camo profile default <profileId>');
|
|
188
354
|
}
|
|
189
355
|
}
|
|
356
|
+
if (alias && isSessionAliasTaken(alias, profileId)) {
|
|
357
|
+
throw new Error(`Alias is already in use: ${alias}`);
|
|
358
|
+
}
|
|
190
359
|
|
|
191
360
|
// Check for existing session in browser service
|
|
192
361
|
const existing = await getSessionByProfile(profileId);
|
|
193
362
|
if (existing) {
|
|
194
363
|
// Session exists in browser service - update registry and lock
|
|
195
364
|
acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
365
|
+
const saved = getSessionInfo(profileId);
|
|
366
|
+
const record = saved
|
|
367
|
+
? updateSession(profileId, {
|
|
368
|
+
sessionId: existing.session_id || existing.profileId,
|
|
369
|
+
url: existing.current_url,
|
|
370
|
+
mode: existing.mode,
|
|
371
|
+
alias: alias || saved.alias || null,
|
|
372
|
+
})
|
|
373
|
+
: registerSession(profileId, {
|
|
374
|
+
sessionId: existing.session_id || existing.profileId,
|
|
375
|
+
url: existing.current_url,
|
|
376
|
+
mode: existing.mode,
|
|
377
|
+
alias: alias || null,
|
|
378
|
+
});
|
|
379
|
+
const idleState = computeIdleState(record);
|
|
380
|
+
const payload = {
|
|
202
381
|
ok: true,
|
|
203
382
|
sessionId: existing.session_id || existing.profileId,
|
|
383
|
+
instanceId: record.instanceId,
|
|
204
384
|
profileId,
|
|
205
385
|
message: 'Session already running',
|
|
206
386
|
url: existing.current_url,
|
|
207
|
-
|
|
387
|
+
alias: record.alias || null,
|
|
388
|
+
idleTimeoutMs: idleState.timeoutMs,
|
|
389
|
+
idleTimeout: formatDurationMs(idleState.timeoutMs),
|
|
390
|
+
closeHint: {
|
|
391
|
+
byProfile: `camo stop ${profileId}`,
|
|
392
|
+
byId: `camo stop --id ${record.instanceId}`,
|
|
393
|
+
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
|
|
397
|
+
const existingHeadless = existing?.headless === true || existingMode.includes('headless');
|
|
398
|
+
if (!existingHeadless && wantsDevtools) {
|
|
399
|
+
payload.devtools = await requestDevtoolsOpen(profileId);
|
|
400
|
+
}
|
|
401
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
208
402
|
startSessionWatchdog(profileId);
|
|
209
403
|
return;
|
|
210
404
|
}
|
|
@@ -219,22 +413,43 @@ export async function handleStartCommand(args) {
|
|
|
219
413
|
}
|
|
220
414
|
|
|
221
415
|
const headless = args.includes('--headless');
|
|
416
|
+
if (wantsDevtools && headless) {
|
|
417
|
+
throw new Error('--devtools is not supported with --headless');
|
|
418
|
+
}
|
|
419
|
+
const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
|
|
222
420
|
const targetUrl = explicitUrl || implicitUrl;
|
|
223
421
|
const result = await callAPI('start', {
|
|
224
422
|
profileId,
|
|
225
423
|
url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
|
|
226
424
|
headless,
|
|
425
|
+
devtools: wantsDevtools,
|
|
227
426
|
});
|
|
228
427
|
|
|
229
428
|
if (result?.ok) {
|
|
230
429
|
const sessionId = result.sessionId || result.profileId || profileId;
|
|
231
430
|
acquireLock(profileId, { sessionId });
|
|
232
|
-
registerSession(profileId, {
|
|
431
|
+
const record = registerSession(profileId, {
|
|
233
432
|
sessionId,
|
|
234
433
|
url: targetUrl,
|
|
235
434
|
headless,
|
|
435
|
+
alias,
|
|
436
|
+
idleTimeoutMs,
|
|
437
|
+
lastAction: 'start',
|
|
236
438
|
});
|
|
237
439
|
startSessionWatchdog(profileId);
|
|
440
|
+
result.instanceId = record.instanceId;
|
|
441
|
+
result.alias = record.alias || null;
|
|
442
|
+
result.idleTimeoutMs = idleTimeoutMs;
|
|
443
|
+
result.idleTimeout = formatDurationMs(idleTimeoutMs);
|
|
444
|
+
result.closeHint = {
|
|
445
|
+
byProfile: `camo stop ${profileId}`,
|
|
446
|
+
byId: `camo stop --id ${record.instanceId}`,
|
|
447
|
+
byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
|
|
448
|
+
all: 'camo close all',
|
|
449
|
+
};
|
|
450
|
+
result.message = headless
|
|
451
|
+
? `Started headless session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
|
|
452
|
+
: 'Started session. Remember to stop it when finished.';
|
|
238
453
|
|
|
239
454
|
if (!headless) {
|
|
240
455
|
let windowTarget = null;
|
|
@@ -280,30 +495,161 @@ export async function handleStartCommand(args) {
|
|
|
280
495
|
Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
|
|
281
496
|
);
|
|
282
497
|
result.profileWindow = savedWindow?.window || null;
|
|
498
|
+
if (wantsDevtools) {
|
|
499
|
+
result.devtools = await requestDevtoolsOpen(profileId);
|
|
500
|
+
}
|
|
283
501
|
}
|
|
284
502
|
}
|
|
285
503
|
console.log(JSON.stringify(result, null, 2));
|
|
286
504
|
}
|
|
287
505
|
|
|
288
506
|
export async function handleStopCommand(args) {
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
507
|
+
const rawTarget = String(args[1] || '').trim();
|
|
508
|
+
const target = rawTarget.toLowerCase();
|
|
509
|
+
const idTarget = readFlagValue(args, ['--id']);
|
|
510
|
+
const aliasTarget = readFlagValue(args, ['--alias']);
|
|
511
|
+
if (args.includes('--id') && !idTarget) {
|
|
512
|
+
throw new Error('Usage: camo stop --id <instanceId>');
|
|
513
|
+
}
|
|
514
|
+
if (args.includes('--alias') && !aliasTarget) {
|
|
515
|
+
throw new Error('Usage: camo stop --alias <alias>');
|
|
516
|
+
}
|
|
517
|
+
const stopIdle = target === 'idle' || args.includes('--idle');
|
|
518
|
+
const stopAll = target === 'all';
|
|
519
|
+
const serviceUp = await checkBrowserService();
|
|
292
520
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
521
|
+
if (stopAll) {
|
|
522
|
+
let liveSessions = [];
|
|
523
|
+
if (serviceUp) {
|
|
524
|
+
try {
|
|
525
|
+
const status = await callAPI('getStatus', {});
|
|
526
|
+
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
527
|
+
} catch {
|
|
528
|
+
// Ignore and fallback to local registry.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const profileSet = new Set(liveSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
532
|
+
for (const session of listRegisteredSessions()) {
|
|
533
|
+
if (String(session?.status || '').trim() === 'closed') continue;
|
|
534
|
+
const profileId = String(session?.profileId || '').trim();
|
|
535
|
+
if (profileId) profileSet.add(profileId);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const results = [];
|
|
539
|
+
for (const profileId of profileSet) {
|
|
540
|
+
// eslint-disable-next-line no-await-in-loop
|
|
541
|
+
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
542
|
+
}
|
|
543
|
+
console.log(JSON.stringify({
|
|
544
|
+
ok: true,
|
|
545
|
+
mode: 'all',
|
|
546
|
+
serviceUp,
|
|
547
|
+
closed: results.filter((item) => item.ok).length,
|
|
548
|
+
failed: results.filter((item) => !item.ok).length,
|
|
549
|
+
results,
|
|
550
|
+
}, null, 2));
|
|
551
|
+
return;
|
|
303
552
|
}
|
|
304
553
|
|
|
305
|
-
if (
|
|
306
|
-
|
|
554
|
+
if (stopIdle) {
|
|
555
|
+
const now = Date.now();
|
|
556
|
+
const registeredSessions = listRegisteredSessions();
|
|
557
|
+
let liveSessions = [];
|
|
558
|
+
if (serviceUp) {
|
|
559
|
+
try {
|
|
560
|
+
const status = await callAPI('getStatus', {});
|
|
561
|
+
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
562
|
+
} catch {
|
|
563
|
+
// Ignore and fallback to local registry.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const regMap = new Map(
|
|
567
|
+
registeredSessions
|
|
568
|
+
.filter((item) => item && String(item?.status || '').trim() === 'active')
|
|
569
|
+
.map((item) => [String(item.profileId || '').trim(), item]),
|
|
570
|
+
);
|
|
571
|
+
const idleTargets = new Set(
|
|
572
|
+
registeredSessions
|
|
573
|
+
.filter((item) => String(item?.status || '').trim() === 'active')
|
|
574
|
+
.map((item) => ({ session: item, idle: computeIdleState(item, now) }))
|
|
575
|
+
.filter((item) => item.idle.idle)
|
|
576
|
+
.map((item) => item.session.profileId),
|
|
577
|
+
);
|
|
578
|
+
let orphanLiveHeadlessCount = 0;
|
|
579
|
+
for (const live of liveSessions) {
|
|
580
|
+
const liveProfileId = String(live?.profileId || '').trim();
|
|
581
|
+
if (!liveProfileId) continue;
|
|
582
|
+
if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
|
|
583
|
+
const mode = String(live?.mode || '').toLowerCase();
|
|
584
|
+
const liveHeadless = live?.headless === true || mode.includes('headless');
|
|
585
|
+
// Live but unregistered headless sessions are treated as idle-orphan targets.
|
|
586
|
+
if (liveHeadless) {
|
|
587
|
+
idleTargets.add(liveProfileId);
|
|
588
|
+
orphanLiveHeadlessCount += 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const results = [];
|
|
592
|
+
for (const profileId of idleTargets) {
|
|
593
|
+
// eslint-disable-next-line no-await-in-loop
|
|
594
|
+
results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
|
|
595
|
+
}
|
|
596
|
+
console.log(JSON.stringify({
|
|
597
|
+
ok: true,
|
|
598
|
+
mode: 'idle',
|
|
599
|
+
serviceUp,
|
|
600
|
+
targetCount: idleTargets.size,
|
|
601
|
+
orphanLiveHeadlessCount,
|
|
602
|
+
closed: results.filter((item) => item.ok).length,
|
|
603
|
+
failed: results.filter((item) => !item.ok).length,
|
|
604
|
+
results,
|
|
605
|
+
}, null, 2));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let profileId = null;
|
|
610
|
+
let resolvedBy = 'profile';
|
|
611
|
+
if (idTarget) {
|
|
612
|
+
const resolved = resolveSessionTarget(idTarget);
|
|
613
|
+
if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
|
|
614
|
+
profileId = resolved.profileId;
|
|
615
|
+
resolvedBy = resolved.reason;
|
|
616
|
+
} else if (aliasTarget) {
|
|
617
|
+
const resolved = resolveSessionTarget(aliasTarget);
|
|
618
|
+
if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
|
|
619
|
+
profileId = resolved.profileId;
|
|
620
|
+
resolvedBy = resolved.reason;
|
|
621
|
+
} else {
|
|
622
|
+
const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
|
|
623
|
+
if (positional) {
|
|
624
|
+
const resolved = resolveSessionTarget(positional);
|
|
625
|
+
if (resolved) {
|
|
626
|
+
profileId = resolved.profileId;
|
|
627
|
+
resolvedBy = resolved.reason;
|
|
628
|
+
} else {
|
|
629
|
+
profileId = positional;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!profileId) {
|
|
635
|
+
profileId = getDefaultProfile();
|
|
636
|
+
}
|
|
637
|
+
if (!profileId) {
|
|
638
|
+
throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const result = await stopAndCleanupProfile(profileId, { serviceUp });
|
|
642
|
+
if (!result.ok && serviceUp) {
|
|
643
|
+
throw new Error(result.error || `stop failed for profile: ${profileId}`);
|
|
644
|
+
}
|
|
645
|
+
console.log(JSON.stringify({
|
|
646
|
+
ok: true,
|
|
647
|
+
profileId,
|
|
648
|
+
resolvedBy,
|
|
649
|
+
serviceUp,
|
|
650
|
+
warning: (!serviceUp && !result.ok) ? result.error : null,
|
|
651
|
+
result: result.result || null,
|
|
652
|
+
}, null, 2));
|
|
307
653
|
}
|
|
308
654
|
|
|
309
655
|
export async function handleStatusCommand(args) {
|
|
@@ -425,14 +771,7 @@ export async function handleClickCommand(args) {
|
|
|
425
771
|
|
|
426
772
|
const result = await callAPI('evaluate', {
|
|
427
773
|
profileId,
|
|
428
|
-
script:
|
|
429
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
430
|
-
if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
|
|
431
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
432
|
-
await new Promise(r => setTimeout(r, 200));
|
|
433
|
-
el.click();
|
|
434
|
-
return { clicked: true, selector: ${JSON.stringify(selector)} };
|
|
435
|
-
})()`
|
|
774
|
+
script: buildSelectorClickScript({ selector, highlight: false }),
|
|
436
775
|
});
|
|
437
776
|
console.log(JSON.stringify(result, null, 2));
|
|
438
777
|
}
|
|
@@ -459,18 +798,7 @@ export async function handleTypeCommand(args) {
|
|
|
459
798
|
|
|
460
799
|
const result = await callAPI('evaluate', {
|
|
461
800
|
profileId,
|
|
462
|
-
script:
|
|
463
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
464
|
-
if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
|
|
465
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
466
|
-
await new Promise(r => setTimeout(r, 200));
|
|
467
|
-
el.focus();
|
|
468
|
-
el.value = '';
|
|
469
|
-
el.value = ${JSON.stringify(text)};
|
|
470
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
471
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
472
|
-
return { typed: true, selector: ${JSON.stringify(selector)}, length: ${text.length} };
|
|
473
|
-
})()`
|
|
801
|
+
script: buildSelectorTypeScript({ selector, highlight: false, text }),
|
|
474
802
|
});
|
|
475
803
|
console.log(JSON.stringify(result, null, 2));
|
|
476
804
|
}
|
|
@@ -5,13 +5,81 @@
|
|
|
5
5
|
import { getDefaultProfile } from '../utils/config.mjs';
|
|
6
6
|
import { callAPI, ensureBrowserService, checkBrowserService } from '../utils/browser-service.mjs';
|
|
7
7
|
import { resolveProfileId } from '../utils/args.mjs';
|
|
8
|
-
import { acquireLock, getLockInfo, releaseLock,
|
|
8
|
+
import { acquireLock, getLockInfo, releaseLock, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
|
|
9
9
|
import {
|
|
10
10
|
getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
|
|
11
|
-
listRegisteredSessions,
|
|
11
|
+
listRegisteredSessions, updateSession
|
|
12
12
|
} from '../lifecycle/session-registry.mjs';
|
|
13
13
|
import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
14
14
|
|
|
15
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function computeIdleState(session, now = Date.now()) {
|
|
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
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const reg of registeredSessions) {
|
|
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
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
15
83
|
export async function handleCleanupCommand(args) {
|
|
16
84
|
const sub = args[1];
|
|
17
85
|
|
|
@@ -158,38 +226,7 @@ export async function handleSessionsCommand(args) {
|
|
|
158
226
|
} catch {}
|
|
159
227
|
}
|
|
160
228
|
|
|
161
|
-
|
|
162
|
-
const liveMap = new Map(liveSessions.map(s => [s.profileId, s]));
|
|
163
|
-
|
|
164
|
-
// Merge live and registered sessions
|
|
165
|
-
const merged = [];
|
|
166
|
-
|
|
167
|
-
// Add all live sessions
|
|
168
|
-
for (const live of liveSessions) {
|
|
169
|
-
const reg = getSessionInfo(live.profileId);
|
|
170
|
-
merged.push({
|
|
171
|
-
profileId: live.profileId,
|
|
172
|
-
sessionId: live.session_id || live.profileId,
|
|
173
|
-
url: live.current_url,
|
|
174
|
-
mode: live.mode,
|
|
175
|
-
live: true,
|
|
176
|
-
registered: !!reg,
|
|
177
|
-
registryStatus: reg?.status,
|
|
178
|
-
lastSeen: reg?.lastSeen,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Add registered sessions that are not live (orphaned)
|
|
183
|
-
for (const reg of registeredSessions) {
|
|
184
|
-
if (!liveMap.has(reg.profileId) && reg.status !== 'closed') {
|
|
185
|
-
merged.push({
|
|
186
|
-
...reg,
|
|
187
|
-
live: false,
|
|
188
|
-
orphaned: true,
|
|
189
|
-
needsRecovery: reg.status === 'active',
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
229
|
+
const merged = buildMergedSessionRows(liveSessions, registeredSessions);
|
|
193
230
|
|
|
194
231
|
console.log(JSON.stringify({
|
|
195
232
|
ok: true,
|
|
@@ -202,6 +239,10 @@ export async function handleSessionsCommand(args) {
|
|
|
202
239
|
}, null, 2));
|
|
203
240
|
}
|
|
204
241
|
|
|
242
|
+
export async function handleInstancesCommand(args) {
|
|
243
|
+
await handleSessionsCommand(args);
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
export async function handleRecoverCommand(args) {
|
|
206
247
|
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
207
248
|
if (!profileId) throw new Error('Usage: camo recover [profileId]');
|
|
@@ -33,7 +33,20 @@ export function buildSelectorClickScript({ selector, highlight }) {
|
|
|
33
33
|
}
|
|
34
34
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
35
35
|
await new Promise((r) => setTimeout(r, 150));
|
|
36
|
-
el
|
|
36
|
+
if (el instanceof HTMLElement) {
|
|
37
|
+
try { el.focus({ preventScroll: true }); } catch {}
|
|
38
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
39
|
+
try {
|
|
40
|
+
if (typeof PointerEvent === 'function') {
|
|
41
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { ...common, pointerType: 'mouse', button: 0 }));
|
|
42
|
+
el.dispatchEvent(new PointerEvent('pointerup', { ...common, pointerType: 'mouse', button: 0 }));
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
try { el.dispatchEvent(new MouseEvent('mousedown', { ...common, button: 0 })); } catch {}
|
|
46
|
+
try { el.dispatchEvent(new MouseEvent('mouseup', { ...common, button: 0 })); } catch {}
|
|
47
|
+
}
|
|
48
|
+
if (typeof el.click === 'function') el.click();
|
|
49
|
+
else el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, button: 0 }));
|
|
37
50
|
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
38
51
|
setTimeout(() => { el.style.outline = restoreOutline; }, 260);
|
|
39
52
|
}
|
|
@@ -56,9 +69,63 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
|
|
|
56
69
|
}
|
|
57
70
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
58
71
|
await new Promise((r) => setTimeout(r, 150));
|
|
59
|
-
el
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
if (el instanceof HTMLElement) {
|
|
73
|
+
try { el.focus({ preventScroll: true }); } catch {}
|
|
74
|
+
if (typeof el.click === 'function') el.click();
|
|
75
|
+
}
|
|
76
|
+
const value = ${textLiteral};
|
|
77
|
+
const fireInputEvent = (target, name, init) => {
|
|
78
|
+
try {
|
|
79
|
+
if (typeof InputEvent === 'function') {
|
|
80
|
+
target.dispatchEvent(new InputEvent(name, init));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
target.dispatchEvent(new Event(name, { bubbles: true, cancelable: init?.cancelable === true }));
|
|
85
|
+
};
|
|
86
|
+
const assignControlValue = (target, next) => {
|
|
87
|
+
if (target instanceof HTMLInputElement) {
|
|
88
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
89
|
+
if (setter) setter.call(target, next);
|
|
90
|
+
else target.value = next;
|
|
91
|
+
if (typeof target.setSelectionRange === 'function') {
|
|
92
|
+
const cursor = String(next).length;
|
|
93
|
+
try { target.setSelectionRange(cursor, cursor); } catch {}
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
98
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
99
|
+
if (setter) setter.call(target, next);
|
|
100
|
+
else target.value = next;
|
|
101
|
+
if (typeof target.setSelectionRange === 'function') {
|
|
102
|
+
const cursor = String(next).length;
|
|
103
|
+
try { target.setSelectionRange(cursor, cursor); } catch {}
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
const editableAssigned = assignControlValue(el, value);
|
|
110
|
+
if (!editableAssigned) {
|
|
111
|
+
if (el instanceof HTMLElement && el.isContentEditable) {
|
|
112
|
+
el.textContent = value;
|
|
113
|
+
} else {
|
|
114
|
+
throw new Error('Element not editable: ' + ${selectorLiteral});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
fireInputEvent(el, 'beforeinput', {
|
|
118
|
+
bubbles: true,
|
|
119
|
+
cancelable: true,
|
|
120
|
+
data: value,
|
|
121
|
+
inputType: 'insertText',
|
|
122
|
+
});
|
|
123
|
+
fireInputEvent(el, 'input', {
|
|
124
|
+
bubbles: true,
|
|
125
|
+
cancelable: false,
|
|
126
|
+
data: value,
|
|
127
|
+
inputType: 'insertText',
|
|
128
|
+
});
|
|
62
129
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
63
130
|
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
64
131
|
setTimeout(() => { el.style.outline = restoreOutline; }, 260);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import os from 'node:os';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
9
10
|
|
|
10
11
|
const SESSION_DIR = path.join(os.homedir(), '.webauto', 'sessions');
|
|
11
12
|
|
|
@@ -15,6 +16,25 @@ function ensureSessionDir() {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function normalizeAlias(value) {
|
|
20
|
+
const text = String(value || '').trim();
|
|
21
|
+
if (!text) return null;
|
|
22
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
|
|
23
|
+
throw new Error('Invalid alias. Use only letters, numbers, dot, underscore, dash.');
|
|
24
|
+
}
|
|
25
|
+
return text.slice(0, 64);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeTimeoutMs(value) {
|
|
29
|
+
const ms = Number(value);
|
|
30
|
+
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
31
|
+
return Math.floor(ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateInstanceId() {
|
|
35
|
+
return `inst_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
export function getSessionFile(profileId) {
|
|
19
39
|
return path.join(SESSION_DIR, `${profileId}.json`);
|
|
20
40
|
}
|
|
@@ -22,14 +42,24 @@ export function getSessionFile(profileId) {
|
|
|
22
42
|
export function registerSession(profileId, sessionInfo = {}) {
|
|
23
43
|
ensureSessionDir();
|
|
24
44
|
const sessionFile = getSessionFile(profileId);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const alias = normalizeAlias(sessionInfo.alias);
|
|
47
|
+
const idleTimeoutMs = normalizeTimeoutMs(sessionInfo.idleTimeoutMs);
|
|
48
|
+
const instanceId = String(sessionInfo.instanceId || sessionInfo.sessionId || generateInstanceId()).trim();
|
|
25
49
|
|
|
26
50
|
const sessionData = {
|
|
27
51
|
profileId,
|
|
28
52
|
pid: process.pid,
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
instanceId,
|
|
54
|
+
alias,
|
|
55
|
+
startTime: now,
|
|
56
|
+
lastSeen: now,
|
|
57
|
+
lastActivityAt: now,
|
|
31
58
|
status: 'active',
|
|
32
59
|
...sessionInfo,
|
|
60
|
+
instanceId,
|
|
61
|
+
alias,
|
|
62
|
+
idleTimeoutMs,
|
|
33
63
|
};
|
|
34
64
|
|
|
35
65
|
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
|
@@ -44,12 +74,30 @@ export function updateSession(profileId, updates = {}) {
|
|
|
44
74
|
|
|
45
75
|
try {
|
|
46
76
|
const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const mergedAlias = Object.prototype.hasOwnProperty.call(updates, 'alias')
|
|
79
|
+
? normalizeAlias(updates.alias)
|
|
80
|
+
: normalizeAlias(existing.alias);
|
|
81
|
+
const mergedIdleTimeoutMs = Object.prototype.hasOwnProperty.call(updates, 'idleTimeoutMs')
|
|
82
|
+
? normalizeTimeoutMs(updates.idleTimeoutMs)
|
|
83
|
+
: normalizeTimeoutMs(existing.idleTimeoutMs);
|
|
84
|
+
const touchActivity = updates.touchActivity === true;
|
|
85
|
+
const nextActivityAt = touchActivity
|
|
86
|
+
? now
|
|
87
|
+
: (Object.prototype.hasOwnProperty.call(updates, 'lastActivityAt')
|
|
88
|
+
? Number(updates.lastActivityAt) || now
|
|
89
|
+
: Number(existing.lastActivityAt) || now);
|
|
47
90
|
const updated = {
|
|
48
91
|
...existing,
|
|
49
92
|
...updates,
|
|
50
|
-
|
|
93
|
+
instanceId: String(updates.instanceId || existing.instanceId || updates.sessionId || existing.sessionId || generateInstanceId()).trim(),
|
|
94
|
+
alias: mergedAlias,
|
|
95
|
+
idleTimeoutMs: mergedIdleTimeoutMs,
|
|
96
|
+
lastSeen: now,
|
|
97
|
+
lastActivityAt: nextActivityAt,
|
|
51
98
|
profileId,
|
|
52
99
|
};
|
|
100
|
+
delete updated.touchActivity;
|
|
53
101
|
fs.writeFileSync(sessionFile, JSON.stringify(updated, null, 2));
|
|
54
102
|
return updated;
|
|
55
103
|
} catch {
|
|
@@ -93,6 +141,54 @@ export function listRegisteredSessions() {
|
|
|
93
141
|
return sessions;
|
|
94
142
|
}
|
|
95
143
|
|
|
144
|
+
export function findSessionByAlias(alias) {
|
|
145
|
+
const target = normalizeAlias(alias);
|
|
146
|
+
if (!target) return null;
|
|
147
|
+
return listRegisteredSessions().find((item) => normalizeAlias(item?.alias) === target) || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function findSessionByInstanceId(instanceId) {
|
|
151
|
+
const target = String(instanceId || '').trim();
|
|
152
|
+
if (!target) return null;
|
|
153
|
+
return listRegisteredSessions().find((item) => String(item?.instanceId || '').trim() === target) || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resolveSessionTarget(target) {
|
|
157
|
+
const value = String(target || '').trim();
|
|
158
|
+
if (!value) return null;
|
|
159
|
+
const sessions = listRegisteredSessions();
|
|
160
|
+
const byProfile = sessions.find((item) => String(item?.profileId || '').trim() === value);
|
|
161
|
+
if (byProfile) return { profileId: byProfile.profileId, reason: 'profile', session: byProfile };
|
|
162
|
+
const byInstanceId = sessions.find((item) => String(item?.instanceId || '').trim() === value);
|
|
163
|
+
if (byInstanceId) return { profileId: byInstanceId.profileId, reason: 'instanceId', session: byInstanceId };
|
|
164
|
+
const byAlias = sessions.find((item) => normalizeAlias(item?.alias) === normalizeAlias(value));
|
|
165
|
+
if (byAlias) return { profileId: byAlias.profileId, reason: 'alias', session: byAlias };
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function isSessionAliasTaken(alias, exceptProfileId = '') {
|
|
170
|
+
const target = normalizeAlias(alias);
|
|
171
|
+
if (!target) return false;
|
|
172
|
+
const except = String(exceptProfileId || '').trim();
|
|
173
|
+
return listRegisteredSessions().some((item) => {
|
|
174
|
+
if (!item) return false;
|
|
175
|
+
if (except && String(item.profileId || '').trim() === except) return false;
|
|
176
|
+
if (String(item.status || '').trim() !== 'active') return false;
|
|
177
|
+
return normalizeAlias(item.alias) === target;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function touchSessionActivity(profileId, updates = {}) {
|
|
182
|
+
const id = String(profileId || '').trim();
|
|
183
|
+
if (!id) return null;
|
|
184
|
+
const existing = getSessionInfo(id);
|
|
185
|
+
if (!existing) return null;
|
|
186
|
+
return updateSession(id, {
|
|
187
|
+
...updates,
|
|
188
|
+
touchActivity: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
96
192
|
export function markSessionReconnecting(profileId) {
|
|
97
193
|
return updateSession(profileId, { status: 'reconnecting', reconnectAttempt: Date.now() });
|
|
98
194
|
}
|
|
@@ -9,6 +9,7 @@ import { releaseLock } from './lock.mjs';
|
|
|
9
9
|
import { getSessionInfo, markSessionClosed } from './session-registry.mjs';
|
|
10
10
|
|
|
11
11
|
const WATCHDOG_DIR = path.join(os.homedir(), '.webauto', 'run', 'camo-watchdogs');
|
|
12
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
function ensureWatchdogDir() {
|
|
14
15
|
if (!fs.existsSync(WATCHDOG_DIR)) {
|
|
@@ -102,9 +103,11 @@ async function syncViewportWithWindow(profileId, tolerancePx = 3) {
|
|
|
102
103
|
return true;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
function resolveIdleTimeoutMs(sessionInfo) {
|
|
107
|
+
if (!sessionInfo || sessionInfo.headless !== true) return 0;
|
|
108
|
+
const raw = Number(sessionInfo.idleTimeoutMs);
|
|
109
|
+
if (Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
110
|
+
return DEFAULT_HEADLESS_IDLE_TIMEOUT_MS;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
async function cleanupSession(profileId) {
|
|
@@ -126,7 +129,18 @@ async function runMonitor(profileId, options = {}) {
|
|
|
126
129
|
let blankOnlyStreak = 0;
|
|
127
130
|
|
|
128
131
|
while (true) {
|
|
129
|
-
|
|
132
|
+
const localInfo = getSessionInfo(profileId);
|
|
133
|
+
if (!localInfo || localInfo.status !== 'active') return;
|
|
134
|
+
|
|
135
|
+
const idleTimeoutMs = resolveIdleTimeoutMs(localInfo);
|
|
136
|
+
if (idleTimeoutMs > 0) {
|
|
137
|
+
const lastActivityAt = Number(localInfo.lastActivityAt || localInfo.lastSeen || localInfo.startTime || Date.now());
|
|
138
|
+
const idleMs = Date.now() - lastActivityAt;
|
|
139
|
+
if (idleMs >= idleTimeoutMs) {
|
|
140
|
+
await cleanupSession(profileId);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
130
144
|
|
|
131
145
|
let sessions = [];
|
|
132
146
|
try {
|
|
@@ -4,6 +4,14 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
7
|
+
import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
|
|
8
|
+
|
|
9
|
+
function shouldTrackSessionActivity(action, payload) {
|
|
10
|
+
const profileId = String(payload?.profileId || '').trim();
|
|
11
|
+
if (!profileId) return false;
|
|
12
|
+
if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
export async function callAPI(action, payload = {}) {
|
|
9
17
|
const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
@@ -21,6 +29,12 @@ export async function callAPI(action, payload = {}) {
|
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
|
32
|
+
if (shouldTrackSessionActivity(action, payload)) {
|
|
33
|
+
touchSessionActivity(payload.profileId, {
|
|
34
|
+
lastAction: String(action || '').trim() || null,
|
|
35
|
+
lastActionAt: Date.now(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
24
38
|
return body;
|
|
25
39
|
}
|
|
26
40
|
|
package/src/utils/help.mjs
CHANGED
|
@@ -24,12 +24,17 @@ CONFIG:
|
|
|
24
24
|
|
|
25
25
|
BROWSER CONTROL:
|
|
26
26
|
init Ensure camoufox + ensure browser-service daemon
|
|
27
|
-
start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]
|
|
27
|
+
start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
|
|
28
28
|
stop [profileId]
|
|
29
|
+
stop --id <instanceId> Stop by instance id
|
|
30
|
+
stop --alias <alias> Stop by alias
|
|
31
|
+
stop idle Stop all idle sessions
|
|
32
|
+
stop all Stop all sessions
|
|
29
33
|
status [profileId]
|
|
30
34
|
list Alias of status
|
|
31
35
|
|
|
32
36
|
LIFECYCLE & CLEANUP:
|
|
37
|
+
instances List global camoufox instances (live + registered + idle state)
|
|
33
38
|
sessions List active browser sessions
|
|
34
39
|
cleanup [profileId] Cleanup session (release lock + stop)
|
|
35
40
|
cleanup all Cleanup all active sessions
|
|
@@ -90,8 +95,14 @@ EXAMPLES:
|
|
|
90
95
|
camo create fingerprint --os windows --region uk
|
|
91
96
|
camo profile create myprofile
|
|
92
97
|
camo profile default myprofile
|
|
93
|
-
camo start --url https://example.com
|
|
98
|
+
camo start --url https://example.com --alias main
|
|
99
|
+
camo start worker-1 --headless --alias shard1 --idle-timeout 45m
|
|
100
|
+
camo start worker-1 --devtools
|
|
94
101
|
camo start myprofile --width 1920 --height 1020
|
|
102
|
+
camo stop --id inst_xxxxxxxx
|
|
103
|
+
camo stop --alias shard1
|
|
104
|
+
camo stop idle
|
|
105
|
+
camo close all
|
|
95
106
|
camo goto https://www.xiaohongshu.com
|
|
96
107
|
camo scroll --down --amount 500
|
|
97
108
|
camo click "#search-input"
|
|
@@ -109,6 +120,7 @@ EXAMPLES:
|
|
|
109
120
|
camo mouse wheel --deltay -300
|
|
110
121
|
camo system display
|
|
111
122
|
camo sessions
|
|
123
|
+
camo instances
|
|
112
124
|
camo cleanup myprofile
|
|
113
125
|
camo force-stop myprofile
|
|
114
126
|
camo lock list
|