@poncho-ai/browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +14 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +687 -0
- package/package.json +44 -0
- package/src/index.ts +12 -0
- package/src/session.ts +595 -0
- package/src/tools.ts +167 -0
- package/src/types.ts +60 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@poncho-ai/browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser automation for Poncho agents, powered by agent-browser",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/cesr/poncho-ai.git",
|
|
8
|
+
"directory": "packages/browser"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"agent-browser": "^0.15.1",
|
|
24
|
+
"@poncho-ai/sdk": "1.0.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"tsup": "^8.0.0",
|
|
28
|
+
"vitest": "^1.4.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"ai",
|
|
32
|
+
"agent",
|
|
33
|
+
"browser",
|
|
34
|
+
"automation"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
39
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
40
|
+
"postinstall": "node -e \"if(!process.env.CI&&!process.env.SERVERLESS)require('child_process').execSync('npx playwright install chromium',{stdio:'inherit'})\"",
|
|
41
|
+
"test": "vitest --passWithNoTests",
|
|
42
|
+
"lint": "eslint src/"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { BrowserSession } from "./session.js";
|
|
2
|
+
export { createBrowserTools } from "./tools.js";
|
|
3
|
+
export type {
|
|
4
|
+
BrowserConfig,
|
|
5
|
+
BrowserFrame,
|
|
6
|
+
BrowserStatus,
|
|
7
|
+
ViewportOptions,
|
|
8
|
+
ScreencastOptions,
|
|
9
|
+
MouseInputEvent,
|
|
10
|
+
KeyboardInputEvent,
|
|
11
|
+
ScrollInputEvent,
|
|
12
|
+
} from "./types.js";
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import type {
|
|
5
|
+
BrowserConfig,
|
|
6
|
+
BrowserFrame,
|
|
7
|
+
BrowserStatus,
|
|
8
|
+
ScreencastOptions,
|
|
9
|
+
MouseInputEvent,
|
|
10
|
+
KeyboardInputEvent,
|
|
11
|
+
ScrollInputEvent,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
type FrameListener = (frame: BrowserFrame) => void;
|
|
15
|
+
type StatusListener = (status: BrowserStatus) => void;
|
|
16
|
+
|
|
17
|
+
let BrowserManagerCtor: (new () => BrowserManagerInstance) | undefined;
|
|
18
|
+
|
|
19
|
+
interface BrowserManagerInstance {
|
|
20
|
+
isLaunched(): boolean;
|
|
21
|
+
launch(options: Record<string, unknown>): Promise<void>;
|
|
22
|
+
getPage(): { url(): string; title(): Promise<string>; screenshot(opts?: Record<string, unknown>): Promise<Buffer>; goBack(): Promise<unknown>; goForward(): Promise<unknown> };
|
|
23
|
+
getSnapshot(options?: { interactive?: boolean; compact?: boolean }): Promise<{ tree: string; refs: Record<string, unknown> }>;
|
|
24
|
+
getLocatorFromRef(ref: string): { click(): Promise<void> } | null;
|
|
25
|
+
getLocator(selector: string): { fill(text: string): Promise<void>; click(): Promise<void> };
|
|
26
|
+
newTab(): Promise<void>;
|
|
27
|
+
switchTo(index: number): Promise<{ index: number; url: string }>;
|
|
28
|
+
closeTab(index?: number): Promise<{ closed: number; remaining: number }>;
|
|
29
|
+
listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>>;
|
|
30
|
+
getActiveIndex(): number;
|
|
31
|
+
startScreencast(
|
|
32
|
+
callback: (frame: { data: string; metadata: Record<string, number>; sessionId: number }) => void,
|
|
33
|
+
options?: Record<string, unknown>,
|
|
34
|
+
): Promise<void>;
|
|
35
|
+
stopScreencast(): Promise<void>;
|
|
36
|
+
isScreencasting(): boolean;
|
|
37
|
+
injectMouseEvent(params: Record<string, unknown>): Promise<void>;
|
|
38
|
+
injectKeyboardEvent(params: Record<string, unknown>): Promise<void>;
|
|
39
|
+
getCDPSession(): Promise<{ send(method: string, params?: Record<string, unknown>): Promise<unknown> }>;
|
|
40
|
+
saveStorageState(path: string): Promise<void>;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
setViewport(width: number, height: number): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function getBrowserManagerCtor(): Promise<new () => BrowserManagerInstance> {
|
|
46
|
+
if (!BrowserManagerCtor) {
|
|
47
|
+
const mod = await import("agent-browser/dist/browser.js");
|
|
48
|
+
BrowserManagerCtor = mod.BrowserManager as unknown as new () => BrowserManagerInstance;
|
|
49
|
+
}
|
|
50
|
+
return BrowserManagerCtor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_TABS = 8;
|
|
54
|
+
|
|
55
|
+
// Per-conversation tab state
|
|
56
|
+
interface ConversationTab {
|
|
57
|
+
tabIndex: number;
|
|
58
|
+
url?: string;
|
|
59
|
+
active: boolean;
|
|
60
|
+
lastUsed: number;
|
|
61
|
+
frameListeners: Set<FrameListener>;
|
|
62
|
+
statusListeners: Set<StatusListener>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class BrowserSession {
|
|
66
|
+
private readonly config: BrowserConfig;
|
|
67
|
+
private readonly sessionId: string;
|
|
68
|
+
private manager: BrowserManagerInstance | undefined;
|
|
69
|
+
|
|
70
|
+
// Tab management: conversationId → tab state
|
|
71
|
+
private readonly tabs = new Map<string, ConversationTab>();
|
|
72
|
+
|
|
73
|
+
// Serialization lock for tab-switching operations
|
|
74
|
+
private _lockQueue: Array<() => void> = [];
|
|
75
|
+
private _locked = false;
|
|
76
|
+
|
|
77
|
+
// Currently screencast conversation (only one at a time due to CDP)
|
|
78
|
+
private _screencastConversation: string | undefined;
|
|
79
|
+
|
|
80
|
+
constructor(sessionId: string, config: BrowserConfig = {}) {
|
|
81
|
+
this.sessionId = sessionId;
|
|
82
|
+
this.config = config;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get profileDir(): string {
|
|
86
|
+
return this.config.profileDir
|
|
87
|
+
?? resolve(homedir(), ".poncho", "browser-profiles", this.sessionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
// Lock for serializing tab-switching operations
|
|
92
|
+
// -----------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
private async lock(): Promise<void> {
|
|
95
|
+
if (!this._locked) {
|
|
96
|
+
this._locked = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
return new Promise<void>((resolve, reject) => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
const idx = this._lockQueue.indexOf(resolve);
|
|
102
|
+
if (idx !== -1) this._lockQueue.splice(idx, 1);
|
|
103
|
+
reject(new Error("Browser operation timed out waiting for lock (30s)"));
|
|
104
|
+
}, 30_000);
|
|
105
|
+
this._lockQueue.push(() => { clearTimeout(timer); resolve(); });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private unlock(): void {
|
|
110
|
+
const next = this._lockQueue.shift();
|
|
111
|
+
if (next) next();
|
|
112
|
+
else this._locked = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
// Core browser + tab management
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
private async launchFreshManager(): Promise<BrowserManagerInstance> {
|
|
120
|
+
const Ctor = await getBrowserManagerCtor();
|
|
121
|
+
const mgr = new Ctor();
|
|
122
|
+
|
|
123
|
+
const viewport = this.config.viewport ?? { width: 1280, height: 720 };
|
|
124
|
+
await mkdir(this.profileDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
await mgr.launch({
|
|
127
|
+
action: "launch",
|
|
128
|
+
headless: this.config.headless ?? true,
|
|
129
|
+
viewport: { width: viewport.width ?? 1280, height: viewport.height ?? 720 },
|
|
130
|
+
executablePath: this.config.executablePath,
|
|
131
|
+
profile: this.profileDir,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const cdp = await mgr.getCDPSession();
|
|
136
|
+
await cdp.send("Debugger.disable");
|
|
137
|
+
} catch { /* best-effort */ }
|
|
138
|
+
|
|
139
|
+
this.manager = mgr;
|
|
140
|
+
return mgr;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async ensureManager(): Promise<BrowserManagerInstance> {
|
|
144
|
+
if (this.manager) {
|
|
145
|
+
try {
|
|
146
|
+
if (this.manager.isLaunched()) return this.manager;
|
|
147
|
+
} catch { /* stale manager */ }
|
|
148
|
+
// Manager exists but is dead/stale -- discard it
|
|
149
|
+
try { await this.manager.close(); } catch { /* */ }
|
|
150
|
+
this.manager = undefined;
|
|
151
|
+
// Clear tab state since they belonged to the dead browser
|
|
152
|
+
for (const [cid, tab] of this.tabs) {
|
|
153
|
+
if (tab.tabIndex >= 0) {
|
|
154
|
+
tab.tabIndex = -1;
|
|
155
|
+
tab.active = false;
|
|
156
|
+
tab.url = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return this.launchFreshManager();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async evictOldestTab(mgr: BrowserManagerInstance): Promise<void> {
|
|
165
|
+
let oldest: { cid: string; tab: ConversationTab } | undefined;
|
|
166
|
+
for (const [cid, tab] of this.tabs) {
|
|
167
|
+
if (tab.tabIndex < 0) continue;
|
|
168
|
+
if (!oldest || tab.lastUsed < oldest.tab.lastUsed) {
|
|
169
|
+
oldest = { cid, tab };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!oldest) return;
|
|
173
|
+
console.log(`[poncho][browser] Evicting idle tab for conversation ${oldest.cid.slice(0, 8)}...`);
|
|
174
|
+
if (this._screencastConversation === oldest.cid) {
|
|
175
|
+
try { await mgr.stopScreencast(); } catch { /* */ }
|
|
176
|
+
this._screencastConversation = undefined;
|
|
177
|
+
}
|
|
178
|
+
if (this.tabs.size > 1) {
|
|
179
|
+
try { await mgr.closeTab(oldest.tab.tabIndex); } catch { /* */ }
|
|
180
|
+
for (const [, t] of this.tabs) {
|
|
181
|
+
if (t.tabIndex > oldest.tab.tabIndex) t.tabIndex--;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
oldest.tab.active = false;
|
|
185
|
+
oldest.tab.url = undefined;
|
|
186
|
+
this.emitStatus(oldest.cid);
|
|
187
|
+
this.tabs.delete(oldest.cid);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Reconcile tab indices with the manager's actual page list. */
|
|
191
|
+
private async reconcileTabs(mgr: BrowserManagerInstance): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const managerTabs = await mgr.listTabs();
|
|
194
|
+
const managerUrls = managerTabs.map((t) => t.url);
|
|
195
|
+
for (const [cid, tab] of this.tabs) {
|
|
196
|
+
if (tab.tabIndex >= managerUrls.length) {
|
|
197
|
+
tab.active = false;
|
|
198
|
+
tab.url = undefined;
|
|
199
|
+
this.emitStatus(cid);
|
|
200
|
+
this.tabs.delete(cid);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch { /* best-effort */ }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private realTabCount(): number {
|
|
207
|
+
let n = 0;
|
|
208
|
+
for (const t of this.tabs.values()) { if (t.tabIndex >= 0) n++; }
|
|
209
|
+
return n;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async switchToConversation(mgr: BrowserManagerInstance, conversationId: string): Promise<ConversationTab> {
|
|
213
|
+
let tab = this.tabs.get(conversationId);
|
|
214
|
+
if (!tab || tab.tabIndex < 0) {
|
|
215
|
+
const realTabs = this.realTabCount();
|
|
216
|
+
if (realTabs >= MAX_TABS) {
|
|
217
|
+
await this.evictOldestTab(mgr);
|
|
218
|
+
}
|
|
219
|
+
if (realTabs > 0) {
|
|
220
|
+
await mgr.newTab();
|
|
221
|
+
}
|
|
222
|
+
const existing = tab;
|
|
223
|
+
tab = {
|
|
224
|
+
tabIndex: mgr.getActiveIndex(),
|
|
225
|
+
active: true,
|
|
226
|
+
lastUsed: Date.now(),
|
|
227
|
+
frameListeners: existing?.frameListeners ?? new Set(),
|
|
228
|
+
statusListeners: existing?.statusListeners ?? new Set(),
|
|
229
|
+
};
|
|
230
|
+
this.tabs.set(conversationId, tab);
|
|
231
|
+
} else {
|
|
232
|
+
if (mgr.getActiveIndex() !== tab.tabIndex) {
|
|
233
|
+
await mgr.switchTo(tab.tabIndex);
|
|
234
|
+
}
|
|
235
|
+
tab.lastUsed = Date.now();
|
|
236
|
+
}
|
|
237
|
+
return tab;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Check if a conversation has an active browser tab. */
|
|
241
|
+
isActiveFor(conversationId: string): boolean {
|
|
242
|
+
return this.tabs.has(conversationId) && (this.tabs.get(conversationId)!.active);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Get the current URL for a conversation's tab. */
|
|
246
|
+
getUrl(conversationId: string): string | undefined {
|
|
247
|
+
return this.tabs.get(conversationId)?.url;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the browser has been launched. */
|
|
251
|
+
get isLaunched(): boolean {
|
|
252
|
+
return !!this.manager?.isLaunched();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
// Browser operations (all scoped by conversationId)
|
|
257
|
+
// -----------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async open(conversationId: string, url: string): Promise<{ title?: string }> {
|
|
260
|
+
await this.lock();
|
|
261
|
+
try {
|
|
262
|
+
return await this._doOpen(conversationId, url);
|
|
263
|
+
} catch (err: unknown) {
|
|
264
|
+
const msg = (err as Error)?.message ?? "";
|
|
265
|
+
if (msg.includes("not launched") || msg.includes("closed") || msg.includes("Target closed")) {
|
|
266
|
+
console.log("[poncho][browser] Browser died mid-open, relaunching...");
|
|
267
|
+
try { await this.manager?.close(); } catch { /* */ }
|
|
268
|
+
this.manager = undefined;
|
|
269
|
+
for (const [, t] of this.tabs) {
|
|
270
|
+
if (t.tabIndex >= 0) { t.tabIndex = -1; t.active = false; t.url = undefined; }
|
|
271
|
+
}
|
|
272
|
+
return await this._doOpen(conversationId, url);
|
|
273
|
+
}
|
|
274
|
+
throw err;
|
|
275
|
+
} finally {
|
|
276
|
+
this.unlock();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async _doOpen(conversationId: string, url: string): Promise<{ title?: string }> {
|
|
281
|
+
const mgr = await this.ensureManager();
|
|
282
|
+
const tab = await this.switchToConversation(mgr, conversationId);
|
|
283
|
+
const page = mgr.getPage();
|
|
284
|
+
|
|
285
|
+
await (page as unknown as { goto(url: string, opts?: Record<string, unknown>): Promise<unknown> })
|
|
286
|
+
.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
287
|
+
|
|
288
|
+
tab.url = page.url();
|
|
289
|
+
tab.active = true;
|
|
290
|
+
this.emitStatus(conversationId);
|
|
291
|
+
|
|
292
|
+
const title = await page.title();
|
|
293
|
+
return { title: title || undefined };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async snapshot(conversationId: string): Promise<string> {
|
|
297
|
+
await this.lock();
|
|
298
|
+
try {
|
|
299
|
+
const mgr = await this.ensureManager();
|
|
300
|
+
await this.switchToConversation(mgr, conversationId);
|
|
301
|
+
const snap = await mgr.getSnapshot({ interactive: true, compact: true });
|
|
302
|
+
return snap.tree;
|
|
303
|
+
} finally {
|
|
304
|
+
this.unlock();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async click(conversationId: string, ref: string): Promise<void> {
|
|
309
|
+
await this.lock();
|
|
310
|
+
try {
|
|
311
|
+
const mgr = await this.ensureManager();
|
|
312
|
+
const tab = await this.switchToConversation(mgr, conversationId);
|
|
313
|
+
const locator = mgr.getLocatorFromRef(ref);
|
|
314
|
+
if (!locator) throw new Error(`No element found for ref ${ref}`);
|
|
315
|
+
await locator.click();
|
|
316
|
+
tab.url = mgr.getPage().url();
|
|
317
|
+
} finally {
|
|
318
|
+
this.unlock();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async type(conversationId: string, ref: string, text: string): Promise<void> {
|
|
323
|
+
await this.lock();
|
|
324
|
+
try {
|
|
325
|
+
const mgr = await this.ensureManager();
|
|
326
|
+
const tab = await this.switchToConversation(mgr, conversationId);
|
|
327
|
+
const locator = mgr.getLocatorFromRef(ref);
|
|
328
|
+
if (!locator) throw new Error(`No element found for ref ${ref}`);
|
|
329
|
+
await (locator as unknown as { fill(text: string): Promise<void> }).fill(text);
|
|
330
|
+
tab.url = mgr.getPage().url();
|
|
331
|
+
} finally {
|
|
332
|
+
this.unlock();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async screenshot(conversationId: string): Promise<string> {
|
|
337
|
+
await this.lock();
|
|
338
|
+
try {
|
|
339
|
+
const mgr = await this.ensureManager();
|
|
340
|
+
await this.switchToConversation(mgr, conversationId);
|
|
341
|
+
const page = mgr.getPage();
|
|
342
|
+
const buf = await page.screenshot({ type: "jpeg", quality: 75 });
|
|
343
|
+
return buf.toString("base64");
|
|
344
|
+
} finally {
|
|
345
|
+
this.unlock();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void> {
|
|
350
|
+
await this.lock();
|
|
351
|
+
try {
|
|
352
|
+
const mgr = await this.ensureManager();
|
|
353
|
+
await this.switchToConversation(mgr, conversationId);
|
|
354
|
+
const page = mgr.getPage();
|
|
355
|
+
const pixels = amount ?? 600;
|
|
356
|
+
const delta = direction === "down" ? pixels : -pixels;
|
|
357
|
+
await (page as unknown as { evaluate(fn: string): Promise<void> })
|
|
358
|
+
.evaluate(`window.scrollBy(0, ${delta})`);
|
|
359
|
+
} finally {
|
|
360
|
+
this.unlock();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async closeTab(conversationId: string): Promise<void> {
|
|
365
|
+
await this.lock();
|
|
366
|
+
try {
|
|
367
|
+
const tab = this.tabs.get(conversationId);
|
|
368
|
+
if (!tab) return;
|
|
369
|
+
|
|
370
|
+
if (this._screencastConversation === conversationId) {
|
|
371
|
+
try { await this.manager?.stopScreencast(); } catch { /* */ }
|
|
372
|
+
this._screencastConversation = undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const otherRealTabs = this.realTabCount() - (tab.tabIndex >= 0 ? 1 : 0);
|
|
376
|
+
if (otherRealTabs > 0 && this.manager?.isLaunched() && tab.tabIndex >= 0) {
|
|
377
|
+
try { await this.manager.closeTab(tab.tabIndex); } catch { /* */ }
|
|
378
|
+
for (const [, t] of this.tabs) {
|
|
379
|
+
if (t.tabIndex > tab.tabIndex) t.tabIndex--;
|
|
380
|
+
}
|
|
381
|
+
} else if (this.manager?.isLaunched()) {
|
|
382
|
+
try { await this.manager.close(); } catch { /* */ }
|
|
383
|
+
this.manager = undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
tab.active = false;
|
|
387
|
+
tab.url = undefined;
|
|
388
|
+
this.emitStatus(conversationId);
|
|
389
|
+
this.tabs.delete(conversationId);
|
|
390
|
+
} finally {
|
|
391
|
+
this.unlock();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async navigate(conversationId: string, action: string): Promise<void> {
|
|
396
|
+
await this.lock();
|
|
397
|
+
try {
|
|
398
|
+
const mgr = await this.ensureManager();
|
|
399
|
+
const tab = await this.switchToConversation(mgr, conversationId);
|
|
400
|
+
const page = mgr.getPage();
|
|
401
|
+
if (action === "back") await page.goBack();
|
|
402
|
+
else if (action === "forward") await page.goForward();
|
|
403
|
+
else throw new Error(`Unknown navigation action: ${action}`);
|
|
404
|
+
tab.url = page.url();
|
|
405
|
+
} finally {
|
|
406
|
+
this.unlock();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
// Screencast (one active at a time, tied to the viewed conversation)
|
|
412
|
+
// -----------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
async startScreencast(conversationId: string, options?: ScreencastOptions): Promise<void> {
|
|
415
|
+
await this.lock();
|
|
416
|
+
try {
|
|
417
|
+
const mgr = await this.ensureManager();
|
|
418
|
+
const tab = this.tabs.get(conversationId);
|
|
419
|
+
if (!tab) { return; }
|
|
420
|
+
|
|
421
|
+
// Always stop any existing screencast so we get a fresh CDP stream
|
|
422
|
+
if (mgr.isScreencasting()) {
|
|
423
|
+
try { await mgr.stopScreencast(); } catch { /* */ }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (mgr.getActiveIndex() !== tab.tabIndex) {
|
|
427
|
+
await mgr.switchTo(tab.tabIndex);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this._screencastConversation = conversationId;
|
|
431
|
+
await mgr.startScreencast(
|
|
432
|
+
(frame) => {
|
|
433
|
+
const cid = this._screencastConversation;
|
|
434
|
+
if (!cid) return;
|
|
435
|
+
const t = this.tabs.get(cid);
|
|
436
|
+
if (!t) return;
|
|
437
|
+
const browserFrame: BrowserFrame = {
|
|
438
|
+
data: frame.data,
|
|
439
|
+
width: frame.metadata.deviceWidth,
|
|
440
|
+
height: frame.metadata.deviceHeight,
|
|
441
|
+
timestamp: Date.now(),
|
|
442
|
+
};
|
|
443
|
+
for (const listener of t.frameListeners) {
|
|
444
|
+
try { listener(browserFrame); } catch { /* */ }
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
format: options?.format ?? "jpeg",
|
|
449
|
+
quality: options?.quality ?? this.config.quality ?? 60,
|
|
450
|
+
maxWidth: options?.maxWidth ?? this.config.viewport?.width ?? 1280,
|
|
451
|
+
maxHeight: options?.maxHeight ?? this.config.viewport?.height ?? 720,
|
|
452
|
+
everyNthFrame: options?.everyNthFrame ?? this.config.everyNthFrame ?? 2,
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
} finally {
|
|
456
|
+
this.unlock();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async stopScreencast(): Promise<void> {
|
|
461
|
+
if (!this.manager?.isScreencasting()) return;
|
|
462
|
+
await this.manager.stopScreencast();
|
|
463
|
+
this._screencastConversation = undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// -----------------------------------------------------------------------
|
|
467
|
+
// Per-conversation event listeners
|
|
468
|
+
// -----------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
onFrame(conversationId: string, listener: FrameListener): () => void {
|
|
471
|
+
let tab = this.tabs.get(conversationId);
|
|
472
|
+
if (!tab) {
|
|
473
|
+
tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
|
|
474
|
+
this.tabs.set(conversationId, tab);
|
|
475
|
+
}
|
|
476
|
+
tab.frameListeners.add(listener);
|
|
477
|
+
return () => { tab!.frameListeners.delete(listener); };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
onStatus(conversationId: string, listener: StatusListener): () => void {
|
|
481
|
+
let tab = this.tabs.get(conversationId);
|
|
482
|
+
if (!tab) {
|
|
483
|
+
tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
|
|
484
|
+
this.tabs.set(conversationId, tab);
|
|
485
|
+
}
|
|
486
|
+
tab.statusListeners.add(listener);
|
|
487
|
+
return () => { tab!.statusListeners.delete(listener); };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// -----------------------------------------------------------------------
|
|
491
|
+
// User input injection (all scoped by conversationId)
|
|
492
|
+
// -----------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
async injectMouse(conversationId: string, event: MouseInputEvent): Promise<void> {
|
|
495
|
+
await this.lock();
|
|
496
|
+
try {
|
|
497
|
+
const mgr = await this.ensureManager();
|
|
498
|
+
await this.switchToConversation(mgr, conversationId);
|
|
499
|
+
await mgr.injectMouseEvent({
|
|
500
|
+
type: event.type,
|
|
501
|
+
x: event.x,
|
|
502
|
+
y: event.y,
|
|
503
|
+
button: event.button ?? "left",
|
|
504
|
+
clickCount: event.clickCount ?? 1,
|
|
505
|
+
deltaX: event.deltaX ?? 0,
|
|
506
|
+
deltaY: event.deltaY ?? 0,
|
|
507
|
+
});
|
|
508
|
+
} finally {
|
|
509
|
+
this.unlock();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async injectKeyboard(conversationId: string, event: KeyboardInputEvent): Promise<void> {
|
|
514
|
+
await this.lock();
|
|
515
|
+
try {
|
|
516
|
+
const mgr = await this.ensureManager();
|
|
517
|
+
await this.switchToConversation(mgr, conversationId);
|
|
518
|
+
const cdp = await mgr.getCDPSession();
|
|
519
|
+
let cdpType: string = event.type;
|
|
520
|
+
if (event.type === "keyDown" && !event.text) cdpType = "rawKeyDown";
|
|
521
|
+
await cdp.send("Input.dispatchKeyEvent", {
|
|
522
|
+
type: cdpType,
|
|
523
|
+
key: event.key,
|
|
524
|
+
code: event.code,
|
|
525
|
+
text: event.text,
|
|
526
|
+
windowsVirtualKeyCode: event.keyCode ?? 0,
|
|
527
|
+
nativeVirtualKeyCode: event.keyCode ?? 0,
|
|
528
|
+
});
|
|
529
|
+
} finally {
|
|
530
|
+
this.unlock();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async injectPaste(conversationId: string, text: string): Promise<void> {
|
|
535
|
+
await this.lock();
|
|
536
|
+
try {
|
|
537
|
+
const mgr = await this.ensureManager();
|
|
538
|
+
await this.switchToConversation(mgr, conversationId);
|
|
539
|
+
const cdp = await mgr.getCDPSession();
|
|
540
|
+
await cdp.send("Input.insertText", { text });
|
|
541
|
+
} finally {
|
|
542
|
+
this.unlock();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async injectScroll(conversationId: string, event: ScrollInputEvent): Promise<void> {
|
|
547
|
+
await this.injectMouse(conversationId, {
|
|
548
|
+
type: "mouseWheel",
|
|
549
|
+
x: event.x ?? 0,
|
|
550
|
+
y: event.y ?? 0,
|
|
551
|
+
deltaX: event.deltaX,
|
|
552
|
+
deltaY: event.deltaY,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// -----------------------------------------------------------------------
|
|
557
|
+
// Session persistence & shutdown
|
|
558
|
+
// -----------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
async saveState(storagePath: string): Promise<void> {
|
|
561
|
+
if (!this.manager?.isLaunched()) return;
|
|
562
|
+
await mkdir(resolve(storagePath, ".."), { recursive: true });
|
|
563
|
+
await this.manager.saveStorageState(storagePath);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async close(): Promise<void> {
|
|
567
|
+
try { await this.stopScreencast(); } catch { /* */ }
|
|
568
|
+
try { await this.manager?.close(); } catch { /* */ }
|
|
569
|
+
this.manager = undefined;
|
|
570
|
+
for (const [cid, tab] of this.tabs) {
|
|
571
|
+
tab.active = false;
|
|
572
|
+
tab.url = undefined;
|
|
573
|
+
this.emitStatus(cid);
|
|
574
|
+
}
|
|
575
|
+
this.tabs.clear();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// -----------------------------------------------------------------------
|
|
579
|
+
// Internals
|
|
580
|
+
// -----------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
private emitStatus(conversationId: string): void {
|
|
583
|
+
const tab = this.tabs.get(conversationId);
|
|
584
|
+
const status: BrowserStatus = {
|
|
585
|
+
active: tab?.active ?? false,
|
|
586
|
+
url: tab?.url,
|
|
587
|
+
interactionAllowed: tab?.active ?? false,
|
|
588
|
+
};
|
|
589
|
+
if (tab) {
|
|
590
|
+
for (const listener of tab.statusListeners) {
|
|
591
|
+
try { listener(status); } catch { /* */ }
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|