dsh-inner-browser-use 0.11.44 → 0.13.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/sidecar.js ADDED
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * The sidecar browser: a real Chrome that the Host owns. // ui-copy-ignore
3
+ *
4
+ * A separate process with its own on-disk profile: a login survives a restart,
5
+ * and a page keeps working while the person looks somewhere else. The column
6
+ * shows it as a screencast, so the tab is a viewer, not the browser.
7
+ */
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ /** Chrome builds this machine may have, most preferred first. */
14
+ const CHROME_BINARIES = [
15
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
16
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
17
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
18
+ '/usr/bin/google-chrome',
19
+ '/usr/bin/chromium',
20
+ ]
21
+
22
+ /** Viewport the screencast and the pointer share. Nothing here is a user-agent detail. */
23
+ export const VIEW = { width: 1440, height: 900 }
24
+
25
+ /**
26
+ * Pixels the mirror actually sends. The viewport is never downscaled: a
27
+ * half-size JPEG turns 12px body text into mush in a column this narrow, and
28
+ * the sharpness is what makes the mirror feel like the page. Quality stops
29
+ * short of lossless so a moving picture still arrives on the next frame.
30
+ */
31
+ const MIRROR = { width: VIEW.width, height: VIEW.height }
32
+
33
+ /** Frames are dropped rather than queued: a lagging viewer must not slow the page. */
34
+ const MAX_INFLIGHT_FRAMES = 2
35
+
36
+ /** Idle lifetime, so a forgotten sidecar does not sit on a login forever. */
37
+ const IDLE_MS = 15 * 60_000
38
+
39
+ /** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
40
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
41
+
42
+ /** @param {string} path - candidate. @returns {string | undefined} the one that exists. */
43
+ function firstBinary() {
44
+ return CHROME_BINARIES.find((path) => existsSync(path))
45
+ }
46
+
47
+ /** Root of everything this plugin keeps for the sidecar. */
48
+ function rootDir() {
49
+ const dir = join(homedir(), '.dsh', 'inner-browser-use', 'chrome')
50
+ mkdirSync(dir, { recursive: true })
51
+ return dir
52
+ }
53
+
54
+ /** @param {string} name - profile name. @returns {string} its directory. */
55
+ function profileDir(name) {
56
+ const dir = join(rootDir(), String(name || 'default').replace(/[^\w.-]/g, '_'))
57
+ mkdirSync(dir, { recursive: true })
58
+ return dir
59
+ }
60
+
61
+ /**
62
+ * A minimal CDP session over one target's WebSocket.
63
+ *
64
+ * `flatten` sessions are not needed: the sidecar owns exactly one page, and a
65
+ * page-level socket addresses it without ids to get wrong.
66
+ */
67
+ class Cdp {
68
+ /** @param {string} url - the target's `webSocketDebuggerUrl`. */
69
+ constructor(url) {
70
+ this.url = url
71
+ this.next = 1
72
+ this.pending = new Map()
73
+ this.listeners = new Set()
74
+ this.closed = false
75
+ this.socket = new WebSocket(url)
76
+ this.ready = new Promise((resolve, reject) => {
77
+ this.socket.addEventListener('open', () => resolve(this))
78
+ this.socket.addEventListener('error', () => reject(new Error('sidecar: CDP socket failed')))
79
+ })
80
+ this.socket.addEventListener('message', (event) => {
81
+ let message
82
+ try {
83
+ message = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data))
84
+ } catch {
85
+ return
86
+ }
87
+ if (message.id !== undefined) {
88
+ const entry = this.pending.get(message.id)
89
+ if (entry === undefined) return
90
+ this.pending.delete(message.id)
91
+ if (message.error) entry.reject(new Error(`${entry.method}: ${message.error.message}`))
92
+ else entry.resolve(message.result ?? {})
93
+ return
94
+ }
95
+ for (const listener of this.listeners) listener(message)
96
+ })
97
+ this.socket.addEventListener('close', () => {
98
+ this.closed = true
99
+ for (const entry of this.pending.values()) entry.reject(new Error(`${entry.method}: sidecar closed`))
100
+ this.pending.clear()
101
+ })
102
+ }
103
+
104
+ close() {
105
+ this.socket.close()
106
+ }
107
+
108
+ /**
109
+ * @param {string} method - CDP method.
110
+ * @param {Record<string, unknown>} params - its parameters.
111
+ * @param {string} [sessionId] - a child target to run it in, for a popup or a frame Chrome runs elsewhere.
112
+ * @returns {Promise<any>} its result.
113
+ */
114
+ send(method, params = {}, sessionId) {
115
+ if (this.closed) return Promise.reject(new Error('sidecar: CDP session is closed'))
116
+ const id = this.next++
117
+ const message = { id, method, params }
118
+ if (sessionId !== undefined) message.sessionId = sessionId
119
+ return new Promise((resolve, reject) => {
120
+ this.pending.set(id, { resolve, reject, method })
121
+ this.socket.send(JSON.stringify(message))
122
+ })
123
+ }
124
+
125
+ /** @param {(message: any) => void} listener - called for every event. @returns {() => void} unsubscribe. */
126
+ on(listener) {
127
+ this.listeners.add(listener)
128
+ return () => this.listeners.delete(listener)
129
+ }
130
+
131
+ close() {
132
+ try {
133
+ this.socket.close()
134
+ } catch {
135
+ /* a socket that is already gone needs no closing */
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * One page of the sidecar, with its screencast and its input.
142
+ *
143
+ * Input is real CDP input: the page sees the same events a person's trackpad
144
+ * produces, which is what makes sites that reject scripted clicks work.
145
+ */
146
+ export class SidecarPage {
147
+ /** @param {Cdp} cdp - the page session. @param {string} profile - profile name. */
148
+ constructor(cdp, profile, port) { // ui-copy-ignore
149
+ this.cdp = cdp
150
+ this.profile = profile
151
+ this.port = port // ui-copy-ignore
152
+ this.epoch = 0
153
+ this.frames = new Set()
154
+ /* Pointer moves are their own, tiny message: the arrow follows the agent at
155
+ once instead of waiting for the next picture to be painted. */
156
+ this.pointerSinks = new Set()
157
+ this.latest = ''
158
+ this.seq = 0
159
+ /* Total JPEG bytes seen, for the bench and for noticing when a page got
160
+ expensive to watch. */
161
+ this.bytes = 0
162
+ this.pointer = { x: VIEW.width / 2, y: VIEW.height / 2 }
163
+ this.url = ''
164
+ this.lastUrl = ''
165
+ this.crashed = false
166
+ this.title = ''
167
+ this.casting = false
168
+ this.inflight = 0
169
+ this.touched = Date.now()
170
+ /* A dialog blocks the page until someone answers it, so it is state, not an
171
+ event: the driver can look at it before deciding. */
172
+ this.dialog = null
173
+ this.console = []
174
+ this.failed = []
175
+ /* What the page asked for, so a page that fills itself from addresses can
176
+ be understood without a screenshot or a retry. */
177
+ this.network = []
178
+ this.requests = new Map()
179
+ /* frame id -> the world its page script runs in. A control inside an iframe
180
+ is only reachable through that world; the frame list is read when the
181
+ read model needs it. */
182
+ this.contexts = new Map()
183
+ this.frameOrder = []
184
+ /* A click can open a tab. Chrome tells us the new target; the mirror then
185
+ follows it, because a person looking at the column expects that too. */
186
+ this.children = new Map()
187
+ this.opened = null
188
+ this.port = Number(port ?? 0) // ui-copy-ignore
189
+ }
190
+
191
+ /**
192
+ * Record the page events that matter to a driver: dialogs, console lines and
193
+ * failed requests. Buffers are capped so a chatty page cannot grow memory.
194
+ * @param {any} message - one CDP message.
195
+ */
196
+ watch(message) {
197
+ if (message.method === 'Runtime.executionContextCreated') {
198
+ const context = message.params?.context ?? {}
199
+ const aux = context.auxData ?? {}
200
+ if (aux.isDefault === true && typeof aux.frameId === 'string') this.contexts.set(aux.frameId, Number(context.id))
201
+ return
202
+ }
203
+ if (message.method === 'Runtime.executionContextDestroyed') {
204
+ const gone = Number(message.params?.executionContextId)
205
+ for (const [frameId, contextId] of this.contexts) if (contextId === gone) this.contexts.delete(frameId)
206
+ return
207
+ }
208
+ if (message.method === 'Runtime.executionContextsCleared') {
209
+ this.contexts.clear()
210
+ return
211
+ }
212
+ if (message.method === 'Target.attachedToTarget') {
213
+ const params = message.params ?? {}
214
+ const sessionId = String(params.sessionId ?? '')
215
+ const info = params.targetInfo ?? {}
216
+ if (sessionId === '') return
217
+ this.children.set(sessionId, { targetId: String(info.targetId ?? ''), type: String(info.type ?? ''), url: String(info.url ?? '') })
218
+ /* A target that waits for the debugger must be let go at once, or the
219
+ tab a person just asked for never loads. */
220
+ void this.cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {})
221
+ if (String(info.type ?? '') === 'page') {
222
+ this.opened = { targetId: String(info.targetId ?? ''), url: String(info.url ?? '') }
223
+ }
224
+ return
225
+ }
226
+ if (message.method === 'Target.detachedFromTarget') {
227
+ this.children.delete(String(message.params?.sessionId ?? ''))
228
+ return
229
+ }
230
+ if (message.method === 'Target.targetInfoChanged') {
231
+ const info = message.params?.targetInfo ?? {}
232
+ const targetId = String(info.targetId ?? '')
233
+ for (const child of this.children.values()) if (child.targetId === targetId) child.url = String(info.url ?? '')
234
+ return
235
+ }
236
+ if (message.method === 'Page.javascriptDialogOpening') {
237
+ this.dialog = message.params ?? {}
238
+ return
239
+ }
240
+ if (message.method === 'Page.javascriptDialogClosed') {
241
+ this.dialog = null
242
+ return
243
+ }
244
+ if (message.method === 'Runtime.exceptionThrown') {
245
+ const detail = message.params?.exceptionDetails ?? {}
246
+ this.console.push({ type: 'error', text: String(detail.exception?.description ?? detail.text ?? '').slice(0, 300) })
247
+ if (this.console.length > 200) this.console.splice(0, 50)
248
+ return
249
+ }
250
+ if (message.method === 'Runtime.consoleAPICalled') {
251
+ const params = message.params ?? {}
252
+ const text = (params.args ?? []).map((arg) => String(arg.value ?? arg.description ?? '')).join(' ').slice(0, 300)
253
+ this.console.push({ type: String(params.type ?? 'log'), text })
254
+ if (this.console.length > 200) this.console.splice(0, 50)
255
+ return
256
+ }
257
+ if (message.method === 'Network.requestWillBeSent') {
258
+ const params = message.params ?? {}
259
+ const request = params.request ?? {}
260
+ const id = String(params.requestId ?? '')
261
+ if (id === '') return
262
+ this.requests.set(id, {
263
+ method: String(request.method ?? 'GET'),
264
+ url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
265
+ type: String(params.type ?? ''),
266
+ status: 0,
267
+ })
268
+ if (this.requests.size > 200) this.requests.delete(this.requests.keys().next().value)
269
+ return
270
+ }
271
+ if (message.method === 'Network.responseReceived') {
272
+ const params = message.params ?? {}
273
+ const known = this.requests.get(String(params.requestId ?? ''))
274
+ if (known === undefined) return
275
+ known.status = Number(params.response?.status ?? 0)
276
+ this.network.push(known)
277
+ if (this.network.length > 120) this.network.splice(0, 40)
278
+ return
279
+ }
280
+ if (message.method === 'Network.loadingFailed') {
281
+ const params = message.params ?? {}
282
+ if (params.canceled === true) return
283
+ this.failed.push({ error: String(params.errorText ?? ''), type: String(params.type ?? '') })
284
+ if (this.failed.length > 100) this.failed.splice(0, 25)
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Follow a tab the page just opened: the mirror shows the new page and the
290
+ * popup is closed, so there is still one page to drive.
291
+ * @returns {Promise<string>} what happened, for the tool result.
292
+ */
293
+ /**
294
+ * The session Chrome gave a frame it runs in its own process, so a read can be
295
+ * sent there. Chrome names an iframe target after its frame.
296
+ * @param {string} frameId - a frame from the frame tree.
297
+ * @returns {string|undefined} the session, when one exists.
298
+ */
299
+ childSession(frameId) {
300
+ for (const [sessionId, child] of this.children) if (child.targetId === frameId) return sessionId
301
+ return undefined
302
+ }
303
+
304
+ /**
305
+ * A tab this page opened, found through the DevTools address list. Chrome
306
+ * gives a page no handle on its own popups, but the list is enough to know
307
+ * one exists and where it went.
308
+ * @returns {Promise<{targetId: string, url: string}|null>} the popup.
309
+ */
310
+ async findPopup() {
311
+ if (this.port <= 0) return null // ui-copy-ignore
312
+ const main = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
313
+ const list = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
314
+ .then((response) => (response.ok ? response.json() : []))
315
+ .catch(() => [])
316
+ const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main)
317
+ const popup = pages.find((item) => /^https?:|^file:/.test(String(item.url ?? '')))
318
+ if (popup !== undefined) return { targetId: String(popup.id), url: String(popup.url) }
319
+ const blank = pages[0]
320
+ return blank === undefined ? null : { targetId: String(blank.id), url: String(blank.url ?? '') }
321
+ }
322
+
323
+ /**
324
+ * The browser's pages, in the order the browser lists them. More than one
325
+ * exists when a site opens a window the driver did not follow.
326
+ * @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
327
+ */
328
+ async tabs() {
329
+ const port = this.port // ui-copy-ignore
330
+ if (port <= 0) return [] // ui-copy-ignore
331
+ const list = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
332
+ .then((response) => (response.ok ? response.json() : []))
333
+ .catch(() => [])
334
+ const current = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
335
+ return (Array.isArray(list) ? list : [])
336
+ .filter((item) => item.type === 'page' && item.webSocketDebuggerUrl !== undefined)
337
+ .map((item) => ({ targetId: String(item.id), title: String(item.title ?? ''), url: String(item.url ?? ''), webSocketUrl: String(item.webSocketDebuggerUrl), current: String(item.id) === current }))
338
+ }
339
+
340
+ /**
341
+ * Follow a tab the page just opened: the mirror shows where the person would
342
+ * have ended up, and the popup is closed so there is still one page to drive.
343
+ * @returns {Promise<string>} what happened, for the tool result.
344
+ */
345
+ async adoptOpened() {
346
+ let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
347
+ this.opened = null
348
+ if (target === null) {
349
+ /* The target list is written before the popup finished loading, so give a
350
+ click that opens one a moment to say where it is going. */
351
+ for (let attempt = 0; attempt < 3 && target === null; attempt += 1) {
352
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150))
353
+ const found = await this.findPopup()
354
+ if (found !== null && found.url !== '') target = found
355
+ else if (found !== null && attempt === 2) target = found
356
+ }
357
+ }
358
+ if (target === null) return ''
359
+ const url = String(target.url ?? '')
360
+ await this.cdp.send('Target.closeTarget', { targetId: target.targetId }).catch(() => {})
361
+ if (this.port > 0) await fetch(`http://127.0.0.1:${this.port}/json/close/${target.targetId}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
362
+ if (!/^https?:|^file:/.test(url)) return ''
363
+ await this.cdp.send('Page.navigate', { url }).catch(() => {})
364
+ return `opened ${url}`
365
+ }
366
+
367
+ /**
368
+ * The frames of the page, main frame first, each with the context its scripts
369
+ * run in. Read on demand: a page with no iframes pays one call.
370
+ * @returns {Promise<Array<{frameId: string, url: string, contextId?: number}>>} the frames.
371
+ */
372
+ async frameList() {
373
+ const tree = await this.cdp.send('Page.getFrameTree').catch(() => null)
374
+ const out = []
375
+ const walk = (node) => {
376
+ if (node === null || node === undefined || typeof node !== 'object') return
377
+ const frameId = String(node.frame?.id ?? '')
378
+ if (frameId !== '') {
379
+ const contextId = this.contexts.get(frameId)
380
+ out.push({ frameId, url: String(node.frame?.url ?? ''), contextId: contextId === undefined ? undefined : Number(contextId) })
381
+ }
382
+ for (const child of node.childFrames ?? []) walk(child)
383
+ }
384
+ walk(tree?.frameTree)
385
+ /* A frame Chrome runs in another process is not in this tree, but it is
386
+ attached as its own target; list it after the tree so it gets a slot. */
387
+ const known = new Set(out.map((item) => item.frameId))
388
+ for (const [sessionId, child] of this.children) {
389
+ if (child.type !== 'iframe' || known.has(child.targetId)) continue
390
+ if (sessionId === '') continue
391
+ out.push({ frameId: child.targetId, url: String(child.url ?? ''), contextId: undefined })
392
+ known.add(child.targetId)
393
+ }
394
+ this.frameOrder = out.map((item) => item.frameId)
395
+ return out
396
+ }
397
+
398
+ /**
399
+ * Responses this page received, newest first.
400
+ * @returns {{method: string, url: string, type: string, status: number}[]} what it asked for.
401
+ */
402
+ networkList() {
403
+ return this.network.slice(-120).reverse()
404
+ }
405
+
406
+ /**
407
+ * Files this browser has downloaded, newest first.
408
+ * @returns {{name: string, bytes: number, at: string}[]} what is on disk.
409
+ */
410
+ downloads() {
411
+ const folder = this.downloadsFolder ?? downloadsDir(profileDir(this.profile))
412
+ let names = []
413
+ try {
414
+ names = readdirSync(folder)
415
+ } catch (error) {
416
+ return []
417
+ }
418
+ const found = []
419
+ for (const name of names) {
420
+ if (name.endsWith('.crdownload')) continue
421
+ const path = join(folder, name)
422
+ try {
423
+ const info = statSync(path)
424
+ if (!info.isFile()) continue
425
+ found.push({ name, bytes: info.size, at: new Date(info.mtimeMs).toISOString() })
426
+ } catch (error) {
427
+ continue
428
+ }
429
+ }
430
+ found.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
431
+ return found.slice(0, 20)
432
+ }
433
+
434
+ /**
435
+ * Where the owner element of a frame sits on the page, for a frame whose own
436
+ * process cannot answer. The page's DOM knows the iframe element.
437
+ * @param {string} frameId - a frame from the frame list.
438
+ * @returns {Promise<{x: number, y: number}|null>} the corner, when known.
439
+ */
440
+ async frameBox(frameId) {
441
+ const owner = await this.cdp.send('DOM.getFrameOwner', { frameId }).catch(() => null)
442
+ const backendNodeId = owner?.backendNodeId
443
+ if (backendNodeId === undefined) return null
444
+ const node = await this.cdp.send('DOM.resolveNode', { backendNodeId }).catch(() => null)
445
+ const objectId = node?.object?.objectId
446
+ if (objectId === undefined) return null
447
+ const said = await this.cdp.send('Runtime.callFunctionOn', {
448
+ objectId,
449
+ functionDeclaration: 'function () { const b = this.getBoundingClientRect(); return { x: Math.round(b.left), y: Math.round(b.top) } }',
450
+ returnByValue: true,
451
+ }).catch(() => null)
452
+ const spot = said?.result?.value
453
+ if (spot === null || spot === undefined || !Number.isFinite(spot.x)) return null
454
+ return { x: Number(spot.x), y: Number(spot.y) }
455
+ }
456
+
457
+ /** Keep one listener that fans frames out to every viewer. */
458
+ async start() {
459
+ await this.cdp.send('Network.enable').catch(() => {})
460
+ /* A click that downloads should leave a file, not nothing: point downloads
461
+ at the profile so the driver can report where they went. */
462
+ const folder = downloadsDir(profileDir(this.profile))
463
+ mkdirSync(folder, { recursive: true })
464
+ this.downloadsFolder = folder
465
+ await this.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: folder }).catch(() => {})
466
+ /* Popups and out-of-process frames are separate targets. Attaching without
467
+ waiting keeps them usable, and lets the mirror follow a new tab. */
468
+ await this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => {})
469
+ this.cdp.on((message) => {
470
+ this.watch(message)
471
+ if (message.method !== 'Page.screencastFrame') return
472
+ const { data, sessionId, metadata } = message.params ?? {}
473
+ void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
474
+ if (typeof data !== 'string' || data === '') return
475
+ this.latest = data
476
+ this.seq += 1
477
+ this.bytes += data.length
478
+ if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
479
+ if (this.inflight >= MAX_INFLIGHT_FRAMES) return
480
+ this.inflight += 1
481
+ const frame = {
482
+ seq: this.seq, // ui-copy-ignore
483
+ w: VIEW.width, // ui-copy-ignore
484
+ h: VIEW.height, // ui-copy-ignore
485
+ jpeg: data, // ui-copy-ignore
486
+ url: this.url, // ui-copy-ignore
487
+ title: this.title, // ui-copy-ignore
488
+ pointer: this.pointer, // ui-copy-ignore
489
+ }
490
+ for (const sink of this.frames) {
491
+ try {
492
+ sink(frame)
493
+ } catch {
494
+ /* a closed viewer is dropped by its own stream handler */
495
+ }
496
+ }
497
+ this.inflight -= 1
498
+ })
499
+ this.cdp.on((message) => {
500
+ if (message.method === 'Page.frameNavigated' && message.params?.frame?.parentId === undefined) {
501
+ this.url = String(message.params.frame.url ?? '')
502
+ void this.readTitle()
503
+ }
504
+ if (message.method === 'Page.loadEventFired') void this.readTitle()
505
+ })
506
+ await this.cdp.send('Page.enable')
507
+ /* An instance left behind by a stopped app can still be sitting on a
508
+ dialog; a paused renderer answers nothing, so clear it first. */
509
+ await Promise.race([
510
+ this.cdp.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}),
511
+ new Promise((resolve) => setTimeout(resolve, 800)),
512
+ ])
513
+ await this.cdp.send('Runtime.enable')
514
+ await this.cdp.send('Emulation.setDeviceMetricsOverride', {
515
+ width: VIEW.width, // ui-copy-ignore
516
+ height: VIEW.height, // ui-copy-ignore
517
+ deviceScaleFactor: 1,
518
+ mobile: false, // ui-copy-ignore
519
+ })
520
+ /* A headless build announces itself in the UA, and some sites refuse that
521
+ string outright. The page is a normal Chrome here, so it says so. */
522
+ const { product } = await this.cdp.send('Browser.getVersion').catch(() => ({ product: '' }))
523
+ if (String(product).includes('HeadlessChrome')) {
524
+ const version = String(product).replace(/.*HeadlessChrome\//, '')
525
+ await this.cdp.send('Emulation.setUserAgentOverride', {
526
+ userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version.split('.')[0]}.0.0.0 Safari/537.36`,
527
+ acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
528
+ platform: 'MacIntel',
529
+ })
530
+ }
531
+ await this.readTitle()
532
+ return this
533
+ }
534
+
535
+ async readTitle() {
536
+ try {
537
+ const call = this.cdp.send('Runtime.evaluate', {
538
+ expression: 'JSON.stringify({url: location.href, title: document.title})',
539
+ returnByValue: true,
540
+ })
541
+ const answer = await Promise.race([call, new Promise((resolve) => setTimeout(resolve, 1500))])
542
+ const result = answer?.result
543
+ const value = JSON.parse(String(result?.value ?? '{}'))
544
+ this.url = String(value.url ?? this.url)
545
+ this.title = String(value.title ?? this.title)
546
+ } catch {
547
+ /* a page mid-navigation has no title yet */
548
+ }
549
+ }
550
+
551
+ /** @returns {Promise<void>} resolves once the page is casting. */
552
+ async cast() {
553
+ if (this.casting) return
554
+ this.casting = true
555
+ /* Full-resolution mirror at a quality that keeps small text readable;
556
+ `optimizeForSpeed` keeps the encoder from adding to the round trip. */
557
+ await this.cdp.send('Page.startScreencast', {
558
+ format: 'jpeg', // ui-copy-ignore
559
+ quality: 72, // ui-copy-ignore
560
+ maxWidth: MIRROR.width, // ui-copy-ignore
561
+ maxHeight: MIRROR.height,
562
+ optimizeForSpeed: true,
563
+ everyNthFrame: 1,
564
+ }).catch(() => {
565
+ this.casting = false
566
+ })
567
+ }
568
+
569
+ async stopCast() {
570
+ if (!this.casting) return
571
+ this.casting = false
572
+ await this.cdp.send('Page.stopScreencast').catch(() => {})
573
+ }
574
+
575
+ /**
576
+ * @param {string} url - address to open.
577
+ * @returns {Promise<{url: string, title: string}>} where it landed.
578
+ */
579
+ async navigate(url) {
580
+ this.touched = Date.now()
581
+ this.lastUrl = String(url)
582
+ await this.cdp.send('Page.navigate', { url })
583
+ for (let i = 0; i < 200; i += 1) {
584
+ await wait(100)
585
+ await this.readTitle()
586
+ if (this.url !== '' && this.url !== 'about:blank') break
587
+ }
588
+ return { url: this.url, title: this.title }
589
+ }
590
+
591
+ /**
592
+ * A renderer that crashed stops answering. Asking for one number is the
593
+ * cheapest way to tell a busy page from a dead one.
594
+ * @returns {Promise<boolean>} whether the page answered.
595
+ */
596
+ async ping() {
597
+ try {
598
+ const answer = await Promise.race([
599
+ this.cdp.send('Runtime.evaluate', { expression: '1', returnByValue: true }),
600
+ wait(600).then(() => { throw new Error('no answer') }),
601
+ ])
602
+ return { ok: answer !== undefined && answer !== null, closed: false }
603
+ } catch {
604
+ /* A closed connection answers at once; a busy page only times out. The
605
+ difference matters: one is replaced now, the other gets one more try. */
606
+ return { ok: false, closed: this.cdp?.closed === true }
607
+ }
608
+ }
609
+
610
+ async reload() {
611
+ this.touched = Date.now()
612
+ await this.cdp.send('Page.reload', {})
613
+ await wait(400)
614
+ await this.readTitle()
615
+ return { url: this.url, title: this.title }
616
+ }
617
+
618
+ /** @param {number} delta - negative goes back. @returns {Promise<{url: string}>} where it landed. */
619
+ async history(delta) {
620
+ const { currentIndex, entries } = await this.cdp.send('Page.getNavigationHistory')
621
+ const entry = entries?.[currentIndex + delta]
622
+ if (entry === undefined) return { url: this.url, moved: false }
623
+ await this.cdp.send('Page.navigateToHistoryEntry', { entryId: entry.id })
624
+ await wait(500)
625
+ await this.readTitle()
626
+ return { url: this.url, moved: true }
627
+ }
628
+
629
+ /** @param {{x: number, y: number}} point - where the pointer goes. */
630
+ movePointer(point) {
631
+ this.pointer = { x: Math.round(point.x), y: Math.round(point.y) }
632
+ this.touched = Date.now()
633
+ const message = { type: 'pointer', w: VIEW.width, h: VIEW.height, pointer: this.pointer }
634
+ for (const sink of this.pointerSinks) {
635
+ try {
636
+ sink(message)
637
+ } catch {
638
+ /* a closed viewer is dropped by its own stream handler */
639
+ }
640
+ }
641
+ }
642
+
643
+ /**
644
+ * The cursor shape the page would show at a point. This is a read: it never
645
+ * changes the page.
646
+ *
647
+ * @param {{x: number, y: number}} point - where to look, in viewport pixels.
648
+ * @returns {Promise<string>} a CSS cursor keyword the Client can apply.
649
+ */
650
+ async cursorAt(point) {
651
+ const expression = `(() => {
652
+ const el = document.elementFromPoint(${Math.round(point.x)}, ${Math.round(point.y)})
653
+ if (el === null) return 'default'
654
+ return getComputedStyle(el).cursor || 'default'
655
+ })()`
656
+ try {
657
+ const { result } = await this.cdp.send('Runtime.evaluate', { expression, returnByValue: true })
658
+ return String(result?.value ?? 'default')
659
+ } catch {
660
+ return 'default'
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Move the page's own hover state without moving the agent's pointer: the
666
+ * person's hand is a real cursor here, and the black arrow marks the agent.
667
+ *
668
+ * @param {{x: number, y: number}} point - where the hand is, in viewport pixels.
669
+ * @returns {Promise<string>} the cursor shape the page shows under it.
670
+ */
671
+ /**
672
+ * Send one input event and do not wait on a renderer that a dialog or a long
673
+ * task has paused. The event still lands; only the acknowledgement is dropped.
674
+ * @param {string} method - Input.* method.
675
+ * @param {object} params - its parameters.
676
+ * @returns {Promise<void>} done when sent or when the wait gives up.
677
+ */
678
+ async input(method, params) {
679
+ await Promise.race([
680
+ this.cdp.send(method, params).catch(() => {}),
681
+ new Promise((resolve) => setTimeout(resolve, 1200)),
682
+ ])
683
+ }
684
+
685
+ async hover(point) {
686
+ await this.input('Input.dispatchMouseEvent', {
687
+ type: 'mouseMoved', // ui-copy-ignore
688
+ x: Math.round(point.x),
689
+ y: Math.round(point.y),
690
+ button: 'none', // ui-copy-ignore
691
+ buttons: 0, // ui-copy-ignore
692
+ clickCount: 0,
693
+ pointerType: 'mouse',
694
+ })
695
+ return this.cursorAt(point)
696
+ }
697
+
698
+ /**
699
+ * @param {string} type - `mousePressed`, `mouseReleased` or `mouseMoved`.
700
+ * @param {{x: number, y: number, button?: string, clickCount?: number}} point - CSS pixels.
701
+ * @returns {Promise<void>} resolves when Chrome took the event.
702
+ */
703
+ async mouse(type, point) {
704
+ this.movePointer(point)
705
+ await this.input('Input.dispatchMouseEvent', {
706
+ type,
707
+ x: point.x,
708
+ y: point.y,
709
+ button: point.button ?? 'left',
710
+ buttons: type === 'mouseReleased' ? 0 : 1,
711
+ clickCount: point.clickCount ?? (type === 'mouseMoved' ? 0 : 1),
712
+ pointerType: 'mouse',
713
+ })
714
+ }
715
+
716
+ /**
717
+ * @param {{x: number, y: number, dx?: number, dy?: number}} args - wheel over a point.
718
+ * @returns {Promise<void>} resolves when Chrome took it.
719
+ */
720
+ async wheel(args) {
721
+ this.movePointer(args)
722
+ await this.input('Input.dispatchMouseEvent', {
723
+ type: 'mouseWheel', // ui-copy-ignore
724
+ x: args.x, // ui-copy-ignore
725
+ y: args.y, // ui-copy-ignore
726
+ deltaX: args.dx ?? 0, // ui-copy-ignore
727
+ deltaY: args.dy ?? 0, // ui-copy-ignore
728
+ pointerType: 'mouse',
729
+ })
730
+ }
731
+
732
+ /**
733
+ * @param {string} type - `keyDown` or `keyUp`.
734
+ * @param {{key: string, code?: string, keyCode?: number, text?: string, modifiers?: number}} key - one key.
735
+ * @returns {Promise<void>} resolves when Chrome took it.
736
+ */
737
+ async key(type, key) {
738
+ this.touched = Date.now()
739
+ const virtual = key.keyCode ?? key.windowsVirtualKeyCode ?? 0
740
+ await this.input('Input.dispatchKeyEvent', {
741
+ type: key.text === undefined || key.text === '' ? type : (type === 'keyDown' ? 'keyDown' : type),
742
+ key: key.key, // ui-copy-ignore
743
+ code: key.code ?? '', // ui-copy-ignore
744
+ windowsVirtualKeyCode: virtual,
745
+ nativeVirtualKeyCode: key.nativeVirtualKeyCode ?? virtual,
746
+ text: key.text, // ui-copy-ignore
747
+ unmodifiedText: key.text,
748
+ modifiers: key.modifiers ?? 0,
749
+ })
750
+ }
751
+
752
+ /** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
753
+ async insertText(text) {
754
+ this.touched = Date.now()
755
+ await this.input('Input.insertText', { text })
756
+ }
757
+
758
+ /** @returns {Promise<string>} a full-size screenshot as base64 PNG. */
759
+ async screenshot() {
760
+ this.touched = Date.now()
761
+ const { data } = await this.cdp.send('Page.captureScreenshot', { format: 'png' })
762
+ return String(data ?? '')
763
+ }
764
+
765
+ /**
766
+ * @param {{full?: boolean, rect?: {x: number, y: number, width: number, height: number}, scale?: number}} [options] - what to capture.
767
+ * @returns {Promise<string>} base64 PNG; `full` ignores the viewport.
768
+ */
769
+ async shot(options = {}) {
770
+ this.touched = Date.now()
771
+ /** @type {any} */
772
+ const args = { format: 'png' }
773
+ if (options.rect) {
774
+ args.clip = { ...options.rect, scale: options.scale ?? 1 }
775
+ args.captureBeyondViewport = options.full === true
776
+ } else if (options.full === true) {
777
+ const { result } = await this.cdp.send('Runtime.evaluate', {
778
+ expression: 'JSON.stringify({w: Math.max(document.documentElement.scrollWidth, innerWidth), h: Math.max(document.documentElement.scrollHeight, innerHeight)})',
779
+ returnByValue: true,
780
+ })
781
+ const size = JSON.parse(String(result?.value ?? '{"w":1440,"h":900}'))
782
+ args.clip = { x: 0, y: 0, width: Math.min(size.w, 8000), height: Math.min(size.h, 20000), scale: options.scale ?? 1 }
783
+ args.captureBeyondViewport = true
784
+ }
785
+ const { data } = await this.cdp.send('Page.captureScreenshot', args)
786
+ return String(data ?? '')
787
+ }
788
+
789
+ /** @returns {Promise<string>} base64 PDF of the whole document. */
790
+ async pdf() {
791
+ this.touched = Date.now()
792
+ const { data } = await this.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true })
793
+ return String(data ?? '')
794
+ }
795
+
796
+ /** Allow the page to use the clipboard; without this the API rejects. */
797
+ async allowClipboard() {
798
+ let origin = 'http://localhost'
799
+ try {
800
+ origin = new URL(this.url).origin
801
+ } catch {
802
+ /* a blank page has no origin yet */
803
+ }
804
+ await this.cdp.send('Browser.grantPermissions', {
805
+ permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
806
+ origin,
807
+ }).catch(() => {})
808
+ }
809
+
810
+ /** @returns {Promise<string>} clipboard text, or an empty string when the page cannot read it. */
811
+ async clipboardRead() {
812
+ await this.allowClipboard()
813
+ const { result } = await this.cdp.send('Runtime.evaluate', {
814
+ expression: 'navigator.clipboard.readText().then((text) => JSON.stringify({ text }))',
815
+ awaitPromise: true,
816
+ returnByValue: true,
817
+ userGesture: true,
818
+ })
819
+ try {
820
+ return String(JSON.parse(String(result?.value ?? '{"text":""}')).text ?? '')
821
+ } catch {
822
+ return ''
823
+ }
824
+ }
825
+
826
+ /** @param {string} text - what to put on the clipboard. @returns {Promise<void>} resolves when written. */
827
+ async clipboardWrite(text) {
828
+ await this.allowClipboard()
829
+ await this.cdp.send('Runtime.evaluate', {
830
+ expression: `navigator.clipboard.writeText(${JSON.stringify(text)}).then(() => true)`,
831
+ awaitPromise: true,
832
+ returnByValue: true,
833
+ userGesture: true,
834
+ })
835
+ }
836
+
837
+ /** @param {number} sessionId - the screencast frame being acknowledged. */
838
+ /**
839
+ * The cookies this profile holds, newest state, straight from the browser.
840
+ * @param {string[]} [urls] - only cookies the given addresses would send.
841
+ * @returns {Promise<Array<{name: string, value: string, domain: string, path: string, secure: boolean, httpOnly: boolean}>>} cookies.
842
+ */
843
+ async cookies(urls) {
844
+ if (Array.isArray(urls) && urls.length > 0) {
845
+ const { cookies: found } = await this.cdp.send('Network.getCookies', { urls })
846
+ return found ?? []
847
+ }
848
+ const { cookies: all } = await this.cdp.send('Network.getAllCookies')
849
+ return all ?? []
850
+ }
851
+
852
+ /**
853
+ * @param {{name: string, value: string, domain?: string, path?: string, secure?: boolean, httpOnly?: boolean, sameSite?: string}} cookie - what to keep.
854
+ * @returns {Promise<{ok: boolean, reason?: string}>} whether the browser took it.
855
+ */
856
+ async setCookie(cookie) {
857
+ const params = {
858
+ name: cookie.name, // ui-copy-ignore
859
+ value: cookie.value, // ui-copy-ignore
860
+ path: cookie.path ?? '/', // ui-copy-ignore
861
+ secure: cookie.secure === true,
862
+ httpOnly: cookie.httpOnly === true,
863
+ }
864
+ if (cookie.domain !== undefined) params.domain = cookie.domain
865
+ else params.url = this.url
866
+ if (cookie.sameSite !== undefined) params.sameSite = cookie.sameSite
867
+ const answer = await this.cdp.send('Network.setCookie', params)
868
+ return { ok: answer?.success === true, reason: String(answer?.error ?? '') }
869
+ }
870
+
871
+ /** @returns {Promise<void>} drops every cookie in this profile. */
872
+ async clearCookies() {
873
+ await this.cdp.send('Network.clearBrowserCookies')
874
+ }
875
+
876
+ async ack(sessionId) {
877
+ await this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
878
+ }
879
+ }
880
+
881
+ /**
882
+ * The Host's own Chrome. // ui-copy-ignore
883
+ *
884
+ * One instance per plugin, reattachable: a plugin reload must not log the
885
+ * person out, so an already-running sidecar is adopted through its port file // ui-copy-ignore
886
+ * instead of being spawned again.
887
+ */
888
+ /** Where a profile keeps the cookies a browser would otherwise drop on exit. */
889
+ function sessionJar(dir) {
890
+ return join(dir, 'session-cookies.json') // ui-copy-ignore
891
+ }
892
+
893
+ /** Where a profile keeps what the page downloads. */
894
+ function downloadsDir(dir) {
895
+ return join(dir, 'downloads')
896
+ }
897
+
898
+ export class Sidecar {
899
+ /** @param {{logger?: any}} options - logging. */
900
+ constructor(options = {}) {
901
+ this.logger = options.logger
902
+ this.child = null
903
+ this.cdp = null
904
+ this.page = null
905
+ /* A measurement or a test may want a profile of its own; the person's
906
+ browser keeps the default one. */
907
+ this.profile = String(options.profile ?? 'default')
908
+ this.headless = true
909
+ this.starting = null
910
+ this.idle = null
911
+ this.lastError = ''
912
+ this.lastUrl = ''
913
+ }
914
+
915
+ /**
916
+ * Files downloaded so far. Asking a stopped browser is not an error.
917
+ * @returns {{name: string, bytes: number, at: string}[]} what is on disk.
918
+ */
919
+ downloads() {
920
+ if (this.page === null) return []
921
+ return this.page.downloads()
922
+ }
923
+
924
+ /**
925
+ * Responses so far. Asking a stopped browser is an empty list, not an error.
926
+ * @returns {{method: string, url: string, type: string, status: number}[]} the requests.
927
+ */
928
+ network() {
929
+ if (this.page === null) return []
930
+ return this.page.networkList()
931
+ }
932
+
933
+ get alive() {
934
+ return this.cdp !== null && !this.cdp.closed && this.page !== null
935
+ }
936
+
937
+ /** @param {string} text - why the sidecar is not usable. */
938
+ note(text) {
939
+ this.lastError = text
940
+ this.logger?.warn?.('dsh-inner-browser-use: sidecar %s', text)
941
+ }
942
+
943
+ /**
944
+ * @param {{profile?: string, headless?: boolean, url?: string}} [options] - how to start.
945
+ * @returns {Promise<SidecarPage>} the page being driven.
946
+ */
947
+ async ensure(options = {}) {
948
+ if (options.profile !== undefined) this.profile = options.profile
949
+ if (options.headless !== undefined) this.headless = options.headless !== false
950
+ if (this.alive) {
951
+ const page = this.page
952
+ /* A page that stopped answering is a page nobody can drive. Start a new
953
+ one on the address it was showing, rather than failing every call. */
954
+ let probe = page.dialog !== null && page.dialog !== undefined ? { ok: true } : await page.ping()
955
+ if (!probe.ok && !probe.closed) {
956
+ await wait(150)
957
+ probe = await page.ping()
958
+ }
959
+ if (!probe.ok) {
960
+ page.crashed = true
961
+ this.note('renderer stopped answering; opening a fresh tab')
962
+ const wasAt = options.url ?? page.lastUrl ?? this.lastUrl
963
+ /* The browser itself is usually fine: only the renderer died. Opening a
964
+ fresh tab in it is much faster than starting Chrome again. */
965
+ try {
966
+ return await this.recover(wasAt)
967
+ } catch {
968
+ await this.shutdown()
969
+ return this.ensure({ ...options, url: wasAt })
970
+ }
971
+ }
972
+ this.touch()
973
+ if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
974
+ return this.page
975
+ }
976
+ if (this.starting !== null) return this.starting
977
+ /* A page whose connection closed on its own (a renderer crash closes the
978
+ tab's session) can be replaced in the browser that is still running. */
979
+ if (this.page !== null && this.page.cdp?.closed === true) {
980
+ const wasAt = options.url ?? this.page.lastUrl ?? this.lastUrl
981
+ this.page.crashed = true
982
+ this.note('renderer stopped answering; opening a fresh tab')
983
+ this.starting = this.recover(wasAt).catch((error) => {
984
+ this.note(String(error?.message ?? error))
985
+ return this.launch(wasAt)
986
+ }).finally(() => {
987
+ this.starting = null
988
+ })
989
+ return this.starting
990
+ }
991
+ this.starting = this.launch(options.url).finally(() => {
992
+ this.starting = null
993
+ })
994
+ return this.starting
995
+ }
996
+
997
+ /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
998
+ async launch(url) {
999
+ const dir = profileDir(this.profile)
1000
+ let port = await this.runningPort(dir) // ui-copy-ignore
1001
+ if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
1002
+ const ws = await this.pageSocket(port) // ui-copy-ignore
1003
+ const cdp = new Cdp(ws)
1004
+ await cdp.ready
1005
+ this.cdp = cdp
1006
+ this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1007
+ if (url !== undefined && url !== '') this.lastUrl = String(url)
1008
+ await this.restoreSessionCookies()
1009
+ await this.page.cast()
1010
+ if (url !== undefined && url !== '') await this.page.navigate(url)
1011
+ this.touch()
1012
+ this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
1013
+ return this.page
1014
+ }
1015
+
1016
+ /**
1017
+ * @param {string} dir - profile directory.
1018
+ * @returns {Promise<number>} the port of a sidecar already using it, or 0. // ui-copy-ignore
1019
+ */
1020
+ async runningPort(dir) {
1021
+ const file = join(dir, 'DevToolsActivePort')
1022
+ if (!existsSync(file)) return 0
1023
+ const claimed = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0)
1024
+ if (!Number.isFinite(claimed) || claimed <= 0) return 0
1025
+ try {
1026
+ const response = await fetch(`http://127.0.0.1:${claimed}/json/version`, { signal: AbortSignal.timeout(1200) })
1027
+ if (!response.ok) return 0
1028
+ return claimed
1029
+ } catch {
1030
+ try {
1031
+ rmSync(file, { force: true })
1032
+ } catch {
1033
+ /* a stale port file is harmless */ // ui-copy-ignore
1034
+ }
1035
+ return 0
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * @param {string} dir - profile directory.
1041
+ * @returns {Promise<number>} the port Chrome opened. // ui-copy-ignore
1042
+ */
1043
+ async spawnChrome(dir) {
1044
+ const binary = firstBinary()
1045
+ if (binary === undefined) throw new Error('sidecar: no Chrome found on this machine')
1046
+ const args = [
1047
+ `--user-data-dir=${dir}`,
1048
+ '--remote-debugging-port=0', // ui-copy-ignore
1049
+ '--remote-allow-origins=*',
1050
+ '--no-first-run',
1051
+ '--no-default-browser-check',
1052
+ '--use-mock-keychain',
1053
+ '--disable-blink-features=AutomationControlled',
1054
+ '--disable-features=Translate,MediaRouter,OptimizationHints',
1055
+ '--disable-sync',
1056
+ /* The driver clicks for a person, and a page that opens a tab on a click
1057
+ is doing what the page means to do. Blocking it would strand the
1058
+ mirror on the old page. */
1059
+ '--disable-popup-blocking',
1060
+ /* A crash-restore bubble would sit on the person's screen and is not how
1061
+ this browser is meant to come back; the profile itself is the state. */
1062
+ '--hide-crash-restore-bubble',
1063
+ '--disable-session-crashed-bubble',
1064
+ /* A background page is throttled to about one frame a second, and the
1065
+ mirror would show a slideshow: nothing here is background work. */
1066
+ '--disable-background-timer-throttling',
1067
+ '--disable-backgrounding-occluded-windows',
1068
+ '--disable-renderer-backgrounding',
1069
+ '--disable-ipc-flooding-protection',
1070
+ '--metrics-recording-only',
1071
+ '--no-service-autorun',
1072
+ '--password-store=basic',
1073
+ '--lang=zh-CN',
1074
+ `--window-size=${VIEW.width},${VIEW.height}`,
1075
+ 'about:blank',
1076
+ ]
1077
+ if (this.headless) args.unshift('--headless=new')
1078
+ const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
1079
+ child.on('exit', () => {
1080
+ if (this.child === child) {
1081
+ this.child = null
1082
+ this.cdp?.close()
1083
+ this.cdp = null
1084
+ this.page = null
1085
+ }
1086
+ })
1087
+ this.child = child
1088
+ const file = join(dir, 'DevToolsActivePort')
1089
+ for (let i = 0; i < 150; i += 1) {
1090
+ await wait(100)
1091
+ if (!existsSync(file)) continue
1092
+ const port = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0) // ui-copy-ignore
1093
+ if (Number.isFinite(port) && port > 0) return port // ui-copy-ignore
1094
+ }
1095
+ throw new Error('sidecar: Chrome did not open a debugging port') // ui-copy-ignore
1096
+ }
1097
+
1098
+ /**
1099
+ * @param {number} port - debugging port. // ui-copy-ignore
1100
+ * @returns {Promise<string>} the page target's socket.
1101
+ */
1102
+ /**
1103
+ * Replace a dead page with a fresh tab in the same browser, on the same address.
1104
+ * @param {string} url - where the person was.
1105
+ * @returns {Promise<SidecarPage>} the new page.
1106
+ */
1107
+ /**
1108
+ * The browser's pages, in the order the browser lists them.
1109
+ * @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
1110
+ */
1111
+ async tabs() {
1112
+ return this.page === null ? [] : this.page.tabs()
1113
+ }
1114
+
1115
+ /**
1116
+ * Move the mirror to another tab, leaving the old one open where it was.
1117
+ * @param {number} index - a row from `tabs()`.
1118
+ * @returns {Promise<SidecarPage>} the page now being driven.
1119
+ */
1120
+ async useTab(index) {
1121
+ const list = await this.tabs()
1122
+ const tab = list[Number(index)]
1123
+ if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1124
+ if (tab.current) return this.page
1125
+ const was = this.page
1126
+ const cdp = new Cdp(tab.webSocketUrl)
1127
+ await cdp.ready
1128
+ const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
1129
+ this.cdp = cdp
1130
+ this.page = page
1131
+ this.lastUrl = tab.url
1132
+ await page.cast()
1133
+ was.cdp.send('Page.stopScreencast').catch(() => {})
1134
+ was.cdp.close()
1135
+ this.touch()
1136
+ return page
1137
+ }
1138
+
1139
+ /**
1140
+ * Close a tab; the mirror moves to a neighbour when the closed one was shown.
1141
+ * @param {number} index - a row from `tabs()`.
1142
+ */
1143
+ async closeTab(index) {
1144
+ const list = await this.tabs()
1145
+ const tab = list[Number(index)]
1146
+ if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1147
+ let port = this.page?.port ?? 0 // ui-copy-ignore
1148
+ let alone = true
1149
+ if (tab.current) {
1150
+ const other = list.findIndex((item) => !item.current)
1151
+ alone = other < 0
1152
+ if (!alone) await this.useTab(other)
1153
+ }
1154
+ if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
1155
+ if (alone) {
1156
+ await this.shutdown()
1157
+ await this.ensure()
1158
+ }
1159
+ }
1160
+
1161
+ async recover(url) {
1162
+ const port = this.page?.port ?? 0 // ui-copy-ignore
1163
+ if (port === 0) throw new Error('sidecar: no browser to recover into') // ui-copy-ignore
1164
+ const address = url === '' || url === undefined ? 'about:blank' : String(url)
1165
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1166
+ if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
1167
+ const target = await created.json()
1168
+ if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: recovered tab has no socket')
1169
+ const cdp = new Cdp(target.webSocketDebuggerUrl)
1170
+ await cdp.ready
1171
+ this.cdp = cdp
1172
+ this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1173
+ await this.page.cast()
1174
+ this.touch()
1175
+ return this.page
1176
+ }
1177
+
1178
+ async pageSocket(port) { // ui-copy-ignore
1179
+ for (let i = 0; i < 60; i += 1) {
1180
+ try {
1181
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
1182
+ const targets = await response.json()
1183
+ const page = targets.find((target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string')
1184
+ if (page !== undefined) return page.webSocketDebuggerUrl
1185
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT', signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
1186
+ if (created.ok) {
1187
+ const target = await created.json()
1188
+ if (typeof target.webSocketDebuggerUrl === 'string') return target.webSocketDebuggerUrl
1189
+ }
1190
+ } catch {
1191
+ /* Chrome is still coming up */
1192
+ }
1193
+ await wait(200)
1194
+ }
1195
+ throw new Error('sidecar: Chrome has no page target')
1196
+ }
1197
+
1198
+ /**
1199
+ * Attach one viewer to the frame stream.
1200
+ * @param {(frame: any) => void} sink - called per frame.
1201
+ * @returns {Promise<() => void>} detach.
1202
+ */
1203
+ async watch(sink) {
1204
+ const page = await this.ensure()
1205
+ page.frames.add(sink)
1206
+ page.pointerSinks.add(sink)
1207
+ await page.cast()
1208
+ return () => {
1209
+ page.frames.delete(sink)
1210
+ page.pointerSinks.delete(sink)
1211
+ if (page.frames.size === 0) void page.stopCast()
1212
+ }
1213
+ }
1214
+
1215
+ touch() {
1216
+ if (this.idle !== null) clearTimeout(this.idle)
1217
+ this.idle = setTimeout(() => {
1218
+ this.idle = null
1219
+ /* Someone is watching the mirror: the page stays up, and the clock starts
1220
+ again so the browser is closed only once it is truly unused. */
1221
+ if ((this.page?.frames.size ?? 0) > 0) {
1222
+ this.touch()
1223
+ return
1224
+ }
1225
+ void this.shutdown()
1226
+ }, IDLE_MS)
1227
+ this.idle.unref?.()
1228
+ }
1229
+
1230
+ /** @returns {Promise<void>} resolves once Chrome is gone. */
1231
+ async shutdown() {
1232
+ /* Ask Chrome to close first: a browser that is killed outright may lose the
1233
+ last minute of cookies, and keeping the person signed in is the point. */
1234
+ if (this.page !== null) await this.saveSessionCookies()
1235
+ if (this.cdp !== null) {
1236
+ await this.cdp.send('Browser.close').catch(() => {})
1237
+ await new Promise((resolve) => setTimeout(resolve, 400))
1238
+ }
1239
+ this.page = null
1240
+ this.cdp?.close()
1241
+ this.cdp = null
1242
+ if (this.child !== null) {
1243
+ try {
1244
+ this.child.kill('SIGTERM')
1245
+ } catch {
1246
+ /* already gone */
1247
+ }
1248
+ this.child = null
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * Chrome drops session cookies when it closes, and most sign-ins are session
1254
+ * cookies. Keeping them here is what lets a person stay signed in across a
1255
+ * reload of the app, which is the whole point of the profile.
1256
+ * @returns {Promise<void>} done.
1257
+ */
1258
+ async saveSessionCookies() {
1259
+ try {
1260
+ const jar = (await this.page.cookies()).filter((c) => c.session === true || Number(c.expires) < 0)
1261
+ const file = sessionJar(profileDir(this.profile))
1262
+ if (jar.length === 0) {
1263
+ rmSync(file, { force: true })
1264
+ return
1265
+ }
1266
+ writeFileSync(file, JSON.stringify(jar), { mode: 0o600 })
1267
+ } catch {
1268
+ /* a browser that is already gone has nothing to save */
1269
+ }
1270
+ }
1271
+
1272
+ /** @returns {Promise<void>} puts back what saveSessionCookies kept. */
1273
+ async restoreSessionCookies() {
1274
+ let jar = []
1275
+ try {
1276
+ jar = JSON.parse(String(readFileSync(sessionJar(profileDir(this.profile)), 'utf8')))
1277
+ } catch {
1278
+ return
1279
+ }
1280
+ if (!Array.isArray(jar)) return
1281
+ for (const cookie of jar.slice(0, 200)) {
1282
+ const domain = String(cookie?.domain ?? '').replace(/^\./, '')
1283
+ if (domain === '') continue
1284
+ try {
1285
+ await this.page.setCookie({
1286
+ name: String(cookie.name),
1287
+ value: String(cookie.value),
1288
+ domain,
1289
+ path: String(cookie.path ?? '/'),
1290
+ secure: cookie.secure === true,
1291
+ httpOnly: cookie.httpOnly === true,
1292
+ sameSite: cookie.sameSite,
1293
+ })
1294
+ } catch {
1295
+ /* one stale cookie must not stop the others */
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ /** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
1301
+ async state() {
1302
+ const profiles = (() => {
1303
+ try {
1304
+ return readdirSync(rootDir()).filter((name) => statSync(join(rootDir(), name)).isDirectory())
1305
+ } catch {
1306
+ return []
1307
+ }
1308
+ })()
1309
+ return {
1310
+ alive: this.alive, // ui-copy-ignore
1311
+ profile: this.profile, // ui-copy-ignore
1312
+ headless: this.headless, // ui-copy-ignore
1313
+ url: this.page?.url ?? '', // ui-copy-ignore
1314
+ title: this.page?.title ?? '',
1315
+ viewers: this.page?.frames.size ?? 0,
1316
+ frames: this.page?.seq ?? 0,
1317
+ crashed: this.page?.crashed === true,
1318
+ pointer: this.page?.pointer ?? null,
1319
+ profiles,
1320
+ error: this.alive ? '' : this.lastError,
1321
+ }
1322
+ }
1323
+ }