@web-auto/camo 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +1 -1
- package/src/cli.mjs +9 -0
- package/src/commands/browser.mjs +9 -7
- package/src/container/change-notifier.mjs +90 -39
- package/src/container/runtime-core/operations/index.mjs +108 -48
- package/src/container/runtime-core/operations/tab-pool.mjs +301 -99
- package/src/container/runtime-core/operations/tab-pool.mjs.bak +762 -0
- package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +762 -0
- package/src/container/runtime-core/operations/viewport.mjs +46 -0
- package/src/container/runtime-core/subscription.mjs +72 -7
- package/src/container/runtime-core/validation.mjs +61 -4
- package/src/core/utils.mjs +4 -0
- package/src/services/browser-service/index.js +27 -10
- package/src/services/browser-service/index.js.bak +671 -0
- package/src/services/browser-service/internal/BrowserSession.js +34 -2
- package/src/services/browser-service/internal/browser-session/input-ops.js +60 -0
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +3 -1
- package/src/services/browser-service/internal/browser-session/page-management.js +120 -58
- package/src/services/browser-service/internal/browser-session/page-management.test.js +43 -0
- package/src/services/browser-service/internal/browser-session/utils.js +6 -0
- package/src/services/controller/controller.js +1 -1
- package/src/services/controller/transport.js +8 -1
- package/src/utils/args.mjs +1 -0
- package/src/utils/browser-service.mjs +13 -1
- package/src/utils/command-log.mjs +64 -0
- package/src/utils/help.mjs +3 -3
|
@@ -37,6 +37,7 @@ export class BrowserSession {
|
|
|
37
37
|
exitNotified = false;
|
|
38
38
|
constructor(options) {
|
|
39
39
|
this.options = options;
|
|
40
|
+
this.maxTabs = Math.max(5, Math.floor(Number(options.maxTabs ?? 5) || 5));
|
|
40
41
|
const profileId = options.profileId || 'default';
|
|
41
42
|
const root = resolveProfilesRoot();
|
|
42
43
|
this.profileDir = path.join(root, profileId);
|
|
@@ -69,6 +70,7 @@ export class BrowserSession {
|
|
|
69
70
|
recordLastKnownUrl: (url) => { if (url)
|
|
70
71
|
this.lastKnownUrl = url; },
|
|
71
72
|
isHeadless: () => this.options.headless === true,
|
|
73
|
+
getMaxTabs: () => this.maxTabs,
|
|
72
74
|
});
|
|
73
75
|
this.navigation = new BrowserSessionNavigation({
|
|
74
76
|
ensurePrimaryPage: () => this.pageManager.ensurePrimaryPage(),
|
|
@@ -147,7 +149,10 @@ export class BrowserSession {
|
|
|
147
149
|
const existing = this.context.pages();
|
|
148
150
|
this.page = existing.length ? existing[0] : await this.context.newPage();
|
|
149
151
|
this.setupPageHooks(this.page);
|
|
150
|
-
this.context.on('page', (p) =>
|
|
152
|
+
this.context.on('page', (p) => {
|
|
153
|
+
this.setupPageHooks(p);
|
|
154
|
+
this.enforceMaxTabs();
|
|
155
|
+
});
|
|
151
156
|
if (this.viewportManager.isFollowingWindow()) {
|
|
152
157
|
await this.viewportManager.refreshFromWindow(this.page).catch(() => { });
|
|
153
158
|
}
|
|
@@ -158,6 +163,33 @@ export class BrowserSession {
|
|
|
158
163
|
setupPageHooks(page) {
|
|
159
164
|
this.pageHooks.setupPageHooks(page);
|
|
160
165
|
}
|
|
166
|
+
|
|
167
|
+
async enforceMaxTabs() {
|
|
168
|
+
try {
|
|
169
|
+
const ctx = this.context;
|
|
170
|
+
if (!ctx) return;
|
|
171
|
+
const pages = ctx.pages().filter((p) => !p.isClosed());
|
|
172
|
+
if (pages.length <= this.maxTabs) return;
|
|
173
|
+
const activePage = this.getActivePage();
|
|
174
|
+
const excess = pages.length - this.maxTabs;
|
|
175
|
+
const closable = pages
|
|
176
|
+
.filter((p) => p !== activePage)
|
|
177
|
+
.sort((a, b) => {
|
|
178
|
+
const aUrl = a.url() || '';
|
|
179
|
+
const bUrl = b.url() || '';
|
|
180
|
+
const aScore = (aUrl.includes('captcha') || aUrl === 'about:blank') ? 1 : 0;
|
|
181
|
+
const bScore = (bUrl.includes('captcha') || bUrl === 'about:blank') ? 1 : 0;
|
|
182
|
+
if (aScore !== bScore) return bScore - aScore;
|
|
183
|
+
return 0;
|
|
184
|
+
});
|
|
185
|
+
const toClose = closable.slice(0, Math.min(excess, closable.length));
|
|
186
|
+
for (const page of toClose) {
|
|
187
|
+
try {
|
|
188
|
+
await page.close({ runBeforeUnload: false });
|
|
189
|
+
} catch { /* ignore */ }
|
|
190
|
+
}
|
|
191
|
+
} catch { /* ignore enforcement errors */ }
|
|
192
|
+
}
|
|
161
193
|
addRuntimeEventObserver(observer) {
|
|
162
194
|
return this.runtimeEvents.addObserver(observer);
|
|
163
195
|
}
|
|
@@ -301,4 +333,4 @@ export class BrowserSession {
|
|
|
301
333
|
this.onExit?.(this.options.profileId);
|
|
302
334
|
}
|
|
303
335
|
}
|
|
304
|
-
//# sourceMappingURL=BrowserSession.js.map
|
|
336
|
+
//# sourceMappingURL=BrowserSession.js.map
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import { isTimeoutLikeError } from './utils.js';
|
|
2
|
+
import { resolveInputMode } from './utils.js';
|
|
3
|
+
|
|
4
|
+
async function createCDPSession(page) {
|
|
5
|
+
const context = page.context();
|
|
6
|
+
return context.newCDPSession(page);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function cdpMouseClick(cdp, x, y, button = 'left', delay = 50) {
|
|
10
|
+
const normalizedButton = button === 'left' ? 'left' : button === 'right' ? 'right' : button === 'middle' ? 'middle' : 'left';
|
|
11
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
12
|
+
type: 'mousePressed',
|
|
13
|
+
x: Math.round(x),
|
|
14
|
+
y: Math.round(y),
|
|
15
|
+
button: normalizedButton,
|
|
16
|
+
clickCount: 1
|
|
17
|
+
});
|
|
18
|
+
if (delay > 0) {
|
|
19
|
+
await new Promise(r => setTimeout(r, delay));
|
|
20
|
+
}
|
|
21
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
22
|
+
type: 'mouseReleased',
|
|
23
|
+
x: Math.round(x),
|
|
24
|
+
y: Math.round(y),
|
|
25
|
+
button: normalizedButton,
|
|
26
|
+
clickCount: 1
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cdpMouseMove(cdp, x, y) {
|
|
31
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
32
|
+
type: 'mouseMoved',
|
|
33
|
+
x: Math.round(x),
|
|
34
|
+
y: Math.round(y)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
2
37
|
|
|
3
38
|
async function readInteractiveViewport(page) {
|
|
4
39
|
const fallback = page.viewportSize?.() || null;
|
|
@@ -39,9 +74,34 @@ export class BrowserSessionInputOps {
|
|
|
39
74
|
this.withInputActionLock = withInputActionLock;
|
|
40
75
|
const envMode = String(process.env.CAMO_SCROLL_INPUT_MODE || '').trim().toLowerCase();
|
|
41
76
|
this.wheelMode = envMode === 'keyboard' ? 'keyboard' : 'wheel';
|
|
77
|
+
this.inputMode = resolveInputMode();
|
|
42
78
|
}
|
|
43
79
|
async mouseClick(opts) {
|
|
44
80
|
const page = await this.ensurePrimaryPage();
|
|
81
|
+
|
|
82
|
+
if (this.inputMode === 'cdp') {
|
|
83
|
+
const { x, y, button = 'left', clicks = 1, delay = 50 } = opts;
|
|
84
|
+
let cdp = null;
|
|
85
|
+
try {
|
|
86
|
+
cdp = await createCDPSession(page);
|
|
87
|
+
for (let i = 0; i < clicks; i++) {
|
|
88
|
+
if (i > 0) {
|
|
89
|
+
await new Promise(r => setTimeout(r, 100 + Math.random() * 100));
|
|
90
|
+
}
|
|
91
|
+
await this.withInputActionLock(async () => {
|
|
92
|
+
await this.runInputAction(page, 'mouse:click(cdp)', async () => {
|
|
93
|
+
await cdpMouseClick(cdp, x, y, button, delay);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
if (cdp) {
|
|
99
|
+
await cdp.detach().catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
45
105
|
await this.withInputActionLock(async () => {
|
|
46
106
|
await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
|
|
47
107
|
const { x, y, button = 'left', clicks = 1, delay = 50, nudgeBefore = false } = opts;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
|
|
1
|
+
import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputMode, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
|
|
2
2
|
import { ensurePageRuntime } from '../pageRuntime.js';
|
|
3
3
|
export class BrowserInputPipeline {
|
|
4
4
|
ensurePrimaryPage;
|
|
@@ -9,6 +9,8 @@ export class BrowserInputPipeline {
|
|
|
9
9
|
}
|
|
10
10
|
inputActionTail = Promise.resolve();
|
|
11
11
|
async ensureInputReady(page) {
|
|
12
|
+
if (resolveInputMode() === 'cdp')
|
|
13
|
+
return;
|
|
12
14
|
if (this.isHeadless())
|
|
13
15
|
return;
|
|
14
16
|
if (shouldSkipBringToFront()) {
|
|
@@ -3,14 +3,95 @@ import { ensurePageRuntime } from '../pageRuntime.js';
|
|
|
3
3
|
import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
|
|
4
4
|
export class BrowserSessionPageManagement {
|
|
5
5
|
deps;
|
|
6
|
+
trackedPages = [];
|
|
7
|
+
trackedPageListeners = new WeakSet();
|
|
8
|
+
trackedPageState = new WeakMap();
|
|
9
|
+
static NEW_PAGE_FORCE_ALIVE_MS = 15_000;
|
|
10
|
+
static ACTIVE_PAGE_FORCE_ALIVE_MS = 5_000;
|
|
6
11
|
constructor(deps) {
|
|
7
12
|
this.deps = deps;
|
|
8
13
|
}
|
|
14
|
+
safeIsClosed(page) {
|
|
15
|
+
if (!page)
|
|
16
|
+
return true;
|
|
17
|
+
try {
|
|
18
|
+
return page.isClosed();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
markTrackedPage(page, forceAliveMs = 0) {
|
|
25
|
+
if (!page)
|
|
26
|
+
return null;
|
|
27
|
+
const prev = this.trackedPageState.get(page);
|
|
28
|
+
const next = {
|
|
29
|
+
closed: false,
|
|
30
|
+
forceAliveUntil: Math.max(Number(prev?.forceAliveUntil || 0), forceAliveMs > 0 ? Date.now() + forceAliveMs : 0),
|
|
31
|
+
};
|
|
32
|
+
this.trackedPageState.set(page, next);
|
|
33
|
+
return page;
|
|
34
|
+
}
|
|
35
|
+
isTrackedPageAlive(page) {
|
|
36
|
+
if (!page)
|
|
37
|
+
return false;
|
|
38
|
+
const state = this.trackedPageState.get(page);
|
|
39
|
+
if (state?.closed === true)
|
|
40
|
+
return false;
|
|
41
|
+
if (!this.safeIsClosed(page))
|
|
42
|
+
return true;
|
|
43
|
+
return Number(state?.forceAliveUntil || 0) > Date.now();
|
|
44
|
+
}
|
|
45
|
+
rememberPage(page, options = {}) {
|
|
46
|
+
if (!page)
|
|
47
|
+
return null;
|
|
48
|
+
this.markTrackedPage(page, Math.max(0, Number(options.forceAliveMs || 0) || 0));
|
|
49
|
+
if (this.safeIsClosed(page) && !this.isTrackedPageAlive(page))
|
|
50
|
+
return null;
|
|
51
|
+
if (!this.trackedPages.includes(page)) {
|
|
52
|
+
this.trackedPages.push(page);
|
|
53
|
+
}
|
|
54
|
+
if (typeof page.on === 'function' && !this.trackedPageListeners.has(page)) {
|
|
55
|
+
page.on('close', () => {
|
|
56
|
+
this.trackedPageState.set(page, {
|
|
57
|
+
closed: true,
|
|
58
|
+
forceAliveUntil: 0,
|
|
59
|
+
});
|
|
60
|
+
this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
|
|
61
|
+
});
|
|
62
|
+
this.trackedPageListeners.add(page);
|
|
63
|
+
}
|
|
64
|
+
return page;
|
|
65
|
+
}
|
|
66
|
+
collectPages(ctx) {
|
|
67
|
+
const active = this.deps.getActivePage();
|
|
68
|
+
if (active) {
|
|
69
|
+
this.rememberPage(active, {
|
|
70
|
+
forceAliveMs: BrowserSessionPageManagement.ACTIVE_PAGE_FORCE_ALIVE_MS,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const merged = [...ctx.pages(), ...this.trackedPages, ...(active ? [active] : [])];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const pages = [];
|
|
76
|
+
for (const page of merged) {
|
|
77
|
+
const tracked = this.trackedPages.includes(page) || page === active;
|
|
78
|
+
const alive = tracked ? this.isTrackedPageAlive(page) : !this.safeIsClosed(page);
|
|
79
|
+
if (!page || !alive || seen.has(page))
|
|
80
|
+
continue;
|
|
81
|
+
seen.add(page);
|
|
82
|
+
pages.push(page);
|
|
83
|
+
this.rememberPage(page);
|
|
84
|
+
}
|
|
85
|
+
this.trackedPages = this.trackedPages.filter((page) => page && this.isTrackedPageAlive(page));
|
|
86
|
+
return pages;
|
|
87
|
+
}
|
|
9
88
|
async openPageViaContext(ctx, beforeCount) {
|
|
10
89
|
try {
|
|
11
|
-
const page = await ctx.newPage()
|
|
90
|
+
const page = this.rememberPage(await ctx.newPage(), {
|
|
91
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
92
|
+
});
|
|
12
93
|
await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
|
|
13
|
-
const after =
|
|
94
|
+
const after = this.collectPages(ctx).length;
|
|
14
95
|
if (after > beforeCount) {
|
|
15
96
|
return page;
|
|
16
97
|
}
|
|
@@ -24,8 +105,10 @@ export class BrowserSessionPageManagement {
|
|
|
24
105
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
25
106
|
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
26
107
|
await opener.keyboard.press(shortcut).catch(() => null);
|
|
27
|
-
const page = await waitPage
|
|
28
|
-
|
|
108
|
+
const page = this.rememberPage(await waitPage, {
|
|
109
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
110
|
+
});
|
|
111
|
+
const pagesNow = this.collectPages(ctx);
|
|
29
112
|
const after = pagesNow.length;
|
|
30
113
|
if (page && after > beforeCount)
|
|
31
114
|
return page;
|
|
@@ -54,6 +137,7 @@ export class BrowserSessionPageManagement {
|
|
|
54
137
|
const ctx = this.deps.ensureContext();
|
|
55
138
|
const existing = this.deps.getActivePage();
|
|
56
139
|
if (existing) {
|
|
140
|
+
this.rememberPage(existing);
|
|
57
141
|
try {
|
|
58
142
|
await this.deps.ensurePageViewport(existing);
|
|
59
143
|
}
|
|
@@ -62,7 +146,9 @@ export class BrowserSessionPageManagement {
|
|
|
62
146
|
}
|
|
63
147
|
return existing;
|
|
64
148
|
}
|
|
65
|
-
const page = await ctx.newPage()
|
|
149
|
+
const page = this.rememberPage(await ctx.newPage(), {
|
|
150
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
151
|
+
});
|
|
66
152
|
this.deps.setActivePage(page);
|
|
67
153
|
this.deps.setupPageHooks(page);
|
|
68
154
|
try {
|
|
@@ -88,14 +174,7 @@ export class BrowserSessionPageManagement {
|
|
|
88
174
|
}
|
|
89
175
|
listPages() {
|
|
90
176
|
const ctx = this.deps.ensureContext();
|
|
91
|
-
|
|
92
|
-
const pages = ctx.pages().filter((p) => {
|
|
93
|
-
if (p.isClosed()) return false;
|
|
94
|
-
const url = p.url();
|
|
95
|
-
// Filter out blank placeholder pages
|
|
96
|
-
if (url === 'about:newtab' || url === 'about:blank') return false;
|
|
97
|
-
return true;
|
|
98
|
-
});
|
|
177
|
+
const pages = this.collectPages(ctx);
|
|
99
178
|
const active = this.deps.getActivePage();
|
|
100
179
|
return pages.map((p, index) => ({
|
|
101
180
|
index,
|
|
@@ -103,9 +182,16 @@ export class BrowserSessionPageManagement {
|
|
|
103
182
|
active: active === p,
|
|
104
183
|
}));
|
|
105
184
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
185
|
+
async newPage(url, options = {}) {
|
|
186
|
+
const ctx = this.deps.ensureContext();
|
|
187
|
+
const maxTabs = typeof this.deps.getMaxTabs === 'function' ? this.deps.getMaxTabs() : null;
|
|
188
|
+
if (Number.isFinite(maxTabs) && maxTabs >= 1) {
|
|
189
|
+
const pages = this.collectPages(ctx);
|
|
190
|
+
if (pages.length >= maxTabs) {
|
|
191
|
+
throw new Error(`max_tabs_exceeded:${maxTabs}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const isMac = process.platform === 'darwin';
|
|
109
195
|
const shortcut = isMac ? 'Meta+t' : 'Control+t';
|
|
110
196
|
let page = null;
|
|
111
197
|
const opener = this.deps.getActivePage() || ctx.pages()[0];
|
|
@@ -114,21 +200,23 @@ export class BrowserSessionPageManagement {
|
|
|
114
200
|
if (!shouldSkipBringToFront()) {
|
|
115
201
|
await opener.bringToFront().catch(() => null);
|
|
116
202
|
}
|
|
117
|
-
const before =
|
|
203
|
+
const before = this.collectPages(ctx).length;
|
|
118
204
|
if (!options?.strictShortcut) {
|
|
119
205
|
page = await this.openPageViaContext(ctx, before);
|
|
120
206
|
}
|
|
121
207
|
if (!page) {
|
|
122
208
|
page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
|
|
123
209
|
}
|
|
124
|
-
let after =
|
|
210
|
+
let after = this.collectPages(ctx).length;
|
|
125
211
|
if (!page || after <= before) {
|
|
126
212
|
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
127
213
|
const osShortcutOk = this.tryOsNewTabShortcut();
|
|
128
214
|
if (osShortcutOk) {
|
|
129
|
-
page = await waitPage
|
|
215
|
+
page = this.rememberPage(await waitPage, {
|
|
216
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
217
|
+
});
|
|
130
218
|
}
|
|
131
|
-
const pagesNow =
|
|
219
|
+
const pagesNow = this.collectPages(ctx);
|
|
132
220
|
after = pagesNow.length;
|
|
133
221
|
if (!page && after > before) {
|
|
134
222
|
page = pagesNow[pagesNow.length - 1] || null;
|
|
@@ -137,9 +225,9 @@ export class BrowserSessionPageManagement {
|
|
|
137
225
|
if (!page || after <= before) {
|
|
138
226
|
if (!options?.strictShortcut) {
|
|
139
227
|
page = await this.openPageViaContext(ctx, before);
|
|
140
|
-
after =
|
|
228
|
+
after = this.collectPages(ctx).length;
|
|
141
229
|
if (!page && after > before) {
|
|
142
|
-
const pagesNow =
|
|
230
|
+
const pagesNow = this.collectPages(ctx);
|
|
143
231
|
page = pagesNow[pagesNow.length - 1] || null;
|
|
144
232
|
}
|
|
145
233
|
}
|
|
@@ -148,6 +236,9 @@ export class BrowserSessionPageManagement {
|
|
|
148
236
|
throw new Error('new_tab_failed');
|
|
149
237
|
}
|
|
150
238
|
this.deps.setupPageHooks(page);
|
|
239
|
+
this.rememberPage(page, {
|
|
240
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
241
|
+
});
|
|
151
242
|
this.deps.setActivePage(page);
|
|
152
243
|
try {
|
|
153
244
|
await this.deps.ensurePageViewport(page);
|
|
@@ -174,12 +265,12 @@ export class BrowserSessionPageManagement {
|
|
|
174
265
|
await ensurePageRuntime(page);
|
|
175
266
|
this.deps.recordLastKnownUrl(url);
|
|
176
267
|
}
|
|
177
|
-
const pages =
|
|
268
|
+
const pages = this.collectPages(ctx);
|
|
178
269
|
return { index: Math.max(0, pages.indexOf(page)), url: page.url() };
|
|
179
270
|
}
|
|
180
271
|
async switchPage(index) {
|
|
181
272
|
const ctx = this.deps.ensureContext();
|
|
182
|
-
const pages =
|
|
273
|
+
const pages = this.collectPages(ctx);
|
|
183
274
|
const idx = Number(index);
|
|
184
275
|
if (!Number.isFinite(idx) || idx < 0 || idx >= pages.length) {
|
|
185
276
|
throw new Error(`invalid_page_index: ${index}`);
|
|
@@ -206,7 +297,7 @@ export class BrowserSessionPageManagement {
|
|
|
206
297
|
}
|
|
207
298
|
async closePage(index) {
|
|
208
299
|
const ctx = this.deps.ensureContext();
|
|
209
|
-
const pages =
|
|
300
|
+
const pages = this.collectPages(ctx);
|
|
210
301
|
if (pages.length === 0) {
|
|
211
302
|
return { closedIndex: -1, activeIndex: -1, total: 0 };
|
|
212
303
|
}
|
|
@@ -217,39 +308,10 @@ export class BrowserSessionPageManagement {
|
|
|
217
308
|
throw new Error(`invalid_page_index: ${index}`);
|
|
218
309
|
}
|
|
219
310
|
const page = pages[closedIndex];
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
await page.close({ runBeforeUnload: false });
|
|
225
|
-
} catch (e) {
|
|
226
|
-
// Ignore close errors
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Wait for close to take effect
|
|
230
|
-
await new Promise(r => setTimeout(r, 100));
|
|
231
|
-
|
|
232
|
-
// Check if actually closed
|
|
233
|
-
let remaining = ctx.pages().filter((p) => !p.isClosed());
|
|
234
|
-
|
|
235
|
-
// If still same count, the page might not have closed properly
|
|
236
|
-
// Try navigating to about:blank first then close
|
|
237
|
-
if (remaining.length === pages.length) {
|
|
238
|
-
try {
|
|
239
|
-
await page.goto('about:blank', { timeout: 500 }).catch(() => {});
|
|
240
|
-
await page.close({ runBeforeUnload: false }).catch(() => {});
|
|
241
|
-
await new Promise(r => setTimeout(r, 100));
|
|
242
|
-
remaining = ctx.pages().filter((p) => !p.isClosed());
|
|
243
|
-
} catch (e) {
|
|
244
|
-
// Ignore
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Final check - filter out pages that look like closed tabs (about:newtab)
|
|
249
|
-
remaining = remaining.filter(p => {
|
|
250
|
-
const url = p.url();
|
|
251
|
-
return url !== 'about:newtab' && url !== 'about:blank';
|
|
252
|
-
});
|
|
311
|
+
this.trackedPageState.set(page, { closed: true, forceAliveUntil: 0 });
|
|
312
|
+
await page.close().catch(() => { });
|
|
313
|
+
this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
|
|
314
|
+
const remaining = this.collectPages(ctx);
|
|
253
315
|
const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
|
|
254
316
|
if (nextIndex >= 0) {
|
|
255
317
|
const nextPage = remaining[nextIndex];
|
|
@@ -103,3 +103,46 @@ test('newPage falls back to shortcut path in strictShortcut mode', async () => {
|
|
|
103
103
|
assert.equal(result.index, 1);
|
|
104
104
|
assert.equal(getActivePage(), created);
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
test('listPages keeps track of newly created pages even when context.pages stays stale', async () => {
|
|
108
|
+
const opener = createPage('opener');
|
|
109
|
+
const created = createPage('created');
|
|
110
|
+
const pages = [opener];
|
|
111
|
+
const { management, getActivePage } = createManagement({
|
|
112
|
+
pages,
|
|
113
|
+
activePage: opener,
|
|
114
|
+
ctxNewPage: async () => created,
|
|
115
|
+
waitForEvent: async () => null,
|
|
116
|
+
});
|
|
117
|
+
const result = await management.newPage();
|
|
118
|
+
assert.equal(result.index, 1);
|
|
119
|
+
assert.equal(result.url, 'https://example.com/created');
|
|
120
|
+
assert.equal(getActivePage(), created);
|
|
121
|
+
const listed = management.listPages();
|
|
122
|
+
assert.deepEqual(listed, [
|
|
123
|
+
{ index: 0, url: 'https://example.com/opener', active: false },
|
|
124
|
+
{ index: 1, url: 'https://example.com/created', active: true },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('listPages keeps a just-created page visible briefly even if page reports closed immediately', async () => {
|
|
129
|
+
const opener = createPage('opener');
|
|
130
|
+
const created = createPage('created');
|
|
131
|
+
const pages = [opener];
|
|
132
|
+
const { management } = createManagement({
|
|
133
|
+
pages,
|
|
134
|
+
activePage: opener,
|
|
135
|
+
ctxNewPage: async () => {
|
|
136
|
+
created.closed = true;
|
|
137
|
+
return created;
|
|
138
|
+
},
|
|
139
|
+
waitForEvent: async () => null,
|
|
140
|
+
});
|
|
141
|
+
const result = await management.newPage();
|
|
142
|
+
assert.equal(result.index, 1);
|
|
143
|
+
const listed = management.listPages();
|
|
144
|
+
assert.deepEqual(listed, [
|
|
145
|
+
{ index: 0, url: 'https://example.com/opener', active: false },
|
|
146
|
+
{ index: 1, url: 'https://example.com/created', active: true },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
@@ -46,6 +46,12 @@ export function isTimeoutLikeError(error) {
|
|
|
46
46
|
const message = String(error?.message || error || '').toLowerCase();
|
|
47
47
|
return message.includes('timed out') || message.includes('timeout');
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export function resolveInputMode() {
|
|
51
|
+
const raw = String(process.env.CAMO_INPUT_MODE ?? '').trim().toLowerCase();
|
|
52
|
+
return raw === 'cdp' ? 'cdp' : 'playwright';
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
export function normalizeUrl(raw) {
|
|
50
56
|
try {
|
|
51
57
|
const url = new URL(raw);
|
|
@@ -224,7 +224,7 @@ export class UiController {
|
|
|
224
224
|
}
|
|
225
225
|
const args = ['create', '--profile', payload.profile];
|
|
226
226
|
if (payload.url) args.push('--url', payload.url);
|
|
227
|
-
if (payload.headless
|
|
227
|
+
if (payload.headless === false) args.push('--no-headless');
|
|
228
228
|
if (payload.keepOpen !== undefined) args.push('--keep-open', String(payload.keepOpen));
|
|
229
229
|
return this.runCliCommand('session-manager', args);
|
|
230
230
|
}
|
|
@@ -21,11 +21,18 @@ export function createTransport({ env = process.env, defaults = {}, debugLog = n
|
|
|
21
21
|
? options.timeoutMs
|
|
22
22
|
: 20000;
|
|
23
23
|
const profileId = (args?.profileId || args?.profile || args?.sessionId || '').toString();
|
|
24
|
+
const senderMeta = {
|
|
25
|
+
source: String(options?.source || env.CAMO_COMMAND_SOURCE || 'controller').trim() || 'controller',
|
|
26
|
+
cwd: String(options?.cwd || env.CAMO_COMMAND_CWD || process.cwd()).trim() || process.cwd(),
|
|
27
|
+
pid: Number(options?.pid || env.CAMO_COMMAND_PID || process.pid) || process.pid,
|
|
28
|
+
ppid: Number(options?.ppid || env.CAMO_COMMAND_PPID || process.ppid) || process.ppid,
|
|
29
|
+
argv: Array.isArray(options?.argv) ? options.argv.map((item) => String(item)) : process.argv.slice(),
|
|
30
|
+
};
|
|
24
31
|
debugLog?.('browserServiceCommand:start', { action, profileId, timeoutMs });
|
|
25
32
|
const res = await fetch(`${getBrowserHttpBase()}/command`, {
|
|
26
33
|
method: 'POST',
|
|
27
34
|
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
body: JSON.stringify({ action, args }),
|
|
35
|
+
body: JSON.stringify({ action, args, meta: { sender: senderMeta } }),
|
|
29
36
|
signal: AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
30
37
|
});
|
|
31
38
|
|
package/src/utils/args.mjs
CHANGED
|
@@ -10,6 +10,7 @@ export function ensureUrlScheme(rawUrl) {
|
|
|
10
10
|
if (typeof rawUrl !== 'string') return rawUrl;
|
|
11
11
|
const trimmed = rawUrl.trim();
|
|
12
12
|
if (!trimmed) return trimmed;
|
|
13
|
+
if (/^(about|chrome|file|data|blob|javascript):/i.test(trimmed)) return trimmed;
|
|
13
14
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed;
|
|
14
15
|
return `https://${trimmed}`;
|
|
15
16
|
}
|
|
@@ -8,6 +8,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
10
|
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
11
|
+
import { buildCommandSenderMeta } from './command-log.mjs';
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
@@ -117,12 +118,23 @@ function shouldTrackSessionActivity(action, payload) {
|
|
|
117
118
|
|
|
118
119
|
export async function callAPI(action, payload = {}, options = {}) {
|
|
119
120
|
const timeoutMs = resolveApiTimeoutMs(options);
|
|
121
|
+
const senderMeta = buildCommandSenderMeta({
|
|
122
|
+
source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
|
|
123
|
+
cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
|
|
124
|
+
pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
|
|
125
|
+
ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
|
|
126
|
+
argv: Array.isArray(options?.argv)
|
|
127
|
+
? options.argv
|
|
128
|
+
: Array.isArray(payload?.__commandArgv)
|
|
129
|
+
? payload.__commandArgv
|
|
130
|
+
: process.argv.slice(),
|
|
131
|
+
});
|
|
120
132
|
let r;
|
|
121
133
|
try {
|
|
122
134
|
r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
123
135
|
method: 'POST',
|
|
124
136
|
headers: { 'Content-Type': 'application/json' },
|
|
125
|
-
body: JSON.stringify({ action, args: payload }),
|
|
137
|
+
body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
|
|
126
138
|
signal: AbortSignal.timeout(timeoutMs),
|
|
127
139
|
});
|
|
128
140
|
} catch (error) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_DIR, ensureDir } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
export const COMMAND_LOG_DIR = path.join(CONFIG_DIR, 'logs');
|
|
6
|
+
export const COMMAND_LOG_FILE = path.join(COMMAND_LOG_DIR, 'command-log.jsonl');
|
|
7
|
+
|
|
8
|
+
function safeSerialize(value) {
|
|
9
|
+
if (value === undefined) return undefined;
|
|
10
|
+
if (value === null) return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(JSON.stringify(value));
|
|
13
|
+
} catch {
|
|
14
|
+
return { text: String(value) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeMeta(meta = {}) {
|
|
19
|
+
const sender = meta?.sender && typeof meta.sender === 'object' ? meta.sender : {};
|
|
20
|
+
return {
|
|
21
|
+
source: String(meta?.source || '').trim() || 'unknown',
|
|
22
|
+
cwd: String(meta?.cwd || sender?.cwd || '').trim() || process.cwd(),
|
|
23
|
+
pid: Number(meta?.pid || sender?.pid || process.pid) || process.pid,
|
|
24
|
+
ppid: Number(meta?.ppid || sender?.ppid || process.ppid) || process.ppid,
|
|
25
|
+
argv: Array.isArray(meta?.argv) ? meta.argv.map((item) => String(item)) : undefined,
|
|
26
|
+
sender: {
|
|
27
|
+
source: String(sender?.source || meta?.source || '').trim() || 'unknown',
|
|
28
|
+
cwd: String(sender?.cwd || meta?.cwd || '').trim() || process.cwd(),
|
|
29
|
+
pid: Number(sender?.pid || meta?.pid || process.pid) || process.pid,
|
|
30
|
+
ppid: Number(sender?.ppid || meta?.ppid || process.ppid) || process.ppid,
|
|
31
|
+
argv: Array.isArray(sender?.argv) ? sender.argv.map((item) => String(item)) : undefined,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function appendCommandLog(entry = {}) {
|
|
37
|
+
try {
|
|
38
|
+
ensureDir(COMMAND_LOG_DIR);
|
|
39
|
+
const meta = normalizeMeta(entry?.meta || {});
|
|
40
|
+
const line = {
|
|
41
|
+
ts: new Date().toISOString(),
|
|
42
|
+
action: String(entry?.action || '').trim() || null,
|
|
43
|
+
profileId: String(entry?.profileId || '').trim() || null,
|
|
44
|
+
command: String(entry?.command || '').trim() || null,
|
|
45
|
+
args: Array.isArray(entry?.args) ? entry.args.map((item) => String(item)) : undefined,
|
|
46
|
+
payload: safeSerialize(entry?.payload),
|
|
47
|
+
meta,
|
|
48
|
+
};
|
|
49
|
+
fs.appendFileSync(COMMAND_LOG_FILE, `${JSON.stringify(line)}\n`, 'utf8');
|
|
50
|
+
return line;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildCommandSenderMeta(overrides = {}) {
|
|
57
|
+
return {
|
|
58
|
+
source: String(overrides?.source || '').trim() || 'unknown',
|
|
59
|
+
cwd: String(overrides?.cwd || '').trim() || process.cwd(),
|
|
60
|
+
pid: Number(overrides?.pid || process.pid) || process.pid,
|
|
61
|
+
ppid: Number(overrides?.ppid || process.ppid) || process.ppid,
|
|
62
|
+
argv: Array.isArray(overrides?.argv) ? overrides.argv.map((item) => String(item)) : process.argv.slice(),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/utils/help.mjs
CHANGED
|
@@ -26,7 +26,7 @@ CONFIG:
|
|
|
26
26
|
|
|
27
27
|
BROWSER CONTROL:
|
|
28
28
|
init Ensure camoufox + ensure browser-service daemon
|
|
29
|
-
start [profileId] [--url <url>] [--headless] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
|
|
29
|
+
start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h> --max-tabs <n>]
|
|
30
30
|
stop [profileId]
|
|
31
31
|
stop --id <instanceId> Stop by instance id
|
|
32
32
|
stop --alias <alias> Stop by alias
|
|
@@ -108,8 +108,8 @@ EXAMPLES:
|
|
|
108
108
|
camo profile create myprofile
|
|
109
109
|
camo profile default myprofile
|
|
110
110
|
camo start --url https://example.com --alias main
|
|
111
|
-
camo start worker-1 --
|
|
112
|
-
camo start worker-1 --devtools
|
|
111
|
+
camo start worker-1 --alias shard1 --idle-timeout 45m
|
|
112
|
+
camo start worker-1 --visible --devtools
|
|
113
113
|
camo start worker-1 --record --record-name xhs-debug --record-output ./logs/xhs-debug.jsonl --record-overlay
|
|
114
114
|
camo start myprofile --width 1920 --height 1020
|
|
115
115
|
camo highlight-mode on
|