dsh-builtin-browser 0.1.6 → 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 +9 -1
- package/lib/browser/runtime.d.ts +24 -8
- package/lib/browser/runtime.js +35 -7
- package/lib/browser/types.d.ts +120 -7
- package/lib/browser/types.js +1 -1
- package/lib/browser-electron/host-main.js +85 -0
- package/lib/browser-electron/provider.d.ts +49 -8
- package/lib/browser-electron/provider.js +355 -34
- package/lib/browser-electron/remote-host.js +29 -1
- package/lib/tool-browser/index.d.ts +13 -7
- package/lib/tool-browser/index.js +509 -34
- package/package.json +4 -2
- package/src/browser/runtime.ts +260 -0
- package/src/browser/types.ts +338 -0
- package/src/browser-electron/entry.ts +58 -0
- package/src/browser-electron/host-main.ts +229 -0
- package/src/browser-electron/provider.ts +922 -0
- package/src/browser-electron/remote-host.ts +421 -0
- package/src/index.ts +33 -0
- package/src/tool-browser/index.ts +862 -0
- package/src/types/electron-shim.d.ts +82 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron-backed browser provider: `WebContentsView` sessions driven over
|
|
3
|
+
* `webContents.debugger` (CDP). The provider itself does not import Electron — it operates through the {@link ElectronBrowserViewHost} seam, which the
|
|
4
|
+
* desktop shell implements with real Electron objects. That keeps this
|
|
5
|
+
* package testable under plain Node and leaves the Electron dependency to the
|
|
6
|
+
* shell that owns the `BrowserWindow`.
|
|
7
|
+
* @module dsh-browser/browser-electron
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import { writeFileSync } from 'node:fs'
|
|
12
|
+
import type {
|
|
13
|
+
BrowserChallenge,
|
|
14
|
+
BrowserContentRequest,
|
|
15
|
+
BrowserContentResult,
|
|
16
|
+
BrowserExecuteRequest,
|
|
17
|
+
BrowserExecuteResult,
|
|
18
|
+
BrowserFillRequest,
|
|
19
|
+
BrowserFillResult,
|
|
20
|
+
BrowserHistoryEntry,
|
|
21
|
+
BrowserOpenRequest,
|
|
22
|
+
BrowserProvider,
|
|
23
|
+
BrowserSessionId,
|
|
24
|
+
BrowserSnapshotResult,
|
|
25
|
+
BrowserTab,
|
|
26
|
+
ExportedCookie,
|
|
27
|
+
} from '../browser/types.ts'
|
|
28
|
+
import { BrowserError } from '../browser/types.ts'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
32
|
+
* inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
|
|
33
|
+
* best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
|
|
34
|
+
* Turnstile, and generic challenge wording.
|
|
35
|
+
*/
|
|
36
|
+
const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
37
|
+
const title = (document.title || '').trim()
|
|
38
|
+
const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
|
|
39
|
+
const lower = (title + '\\n' + bodyText).toLowerCase()
|
|
40
|
+
const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
|
|
41
|
+
const framesLower = frameSrcs.toLowerCase()
|
|
42
|
+
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
|
|
43
|
+
|| !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
|
|
44
|
+
const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
|
|
45
|
+
const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
|
|
46
|
+
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
|
|
47
|
+
const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
|
|
48
|
+
if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
|
|
49
|
+
if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
|
|
50
|
+
if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
|
|
51
|
+
if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
|
|
52
|
+
if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
|
|
53
|
+
return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
|
|
54
|
+
}
|
|
55
|
+
return { blocked: false }
|
|
56
|
+
})()`
|
|
57
|
+
|
|
58
|
+
/** Stable provider id registered with `ctx.browser`. */
|
|
59
|
+
export const ELECTRON_BROWSER_PROVIDER_ID = 'electron'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The minimal Electron surface this provider needs. Implemented by the
|
|
63
|
+
* desktop shell with a real `WebContentsView`; a fake implements it in tests.
|
|
64
|
+
*/
|
|
65
|
+
export interface ElectronBrowserViewHost {
|
|
66
|
+
/**
|
|
67
|
+
* Create a new browser view and return a handle to its webContents-like
|
|
68
|
+
* surface. The host owns windowing (adding the view to the window, sizing,
|
|
69
|
+
* removal); the provider owns CDP-driven behavior.
|
|
70
|
+
*/
|
|
71
|
+
createView(): ElectronViewHandle
|
|
72
|
+
/**
|
|
73
|
+
* Destroy a view created by this host. Called on session close; idempotent
|
|
74
|
+
* for an already-destroyed view.
|
|
75
|
+
* @param handle - the handle returned by {@link createView}.
|
|
76
|
+
*/
|
|
77
|
+
destroyView(handle: ElectronViewHandle): void
|
|
78
|
+
/**
|
|
79
|
+
* Show one view as the session's visible surface. The host keeps exactly
|
|
80
|
+
* one visible; switching tabs reorders visibility without losing state.
|
|
81
|
+
* Optional: a host without visible-tab switching treats every view as
|
|
82
|
+
* always present (acceptable for headless/probe hosts).
|
|
83
|
+
* @param handle - the handle to make visible.
|
|
84
|
+
*/
|
|
85
|
+
showView?(handle: ElectronViewHandle): void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A CDP-capable view handle. This is the subset of Electron's
|
|
90
|
+
* `WebContents`/`WebContentsView` the provider drives; the shell's real
|
|
91
|
+
* implementation adapts `webContents.debugger` to it.
|
|
92
|
+
*/
|
|
93
|
+
export interface ElectronViewHandle {
|
|
94
|
+
/** Unique id of the backing view, used for diagnostics. */
|
|
95
|
+
readonly id: string
|
|
96
|
+
/**
|
|
97
|
+
* Send one CDP command and resolve with its result. Rejects when the
|
|
98
|
+
* debugger is not attached or the command fails.
|
|
99
|
+
* @param method - CDP method, e.g. `Page.navigate`.
|
|
100
|
+
* @param params - CDP command parameters.
|
|
101
|
+
* @returns the CDP `result` object.
|
|
102
|
+
*/
|
|
103
|
+
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One tab inside a session: its view plus a stable id. */
|
|
107
|
+
interface Tab {
|
|
108
|
+
readonly id: string
|
|
109
|
+
readonly handle: ElectronViewHandle
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One live browser session: an ordered list of tabs, one active. */
|
|
113
|
+
interface Session {
|
|
114
|
+
readonly id: BrowserSessionId
|
|
115
|
+
readonly tabs: Tab[]
|
|
116
|
+
activeIndex: number
|
|
117
|
+
/** Chronological operation log (navigate/execute/click/type/screenshot). */
|
|
118
|
+
readonly history: BrowserHistoryEntry[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
122
|
+
export interface ElectronBrowserProviderConfig {
|
|
123
|
+
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
124
|
+
readonly httpOnly?: boolean
|
|
125
|
+
/** Maximum snapshot elements before truncation. Default 60. */
|
|
126
|
+
readonly snapshotMaxElements?: number
|
|
127
|
+
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
128
|
+
readonly contentMaxChars?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* CDP method/params for `Page.navigate`, as sent to {@link ElectronViewHandle.sendCommand}.
|
|
133
|
+
*/
|
|
134
|
+
export interface CdpNavigateParams {
|
|
135
|
+
readonly url: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
|
|
140
|
+
*/
|
|
141
|
+
export interface CdpMouseParams {
|
|
142
|
+
readonly type: 'mousePressed' | 'mouseReleased'
|
|
143
|
+
readonly x: number
|
|
144
|
+
readonly y: number
|
|
145
|
+
readonly button: 'left'
|
|
146
|
+
readonly clickCount: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** CDP method/params for `Input.insertText`. */
|
|
150
|
+
export interface CdpInsertTextParams {
|
|
151
|
+
readonly text: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** CDP method/params for `Runtime.evaluate`. */
|
|
155
|
+
export interface CdpEvaluateParams {
|
|
156
|
+
readonly expression: string
|
|
157
|
+
readonly returnByValue: boolean
|
|
158
|
+
readonly awaitPromise?: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** CDP method for a full-page screenshot capture. */
|
|
162
|
+
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
|
|
163
|
+
/** CDP method for runtime evaluation (the execute path). */
|
|
164
|
+
export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate'
|
|
165
|
+
/** CDP method for navigation. */
|
|
166
|
+
export const CDP_PAGE_NAVIGATE = 'Page.navigate'
|
|
167
|
+
|
|
168
|
+
/** Cap on content returned by a snapshot fetch to keep the wire bounded. */
|
|
169
|
+
const SNAPSHOT_LABEL_MAX = 120
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
173
|
+
* tabs; each tab is one view created by the host. The active tab receives
|
|
174
|
+
* every operation; switching tabs calls the host's optional `showView` and
|
|
175
|
+
* never loses state. Navigation is admitted only for HTTP(S) targets unless
|
|
176
|
+
* {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
|
|
177
|
+
*/
|
|
178
|
+
export class ElectronBrowserProvider implements BrowserProvider {
|
|
179
|
+
readonly id = ELECTRON_BROWSER_PROVIDER_ID
|
|
180
|
+
|
|
181
|
+
private readonly sessions = new Map<BrowserSessionId, Session>()
|
|
182
|
+
private readonly httpOnly: boolean
|
|
183
|
+
private readonly snapshotMaxElements: number
|
|
184
|
+
private readonly contentMaxChars: number
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly host: ElectronBrowserViewHost,
|
|
188
|
+
config: ElectronBrowserProviderConfig = {},
|
|
189
|
+
) {
|
|
190
|
+
this.httpOnly = config.httpOnly ?? true
|
|
191
|
+
this.snapshotMaxElements = config.snapshotMaxElements ?? 60
|
|
192
|
+
this.contentMaxChars = config.contentMaxChars ?? 100_000
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Usable whenever the host can create views (always in the desktop shell). */
|
|
196
|
+
available(): boolean {
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Open a NEW browser session with its own view. Every call mints a fresh
|
|
202
|
+
* session id and backing view; per-task reuse is owned by the caller (the
|
|
203
|
+
* tool layer caches one session per DSH task). Sessions are isolated from
|
|
204
|
+
* each other: each keeps its own tabs, active tab, and history, and only
|
|
205
|
+
* the active tab of a session is made visible.
|
|
206
|
+
*/
|
|
207
|
+
open(): Promise<BrowserSessionId> {
|
|
208
|
+
const handle = this.host.createView()
|
|
209
|
+
const id = `browser:${randomUUID()}`
|
|
210
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
|
|
211
|
+
return Promise.resolve(id)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Open a URL in the active tab (default) or a new tab. */
|
|
215
|
+
async openUrl(session: BrowserSessionId, request: BrowserOpenRequest, signal?: AbortSignal): Promise<void> {
|
|
216
|
+
const s = this.session(session)
|
|
217
|
+
if (request.newTab === true) {
|
|
218
|
+
this.newTab(s)
|
|
219
|
+
}
|
|
220
|
+
await this.navigate(session, { url: request.url }, signal)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** List the session's tabs with their titles. */
|
|
224
|
+
async listTabs(session: BrowserSessionId): Promise<readonly BrowserTab[]> {
|
|
225
|
+
const s = this.session(session)
|
|
226
|
+
const result: BrowserTab[] = []
|
|
227
|
+
for (let i = 0; i < s.tabs.length; i++) {
|
|
228
|
+
const tab = s.tabs[i]
|
|
229
|
+
if (tab === undefined) continue // defensive: array can shift under concurrency
|
|
230
|
+
result.push({
|
|
231
|
+
id: tab.id,
|
|
232
|
+
url: await this.currentUrl(tab.handle).catch(() => ''),
|
|
233
|
+
active: i === s.activeIndex,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Switch to a tab by id, making its view visible. */
|
|
240
|
+
switchTab(session: BrowserSessionId, tabId: string): Promise<void> {
|
|
241
|
+
const s = this.session(session)
|
|
242
|
+
const index = s.tabs.findIndex(tab => tab.id === tabId)
|
|
243
|
+
if (index < 0) {
|
|
244
|
+
throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN')
|
|
245
|
+
}
|
|
246
|
+
s.activeIndex = index
|
|
247
|
+
this.showActive(s)
|
|
248
|
+
return Promise.resolve()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Close one tab; closing the active tab activates the next. */
|
|
252
|
+
closeTab(session: BrowserSessionId, tabId: string): Promise<void> {
|
|
253
|
+
const s = this.session(session)
|
|
254
|
+
const index = s.tabs.findIndex(tab => tab.id === tabId)
|
|
255
|
+
if (index < 0) return Promise.resolve() // idempotent
|
|
256
|
+
const removed = s.tabs[index]
|
|
257
|
+
if (removed !== undefined) {
|
|
258
|
+
s.tabs.splice(index, 1)
|
|
259
|
+
this.host.destroyView(removed.handle)
|
|
260
|
+
}
|
|
261
|
+
if (s.tabs.length === 0) {
|
|
262
|
+
// Session keeps one blank tab so it stays usable.
|
|
263
|
+
this.newTab(s)
|
|
264
|
+
} else if (index < s.activeIndex) {
|
|
265
|
+
// Closing a tab before the active one shifts the array left; keep the
|
|
266
|
+
// same tab active by decrementing the index.
|
|
267
|
+
s.activeIndex -= 1
|
|
268
|
+
} else if (s.activeIndex >= s.tabs.length) {
|
|
269
|
+
// The active tab itself was closed; activate the last remaining one.
|
|
270
|
+
s.activeIndex = s.tabs.length - 1
|
|
271
|
+
}
|
|
272
|
+
this.showActive(s)
|
|
273
|
+
return Promise.resolve()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Close every tab and reset to one blank tab. */
|
|
277
|
+
reset(session: BrowserSessionId): Promise<void> {
|
|
278
|
+
const s = this.session(session)
|
|
279
|
+
for (const tab of s.tabs) this.host.destroyView(tab.handle)
|
|
280
|
+
s.tabs.length = 0
|
|
281
|
+
this.newTab(s)
|
|
282
|
+
s.activeIndex = 0
|
|
283
|
+
this.showActive(s)
|
|
284
|
+
return Promise.resolve()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
288
|
+
async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
|
|
289
|
+
const s = this.session(session)
|
|
290
|
+
const { handle } = this.activeTab(s)
|
|
291
|
+
const url = request.url
|
|
292
|
+
try {
|
|
293
|
+
if (this.httpOnly) {
|
|
294
|
+
let parsed: URL
|
|
295
|
+
try {
|
|
296
|
+
parsed = new URL(url)
|
|
297
|
+
} catch {
|
|
298
|
+
throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
|
|
299
|
+
}
|
|
300
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
301
|
+
throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
signal?.throwIfAborted()
|
|
305
|
+
const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams)
|
|
306
|
+
// Page.navigate resolves even when the navigation fails; surface the
|
|
307
|
+
// failure instead of leaving a silent white screen.
|
|
308
|
+
const errorText = (result as { errorText?: string }).errorText
|
|
309
|
+
if (typeof errorText === 'string' && errorText !== '') {
|
|
310
|
+
this.record(s, 'navigate', { url }, false, { error: errorText })
|
|
311
|
+
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
|
|
312
|
+
}
|
|
313
|
+
this.record(s, 'navigate', { url }, true)
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
316
|
+
this.record(s, 'navigate', { url }, false, { error: String(error) })
|
|
317
|
+
}
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Execute JS in the active tab's page context. */
|
|
323
|
+
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
324
|
+
const s = this.session(session)
|
|
325
|
+
const { handle } = this.activeTab(s)
|
|
326
|
+
signal?.throwIfAborted()
|
|
327
|
+
try {
|
|
328
|
+
// Wrap the script in a Function so `return` statements are legal and
|
|
329
|
+
// request.args arrive as `arguments[0..n]`. A bare script handed to CDP
|
|
330
|
+
// Runtime.evaluate is an expression context — a leading `return` would
|
|
331
|
+
// be a syntax error, and an object-literal script (`{...}`) would parse
|
|
332
|
+
// as a block. So: if the script already starts with `return`, use it as
|
|
333
|
+
// the body verbatim; otherwise wrap it as `return (expr)` so both
|
|
334
|
+
// expression and object-literal forms evaluate to their value. Args are
|
|
335
|
+
// embedded as a JSON array literal; unserializable members become null.
|
|
336
|
+
const body = /^\s*return\b/.test(request.script)
|
|
337
|
+
? request.script
|
|
338
|
+
: `return (${request.script})`
|
|
339
|
+
const hasArgs = request.args !== undefined && request.args.length > 0
|
|
340
|
+
const expression = hasArgs
|
|
341
|
+
? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
|
|
342
|
+
: `(function(){ return Function(${JSON.stringify(body)})() })()`
|
|
343
|
+
// CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
|
|
344
|
+
// (navigate returned but the renderer has not committed). Bound it so a
|
|
345
|
+
// stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
|
|
346
|
+
// whole tool call. The caller's signal wins when it fires first.
|
|
347
|
+
const timeoutMs = request.timeoutMs ?? 30_000
|
|
348
|
+
const result = await withTimeout(
|
|
349
|
+
handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
350
|
+
expression,
|
|
351
|
+
returnByValue: true,
|
|
352
|
+
awaitPromise: true,
|
|
353
|
+
} satisfies CdpEvaluateParams),
|
|
354
|
+
timeoutMs,
|
|
355
|
+
signal,
|
|
356
|
+
`browser: execute timed out after ${timeoutMs}ms`,
|
|
357
|
+
)
|
|
358
|
+
if (result.exceptionDetails !== undefined) {
|
|
359
|
+
const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
|
|
360
|
+
const exception = detail.exception?.description ?? detail.text ?? 'unknown exception'
|
|
361
|
+
this.record(s, 'execute', { script: request.script }, false, { error: exception })
|
|
362
|
+
return { ok: false, exception }
|
|
363
|
+
}
|
|
364
|
+
const value = (result.result as { value?: unknown } | undefined)?.value ?? null
|
|
365
|
+
this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
|
|
366
|
+
return { ok: true, value }
|
|
367
|
+
} catch (error) {
|
|
368
|
+
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Produce an AI-friendly snapshot of the active tab. */
|
|
373
|
+
async snapshot(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserSnapshotResult> {
|
|
374
|
+
const s = this.session(session)
|
|
375
|
+
const tab = this.activeTab(s)
|
|
376
|
+
signal?.throwIfAborted()
|
|
377
|
+
const script = `(() => {
|
|
378
|
+
const cap = ${String(this.snapshotMaxElements)}
|
|
379
|
+
const url = location.href
|
|
380
|
+
const title = document.title || undefined
|
|
381
|
+
const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
|
|
382
|
+
const out = []
|
|
383
|
+
for (const el of els) {
|
|
384
|
+
if (out.length >= cap) break
|
|
385
|
+
const r = el.getBoundingClientRect()
|
|
386
|
+
const cs = getComputedStyle(el)
|
|
387
|
+
if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
|
|
388
|
+
const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
|
|
389
|
+
: el.tagName === 'TEXTAREA' ? 'textarea'
|
|
390
|
+
: el.tagName === 'SELECT' ? 'select'
|
|
391
|
+
: el.tagName === 'BUTTON' ? 'button'
|
|
392
|
+
: el.tagName === 'A' ? 'link' : 'other'
|
|
393
|
+
const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').toString().replace(/\\s+/g, ' ').trim().slice(0, ${String(SNAPSHOT_LABEL_MAX)})
|
|
394
|
+
if (!label && kind !== 'link') continue
|
|
395
|
+
out.push({
|
|
396
|
+
ref: out.length + 1,
|
|
397
|
+
kind,
|
|
398
|
+
label,
|
|
399
|
+
selector: el.id ? '#' + el.id : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
400
|
+
x: Math.round(r.x + r.width / 2),
|
|
401
|
+
y: Math.round(r.y + r.height / 2),
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
const challenge = ${CHALLENGE_DETECT_EXPRESSION}
|
|
405
|
+
return { url, title, elements: out, truncated: out.length >= cap, challenge }
|
|
406
|
+
})()`
|
|
407
|
+
// Same hang guard as execute: a renderer that has not committed after
|
|
408
|
+
// navigate would otherwise block snapshot forever.
|
|
409
|
+
const timeoutMs = 30_000
|
|
410
|
+
const result = await withTimeout(
|
|
411
|
+
handleSendEvaluate(tab.handle, script),
|
|
412
|
+
timeoutMs,
|
|
413
|
+
signal,
|
|
414
|
+
`browser: snapshot timed out after ${timeoutMs}ms`,
|
|
415
|
+
)
|
|
416
|
+
if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
|
|
417
|
+
const value = result.value as BrowserSnapshotResult
|
|
418
|
+
return value
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Check whether a human-verification challenge is blocking the active tab. */
|
|
422
|
+
async detectChallenge(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserChallenge> {
|
|
423
|
+
const s = this.session(session)
|
|
424
|
+
const tab = this.activeTab(s)
|
|
425
|
+
signal?.throwIfAborted()
|
|
426
|
+
const timeoutMs = 15_000
|
|
427
|
+
const result = await withTimeout(
|
|
428
|
+
handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION),
|
|
429
|
+
timeoutMs,
|
|
430
|
+
signal,
|
|
431
|
+
`browser: challenge detection timed out after ${timeoutMs}ms`,
|
|
432
|
+
)
|
|
433
|
+
if (!result.ok) {
|
|
434
|
+
throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED')
|
|
435
|
+
}
|
|
436
|
+
const value = result.value as BrowserChallenge
|
|
437
|
+
return { blocked: value.blocked === true, kind: value.kind, reason: value.reason }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Fetch page content in a requested format. */
|
|
441
|
+
async content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult> {
|
|
442
|
+
const tab = this.activeTab(this.session(session))
|
|
443
|
+
signal?.throwIfAborted()
|
|
444
|
+
const maxChars = request.maxChars ?? this.contentMaxChars
|
|
445
|
+
const selector = request.selector ?? ''
|
|
446
|
+
const format = request.format
|
|
447
|
+
const script = `(() => {
|
|
448
|
+
const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
|
|
449
|
+
if (!root) return { ok: false, reason: 'selector not found' }
|
|
450
|
+
const fmt = ${JSON.stringify(format)}
|
|
451
|
+
let content = ''
|
|
452
|
+
if (fmt === 'txt') content = root.innerText || ''
|
|
453
|
+
else if (fmt === 'html') content = root.outerHTML || ''
|
|
454
|
+
else if (fmt === 'json') content = JSON.stringify(root)
|
|
455
|
+
else {
|
|
456
|
+
// markdown: headings, paragraphs, links, lists (best-effort)
|
|
457
|
+
const parts = []
|
|
458
|
+
const walk = (node) => {
|
|
459
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
460
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
461
|
+
const tag = node.tagName.toLowerCase()
|
|
462
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
463
|
+
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4') parts.push('\\n' + '#'.repeat(Number(tag[1])) + ' ' + (node.textContent || '').trim() + '\\n')
|
|
464
|
+
else if (tag === 'a') { const t = (node.textContent || '').trim(); if (t) parts.push('[' + t + '](' + (node.href || '') + ')') }
|
|
465
|
+
else if (tag === 'li') parts.push(' - ' + (node.textContent || '').trim())
|
|
466
|
+
else if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'article') { const t = (node.textContent || '').trim(); if (t) parts.push(t + '\\n') }
|
|
467
|
+
else { for (const child of node.childNodes) walk(child) }
|
|
468
|
+
}
|
|
469
|
+
if (root.nodeType === Node.TEXT_NODE) walk(root)
|
|
470
|
+
else for (const child of root.childNodes) walk(child)
|
|
471
|
+
// Join without a separator: each part already carries its own trailing
|
|
472
|
+
// newline, so a space join would smear headings/links into run-on text.
|
|
473
|
+
content = parts.join('')
|
|
474
|
+
}
|
|
475
|
+
const truncated = content.length > ${String(maxChars)}
|
|
476
|
+
return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
|
|
477
|
+
})()`
|
|
478
|
+
// Honor a per-call timeout: content evaluation can hang on a heavy page,
|
|
479
|
+
// so a caller-supplied budget bounds it. AbortSignal.any merges the
|
|
480
|
+
// caller's signal with a timeout.
|
|
481
|
+
const timeoutMs = request.timeoutMs ?? 30_000
|
|
482
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
483
|
+
const result = signal !== undefined
|
|
484
|
+
? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
|
|
485
|
+
: await handleSendEvaluate(tab.handle, script, timeoutSignal)
|
|
486
|
+
if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
|
|
487
|
+
const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
|
|
488
|
+
if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
|
|
489
|
+
return { content: value.content ?? '', truncated: value.truncated ?? false }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
|
|
493
|
+
async click(session: BrowserSessionId, request: { readonly x: number; readonly y: number }, signal?: AbortSignal): Promise<void> {
|
|
494
|
+
const s = this.session(session)
|
|
495
|
+
const { handle } = this.activeTab(s)
|
|
496
|
+
signal?.throwIfAborted()
|
|
497
|
+
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
|
|
498
|
+
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
|
|
499
|
+
this.record(s, 'click', { x: request.x, y: request.y }, true)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Type into the focused element. */
|
|
503
|
+
async type(session: BrowserSessionId, request: { readonly text: string }, signal?: AbortSignal): Promise<void> {
|
|
504
|
+
const s = this.session(session)
|
|
505
|
+
const { handle } = this.activeTab(s)
|
|
506
|
+
signal?.throwIfAborted()
|
|
507
|
+
await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
|
|
508
|
+
this.record(s, 'type', { text: request.text.slice(0, 200) }, true)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Fill a form's fields in one batch. Runs one page-context script that
|
|
513
|
+
* resolves each field (selector, or name/label/placeholder among visible
|
|
514
|
+
* controls), sets its value with the native prototype setter (React/Vue
|
|
515
|
+
* controlled inputs included) plus input/change events, handles
|
|
516
|
+
* select/checkbox/radio/contenteditable, and optionally submits the form.
|
|
517
|
+
*/
|
|
518
|
+
async fillForm(session: BrowserSessionId, request: BrowserFillRequest, signal?: AbortSignal): Promise<BrowserFillResult> {
|
|
519
|
+
const s = this.session(session)
|
|
520
|
+
const tab = this.activeTab(s)
|
|
521
|
+
signal?.throwIfAborted()
|
|
522
|
+
const specs = JSON.stringify(request.fields.map(f => ({
|
|
523
|
+
selector: f.selector ?? null,
|
|
524
|
+
name: f.name ?? null,
|
|
525
|
+
label: f.label ?? null,
|
|
526
|
+
placeholder: f.placeholder ?? null,
|
|
527
|
+
kind: f.kind ?? 'text',
|
|
528
|
+
value: f.value,
|
|
529
|
+
})))
|
|
530
|
+
const submitFlag = request.submit === true
|
|
531
|
+
const script = `(() => {
|
|
532
|
+
const specs = ${specs}
|
|
533
|
+
const out = []
|
|
534
|
+
const setNative = (el, proto, value) => {
|
|
535
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
536
|
+
if (setter) setter.call(el, value)
|
|
537
|
+
else el.value = value
|
|
538
|
+
}
|
|
539
|
+
const visible = (el) => {
|
|
540
|
+
const r = el.getBoundingClientRect()
|
|
541
|
+
const cs = getComputedStyle(el)
|
|
542
|
+
return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
|
|
543
|
+
}
|
|
544
|
+
const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
|
|
545
|
+
const matches = (el, spec) => {
|
|
546
|
+
if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
|
|
547
|
+
if (spec.name && el.name === spec.name) return true
|
|
548
|
+
if (spec.placeholder && el.placeholder === spec.placeholder) return true
|
|
549
|
+
if (spec.label) {
|
|
550
|
+
if (el.getAttribute('aria-label') === spec.label) return true
|
|
551
|
+
if (el.id) {
|
|
552
|
+
const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
|
|
553
|
+
if (lbl && (lbl.textContent || '').trim() === spec.label) return true
|
|
554
|
+
}
|
|
555
|
+
const wrap = el.closest('label')
|
|
556
|
+
if (wrap && (wrap.textContent || '').trim() === spec.label) return true
|
|
557
|
+
}
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
const candidates = (spec) => {
|
|
561
|
+
const all = spec.selector
|
|
562
|
+
? [...document.querySelectorAll(spec.selector)]
|
|
563
|
+
: [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
|
|
564
|
+
const vis = all.filter(visible)
|
|
565
|
+
return vis.length > 0 ? vis : all
|
|
566
|
+
}
|
|
567
|
+
for (const spec of specs) {
|
|
568
|
+
const els = candidates(spec)
|
|
569
|
+
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
570
|
+
const el = els[0]
|
|
571
|
+
const tag = el.tagName
|
|
572
|
+
const type = (el.type || '').toLowerCase()
|
|
573
|
+
try {
|
|
574
|
+
if (tag === 'SELECT') {
|
|
575
|
+
const wanted = String(spec.value)
|
|
576
|
+
if (el.multiple) {
|
|
577
|
+
const wantedList = wanted.split(',').map(x => x.trim())
|
|
578
|
+
let hit = false
|
|
579
|
+
for (const o of [...el.options]) {
|
|
580
|
+
o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
|
|
581
|
+
if (o.selected) hit = true
|
|
582
|
+
}
|
|
583
|
+
if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
|
|
584
|
+
} else {
|
|
585
|
+
let opt = [...el.options].find(o => o.value === wanted)
|
|
586
|
+
if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
|
|
587
|
+
if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
|
|
588
|
+
setNative(el, HTMLSelectElement.prototype, opt.value)
|
|
589
|
+
}
|
|
590
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
591
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
592
|
+
out.push({ ok: true, method: 'select', target: describe(spec) })
|
|
593
|
+
} else if (type === 'file') {
|
|
594
|
+
out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
|
|
595
|
+
} else if (type === 'checkbox') {
|
|
596
|
+
const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
|
|
597
|
+
if (el.checked !== want) el.click()
|
|
598
|
+
out.push({ ok: true, method: 'checkbox', target: describe(spec) })
|
|
599
|
+
} else if (type === 'radio') {
|
|
600
|
+
const wanted = String(spec.value)
|
|
601
|
+
const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
|
|
602
|
+
.find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
|
|
603
|
+
if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
|
|
604
|
+
if (!radio.checked) radio.click()
|
|
605
|
+
out.push({ ok: true, method: 'radio', target: describe(spec) })
|
|
606
|
+
} else if (el.isContentEditable) {
|
|
607
|
+
el.textContent = String(spec.value)
|
|
608
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
609
|
+
out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
|
|
610
|
+
} else if (tag === 'TEXTAREA') {
|
|
611
|
+
setNative(el, HTMLTextAreaElement.prototype, String(spec.value))
|
|
612
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
613
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
614
|
+
out.push({ ok: true, method: 'textarea', target: describe(spec) })
|
|
615
|
+
} else {
|
|
616
|
+
setNative(el, HTMLInputElement.prototype, String(spec.value))
|
|
617
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
618
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
619
|
+
out.push({ ok: true, method: 'input', target: describe(spec) })
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
622
|
+
out.push({ ok: false, error: String(e), target: describe(spec) })
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
let submitted = false
|
|
626
|
+
if (${submitFlag}) {
|
|
627
|
+
let last = null
|
|
628
|
+
for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
|
|
629
|
+
const form = last && (last.form || last.closest('form'))
|
|
630
|
+
if (form) { form.requestSubmit(); submitted = true }
|
|
631
|
+
}
|
|
632
|
+
return { fields: out, submitted }
|
|
633
|
+
})()`
|
|
634
|
+
const timeoutMs = request.timeoutMs ?? 30_000
|
|
635
|
+
const result = await withTimeout(
|
|
636
|
+
handleSendEvaluate(tab.handle, script),
|
|
637
|
+
timeoutMs,
|
|
638
|
+
signal,
|
|
639
|
+
`browser: fillForm timed out after ${timeoutMs}ms`,
|
|
640
|
+
)
|
|
641
|
+
if (!result.ok) {
|
|
642
|
+
throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED')
|
|
643
|
+
}
|
|
644
|
+
const value = result.value as BrowserFillResult
|
|
645
|
+
const okCount = value.fields.filter(f => f.ok).length
|
|
646
|
+
this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
|
|
647
|
+
result: `${okCount}/${value.fields.length} fields filled${value.submitted ? ', form submitted' : ''}`,
|
|
648
|
+
})
|
|
649
|
+
return { fields: value.fields, submitted: value.submitted === true }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Download a URL to a local file, keeping the session's cookies/login.
|
|
654
|
+
* Requires the self-hosted host (which implements view-level download); the
|
|
655
|
+
* desktop shell's embedded views delegate downloads to the real browser UI.
|
|
656
|
+
*/
|
|
657
|
+
async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
|
|
658
|
+
const s = this.session(session)
|
|
659
|
+
const { handle } = this.activeTab(s)
|
|
660
|
+
signal?.throwIfAborted()
|
|
661
|
+
const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
|
|
662
|
+
if (typeof downloadable.download !== 'function') {
|
|
663
|
+
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
|
|
664
|
+
}
|
|
665
|
+
await downloadable.download(request.url, request.savePath)
|
|
666
|
+
this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
|
|
667
|
+
return { path: request.savePath }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Export the session's cookies (login state) as serializable objects.
|
|
672
|
+
* Self-hosted only; the desktop shell's embedded views use the real profile.
|
|
673
|
+
*/
|
|
674
|
+
async flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]> {
|
|
675
|
+
const s = this.session(session)
|
|
676
|
+
const { handle } = this.activeTab(s)
|
|
677
|
+
const host = handle as { flushAuth?(): Promise<ExportedCookie[]> }
|
|
678
|
+
if (typeof host.flushAuth !== 'function') {
|
|
679
|
+
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
680
|
+
}
|
|
681
|
+
const cookies = await host.flushAuth()
|
|
682
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
|
|
683
|
+
return cookies
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
687
|
+
async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
|
|
688
|
+
const s = this.session(session)
|
|
689
|
+
const { handle } = this.activeTab(s)
|
|
690
|
+
const host = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
|
|
691
|
+
if (typeof host.restoreAuth !== 'function') {
|
|
692
|
+
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
693
|
+
}
|
|
694
|
+
const restored = await host.restoreAuth(cookies)
|
|
695
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
|
|
696
|
+
return restored
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
700
|
+
async screenshot(
|
|
701
|
+
session: BrowserSessionId,
|
|
702
|
+
request?: { readonly fullPage?: boolean; readonly savePath?: string },
|
|
703
|
+
signal?: AbortSignal,
|
|
704
|
+
): Promise<{ readonly dataUrl: string; readonly path?: string }> {
|
|
705
|
+
const { handle } = this.activeTab(this.session(session))
|
|
706
|
+
signal?.throwIfAborted()
|
|
707
|
+
const params: Record<string, unknown> = {}
|
|
708
|
+
if (request?.fullPage === true) {
|
|
709
|
+
// `captureBeyondViewport` captures the full scrollable content; without
|
|
710
|
+
// a clip this yields the full-page image (CDP default is the viewport).
|
|
711
|
+
params.captureBeyondViewport = true
|
|
712
|
+
}
|
|
713
|
+
const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params)
|
|
714
|
+
const data = result.data
|
|
715
|
+
if (typeof data !== 'string') {
|
|
716
|
+
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
|
|
717
|
+
}
|
|
718
|
+
// Optional disk write so a vision tool (e.g. modlens read_image) can read
|
|
719
|
+
// the screenshot from a file path. Fire-and-forget: a write failure must
|
|
720
|
+
// not fail the capture.
|
|
721
|
+
if (request?.savePath !== undefined) {
|
|
722
|
+
try {
|
|
723
|
+
writeFileSync(request.savePath, Buffer.from(data, 'base64'))
|
|
724
|
+
return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath }
|
|
725
|
+
} catch (error) {
|
|
726
|
+
// Report the write problem but keep the capture usable.
|
|
727
|
+
throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { dataUrl: `data:image/png;base64,${data}` }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Append one operation to the session's history. */
|
|
734
|
+
private record(
|
|
735
|
+
s: Session,
|
|
736
|
+
action: string,
|
|
737
|
+
params: Record<string, unknown>,
|
|
738
|
+
ok: boolean,
|
|
739
|
+
detail?: { result?: string; error?: string },
|
|
740
|
+
): void {
|
|
741
|
+
const entry: BrowserHistoryEntry = {
|
|
742
|
+
seq: s.history.length + 1,
|
|
743
|
+
action,
|
|
744
|
+
params,
|
|
745
|
+
ok,
|
|
746
|
+
...detail?.result !== undefined ? { result: detail.result } : {},
|
|
747
|
+
...detail?.error !== undefined ? { error: detail.error } : {},
|
|
748
|
+
at: Date.now(),
|
|
749
|
+
}
|
|
750
|
+
s.history.push(entry)
|
|
751
|
+
// Bound memory: keep the last 500 operations.
|
|
752
|
+
if (s.history.length > 500) s.history.splice(0, s.history.length - 500)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Return the session's chronological operation log (newest last). */
|
|
756
|
+
async history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]> {
|
|
757
|
+
return this.session(session).history
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
762
|
+
* re-issued against the current page; execute re-runs its script. The
|
|
763
|
+
* replayed step is appended to history as a new entry.
|
|
764
|
+
* @param session - the session id.
|
|
765
|
+
* @param seq - the recorded entry's sequence number to replay.
|
|
766
|
+
*/
|
|
767
|
+
async replay(session: BrowserSessionId, seq: number): Promise<void> {
|
|
768
|
+
const s = this.session(session)
|
|
769
|
+
const entry = s.history.find(e => e.seq === seq)
|
|
770
|
+
if (entry === undefined) {
|
|
771
|
+
throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN')
|
|
772
|
+
}
|
|
773
|
+
switch (entry.action) {
|
|
774
|
+
case 'navigate': {
|
|
775
|
+
const url = entry.params.url
|
|
776
|
+
if (typeof url !== 'string') throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID')
|
|
777
|
+
await this.navigate(session, { url })
|
|
778
|
+
this.record(s, 'replay', { seq, of: entry.action, url }, true)
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
case 'click': {
|
|
782
|
+
const x = entry.params.x
|
|
783
|
+
const y = entry.params.y
|
|
784
|
+
if (typeof x !== 'number' || typeof y !== 'number') throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID')
|
|
785
|
+
await this.click(session, { x, y })
|
|
786
|
+
this.record(s, 'replay', { seq, of: entry.action, x, y }, true)
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
case 'type': {
|
|
790
|
+
const text = entry.params.text
|
|
791
|
+
if (typeof text !== 'string') throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID')
|
|
792
|
+
await this.type(session, { text })
|
|
793
|
+
this.record(s, 'replay', { seq, of: entry.action, text }, true)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
case 'execute': {
|
|
797
|
+
const script = entry.params.script
|
|
798
|
+
if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
|
|
799
|
+
const result = await this.execute(session, { script })
|
|
800
|
+
this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
default:
|
|
804
|
+
throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE')
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Close the session and destroy all its views. Idempotent. */
|
|
809
|
+
close(session: BrowserSessionId): Promise<void> {
|
|
810
|
+
const existing = this.sessions.get(session)
|
|
811
|
+
if (existing !== undefined) {
|
|
812
|
+
this.sessions.delete(session)
|
|
813
|
+
for (const tab of existing.tabs) this.host.destroyView(tab.handle)
|
|
814
|
+
}
|
|
815
|
+
return Promise.resolve()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Look up a session or throw the unknown-session error. */
|
|
819
|
+
private session(session: BrowserSessionId): Session {
|
|
820
|
+
const existing = this.sessions.get(session)
|
|
821
|
+
if (existing === undefined) {
|
|
822
|
+
throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN')
|
|
823
|
+
}
|
|
824
|
+
return existing
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** The active tab of a session. */
|
|
828
|
+
private activeTab(s: Session): Tab {
|
|
829
|
+
const tab = s.tabs[s.activeIndex]
|
|
830
|
+
if (tab === undefined) throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN')
|
|
831
|
+
return tab
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Append a fresh tab and make it active. */
|
|
835
|
+
private newTab(s: Session): void {
|
|
836
|
+
const handle = this.host.createView()
|
|
837
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle })
|
|
838
|
+
s.activeIndex = s.tabs.length - 1
|
|
839
|
+
this.showActive(s)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Ask the host to show the active tab's view. */
|
|
843
|
+
private showActive(s: Session): void {
|
|
844
|
+
this.host.showView?.(this.activeTab(s).handle)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Read the current URL of a view through CDP. */
|
|
848
|
+
private async currentUrl(handle: ElectronViewHandle): Promise<string> {
|
|
849
|
+
const result = await handleSendEvaluate(handle, 'location.href')
|
|
850
|
+
return result.ok && typeof result.value === 'string' ? result.value : ''
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
856
|
+
* hanging the tool call forever. The caller's signal, when provided, wins
|
|
857
|
+
* over the timeout if it fires first.
|
|
858
|
+
* @param promise - the operation to bound.
|
|
859
|
+
* @param ms - the timeout budget.
|
|
860
|
+
* @param signal - optional caller signal.
|
|
861
|
+
* @param message - the timeout error message.
|
|
862
|
+
* @returns the promise's value, or a rejected promise on timeout/abort.
|
|
863
|
+
*/
|
|
864
|
+
function withTimeout<T>(
|
|
865
|
+
promise: Promise<T>,
|
|
866
|
+
ms: number,
|
|
867
|
+
signal: AbortSignal | undefined,
|
|
868
|
+
message: string,
|
|
869
|
+
): Promise<T> {
|
|
870
|
+
return new Promise<T>((resolve, reject) => {
|
|
871
|
+
let done = false
|
|
872
|
+
const timer = setTimeout(() => {
|
|
873
|
+
if (done) return
|
|
874
|
+
done = true
|
|
875
|
+
reject(new Error(message))
|
|
876
|
+
}, ms)
|
|
877
|
+
const finish = (fn: () => void): void => {
|
|
878
|
+
if (done) return
|
|
879
|
+
done = true
|
|
880
|
+
clearTimeout(timer)
|
|
881
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
882
|
+
fn()
|
|
883
|
+
}
|
|
884
|
+
const onAbort = (): void => {
|
|
885
|
+
if (done) return
|
|
886
|
+
done = true
|
|
887
|
+
clearTimeout(timer)
|
|
888
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
|
|
889
|
+
}
|
|
890
|
+
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
|
|
891
|
+
promise.then(
|
|
892
|
+
value => finish(() => resolve(value)),
|
|
893
|
+
error => finish(() => reject(error)),
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Run a `Runtime.evaluate` through a view handle and normalize the result.
|
|
900
|
+
* Shared by execute, snapshot, content, and internal URL reads.
|
|
901
|
+
* @param handle - the view handle to evaluate in.
|
|
902
|
+
* @param expression - the JS expression.
|
|
903
|
+
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
904
|
+
*/
|
|
905
|
+
async function handleSendEvaluate(
|
|
906
|
+
handle: ElectronViewHandle,
|
|
907
|
+
expression: string,
|
|
908
|
+
signal?: AbortSignal,
|
|
909
|
+
): Promise<BrowserExecuteResult> {
|
|
910
|
+
signal?.throwIfAborted()
|
|
911
|
+
const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
912
|
+
expression,
|
|
913
|
+
returnByValue: true,
|
|
914
|
+
awaitPromise: true,
|
|
915
|
+
} satisfies CdpEvaluateParams)
|
|
916
|
+
if (result.exceptionDetails !== undefined) {
|
|
917
|
+
const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
|
|
918
|
+
return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' }
|
|
919
|
+
}
|
|
920
|
+
return { ok: true, value: (result.result as { value?: unknown } | undefined)?.value ?? null }
|
|
921
|
+
}
|
|
922
|
+
|