dsh-inner-browser-use 0.11.44 → 0.18.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,1364 @@
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
+ /* What the person did in the mirror, and when. It starts empty rather than
914
+ missing: an adopted browser has no record of a hand on it yet, and asking
915
+ about one used to throw instead of answering. */
916
+ this.person = { at: 0, kind: '', x: 0, y: 0, seen: true } // ui-copy-ignore
917
+ /* How long after the person's last real input the agent holds off. Short
918
+ enough that a stray click does not stall a run, long enough that the agent
919
+ does not type into a field the person is in the middle of filling. */
920
+ this.holdMs = 6000
921
+ }
922
+
923
+ /**
924
+ * The person touched the mirror by hand; every action waits for them now.
925
+ * Called from the input route, which only sees the person's own input.
926
+ */
927
+ humanUse({ kind = '', x = 0, y = 0 } = {}) {
928
+ this.person = { at: Date.now(), kind, x, y, seen: false }
929
+ }
930
+
931
+ /** @returns {number} ms the agent should still hold off; 0 when free to act. */
932
+ holding() {
933
+ if (this.person === undefined || this.person.at === 0) return 0
934
+ const left = this.holdMs - (Date.now() - this.person.at)
935
+ return left > 0 ? left : 0
936
+ }
937
+
938
+ /** Take the page back now, without waiting out the hold. */
939
+ release() {
940
+ if (this.person === undefined) this.person = { at: 0, kind: '', x: 0, y: 0, seen: true } // ui-copy-ignore
941
+ this.person.at = 0
942
+ this.person.seen = true
943
+ }
944
+
945
+ /**
946
+ * Files downloaded so far. Asking a stopped browser is not an error.
947
+ * @returns {{name: string, bytes: number, at: string}[]} what is on disk.
948
+ */
949
+ downloads() {
950
+ if (this.page === null) return []
951
+ return this.page.downloads()
952
+ }
953
+
954
+ /**
955
+ * Responses so far. Asking a stopped browser is an empty list, not an error.
956
+ * @returns {{method: string, url: string, type: string, status: number}[]} the requests.
957
+ */
958
+ network() {
959
+ if (this.page === null) return []
960
+ return this.page.networkList()
961
+ }
962
+
963
+ get alive() {
964
+ return this.cdp !== null && !this.cdp.closed && this.page !== null
965
+ }
966
+
967
+ /** @param {string} text - why the sidecar is not usable. */
968
+ note(text) {
969
+ this.lastError = text
970
+ this.logger?.warn?.('dsh-inner-browser-use: sidecar %s', text)
971
+ }
972
+
973
+ /**
974
+ * @param {{profile?: string, headless?: boolean, url?: string}} [options] - how to start.
975
+ * @returns {Promise<SidecarPage>} the page being driven.
976
+ */
977
+ async ensure(options = {}) {
978
+ if (options.profile !== undefined) this.profile = options.profile
979
+ const wanted = options.headless === undefined ? this.headless : options.headless !== false
980
+ const modeChanged = wanted !== this.headless
981
+ this.headless = wanted
982
+ /* A windowless browser and a windowed one are different processes, so asking
983
+ for the other one has to restart it. Setting the flag alone left the request
984
+ with no effect at all: a site that refuses a windowless browser stayed out
985
+ of reach until the browser happened to close for an unrelated reason. */
986
+ if (this.alive && modeChanged) {
987
+ const wasAt = options.url ?? this.page?.lastUrl ?? this.lastUrl
988
+ await this.shutdown()
989
+ return this.ensure({ ...options, url: wasAt })
990
+ }
991
+ if (this.alive) {
992
+ const page = this.page
993
+ /* A page that stopped answering is a page nobody can drive. Start a new
994
+ one on the address it was showing, rather than failing every call. */
995
+ let probe = page.dialog !== null && page.dialog !== undefined ? { ok: true } : await page.ping()
996
+ if (!probe.ok && !probe.closed) {
997
+ await wait(150)
998
+ probe = await page.ping()
999
+ }
1000
+ if (!probe.ok) {
1001
+ page.crashed = true
1002
+ this.note('renderer stopped answering; opening a fresh tab')
1003
+ const wasAt = options.url ?? page.lastUrl ?? this.lastUrl
1004
+ /* The browser itself is usually fine: only the renderer died. Opening a
1005
+ fresh tab in it is much faster than starting Chrome again. */
1006
+ try {
1007
+ return await this.recover(wasAt)
1008
+ } catch {
1009
+ await this.shutdown()
1010
+ return this.ensure({ ...options, url: wasAt })
1011
+ }
1012
+ }
1013
+ this.touch()
1014
+ if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
1015
+ return this.page
1016
+ }
1017
+ if (this.starting !== null) return this.starting
1018
+ /* A page whose connection closed on its own (a renderer crash closes the
1019
+ tab's session) can be replaced in the browser that is still running. */
1020
+ if (this.page !== null && this.page.cdp?.closed === true) {
1021
+ const wasAt = options.url ?? this.page.lastUrl ?? this.lastUrl
1022
+ this.page.crashed = true
1023
+ this.note('renderer stopped answering; opening a fresh tab')
1024
+ this.starting = this.recover(wasAt).catch((error) => {
1025
+ this.note(String(error?.message ?? error))
1026
+ return this.launch(wasAt)
1027
+ }).finally(() => {
1028
+ this.starting = null
1029
+ })
1030
+ return this.starting
1031
+ }
1032
+ this.starting = this.launch(options.url).finally(() => {
1033
+ this.starting = null
1034
+ })
1035
+ return this.starting
1036
+ }
1037
+
1038
+ /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
1039
+ async launch(url) {
1040
+ const dir = profileDir(this.profile)
1041
+ let port = await this.runningPort(dir) // ui-copy-ignore
1042
+ if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
1043
+ const ws = await this.pageSocket(port) // ui-copy-ignore
1044
+ const cdp = new Cdp(ws)
1045
+ await cdp.ready
1046
+ this.cdp = cdp
1047
+ this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1048
+ if (url !== undefined && url !== '') this.lastUrl = String(url)
1049
+ await this.restoreSessionCookies()
1050
+ await this.page.cast()
1051
+ if (url !== undefined && url !== '') await this.page.navigate(url)
1052
+ this.touch()
1053
+ this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
1054
+ return this.page
1055
+ }
1056
+
1057
+ /**
1058
+ * @param {string} dir - profile directory.
1059
+ * @returns {Promise<number>} the port of a sidecar already using it, or 0. // ui-copy-ignore
1060
+ */
1061
+ async runningPort(dir) {
1062
+ const file = join(dir, 'DevToolsActivePort')
1063
+ if (!existsSync(file)) return 0
1064
+ const claimed = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0)
1065
+ if (!Number.isFinite(claimed) || claimed <= 0) return 0
1066
+ try {
1067
+ const response = await fetch(`http://127.0.0.1:${claimed}/json/version`, { signal: AbortSignal.timeout(1200) })
1068
+ if (!response.ok) return 0
1069
+ return claimed
1070
+ } catch {
1071
+ try {
1072
+ rmSync(file, { force: true })
1073
+ } catch {
1074
+ /* a stale port file is harmless */ // ui-copy-ignore
1075
+ }
1076
+ return 0
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * @param {string} dir - profile directory.
1082
+ * @returns {Promise<number>} the port Chrome opened. // ui-copy-ignore
1083
+ */
1084
+ async spawnChrome(dir) {
1085
+ const binary = firstBinary()
1086
+ if (binary === undefined) throw new Error('sidecar: no Chrome found on this machine')
1087
+ const args = [
1088
+ `--user-data-dir=${dir}`,
1089
+ '--remote-debugging-port=0', // ui-copy-ignore
1090
+ '--remote-allow-origins=*',
1091
+ '--no-first-run',
1092
+ '--no-default-browser-check',
1093
+ '--use-mock-keychain',
1094
+ '--disable-blink-features=AutomationControlled',
1095
+ '--disable-features=Translate,MediaRouter,OptimizationHints',
1096
+ '--disable-sync',
1097
+ /* The driver clicks for a person, and a page that opens a tab on a click
1098
+ is doing what the page means to do. Blocking it would strand the
1099
+ mirror on the old page. */
1100
+ '--disable-popup-blocking',
1101
+ /* A crash-restore bubble would sit on the person's screen and is not how
1102
+ this browser is meant to come back; the profile itself is the state. */
1103
+ '--hide-crash-restore-bubble',
1104
+ '--disable-session-crashed-bubble',
1105
+ /* A background page is throttled to about one frame a second, and the
1106
+ mirror would show a slideshow: nothing here is background work. */
1107
+ '--disable-background-timer-throttling',
1108
+ '--disable-backgrounding-occluded-windows',
1109
+ '--disable-renderer-backgrounding',
1110
+ '--disable-ipc-flooding-protection',
1111
+ '--metrics-recording-only',
1112
+ '--no-service-autorun',
1113
+ '--password-store=basic',
1114
+ '--lang=zh-CN',
1115
+ `--window-size=${VIEW.width},${VIEW.height}`,
1116
+ 'about:blank',
1117
+ ]
1118
+ if (this.headless) args.unshift('--headless=new')
1119
+ const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
1120
+ child.on('exit', () => {
1121
+ if (this.child === child) {
1122
+ this.child = null
1123
+ this.cdp?.close()
1124
+ this.cdp = null
1125
+ this.page = null
1126
+ }
1127
+ })
1128
+ this.child = child
1129
+ const file = join(dir, 'DevToolsActivePort')
1130
+ for (let i = 0; i < 150; i += 1) {
1131
+ await wait(100)
1132
+ if (!existsSync(file)) continue
1133
+ const port = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0) // ui-copy-ignore
1134
+ if (Number.isFinite(port) && port > 0) return port // ui-copy-ignore
1135
+ }
1136
+ throw new Error('sidecar: Chrome did not open a debugging port') // ui-copy-ignore
1137
+ }
1138
+
1139
+ /**
1140
+ * @param {number} port - debugging port. // ui-copy-ignore
1141
+ * @returns {Promise<string>} the page target's socket.
1142
+ */
1143
+ /**
1144
+ * Replace a dead page with a fresh tab in the same browser, on the same address.
1145
+ * @param {string} url - where the person was.
1146
+ * @returns {Promise<SidecarPage>} the new page.
1147
+ */
1148
+ /**
1149
+ * The browser's pages, in the order the browser lists them.
1150
+ * @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
1151
+ */
1152
+ async tabs() {
1153
+ return this.page === null ? [] : this.page.tabs()
1154
+ }
1155
+
1156
+ /**
1157
+ * Move the mirror to another tab, leaving the old one open where it was.
1158
+ * @param {number} index - a row from `tabs()`.
1159
+ * @returns {Promise<SidecarPage>} the page now being driven.
1160
+ */
1161
+ async useTab(index) {
1162
+ const list = await this.tabs()
1163
+ const tab = list[Number(index)]
1164
+ if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1165
+ if (tab.current) return this.page
1166
+ const was = this.page
1167
+ const cdp = new Cdp(tab.webSocketUrl)
1168
+ await cdp.ready
1169
+ const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
1170
+ this.cdp = cdp
1171
+ this.page = page
1172
+ this.lastUrl = tab.url
1173
+ await page.cast()
1174
+ was.cdp.send('Page.stopScreencast').catch(() => {})
1175
+ was.cdp.close()
1176
+ this.touch()
1177
+ return page
1178
+ }
1179
+
1180
+ /**
1181
+ * Close a tab; the mirror moves to a neighbour when the closed one was shown.
1182
+ * @param {number} index - a row from `tabs()`.
1183
+ */
1184
+ async closeTab(index) {
1185
+ const list = await this.tabs()
1186
+ const tab = list[Number(index)]
1187
+ if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1188
+ let port = this.page?.port ?? 0 // ui-copy-ignore
1189
+ let alone = true
1190
+ if (tab.current) {
1191
+ const other = list.findIndex((item) => !item.current)
1192
+ alone = other < 0
1193
+ if (!alone) await this.useTab(other)
1194
+ }
1195
+ if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
1196
+ if (alone) {
1197
+ await this.shutdown()
1198
+ await this.ensure()
1199
+ }
1200
+ }
1201
+
1202
+ async recover(url) {
1203
+ const port = this.page?.port ?? 0 // ui-copy-ignore
1204
+ if (port === 0) throw new Error('sidecar: no browser to recover into') // ui-copy-ignore
1205
+ const address = url === '' || url === undefined ? 'about:blank' : String(url)
1206
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1207
+ if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
1208
+ const target = await created.json()
1209
+ if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: recovered tab has no socket')
1210
+ const cdp = new Cdp(target.webSocketDebuggerUrl)
1211
+ await cdp.ready
1212
+ this.cdp = cdp
1213
+ this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1214
+ await this.page.cast()
1215
+ this.touch()
1216
+ return this.page
1217
+ }
1218
+
1219
+ async pageSocket(port) { // ui-copy-ignore
1220
+ for (let i = 0; i < 60; i += 1) {
1221
+ try {
1222
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
1223
+ const targets = await response.json()
1224
+ const page = targets.find((target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string')
1225
+ if (page !== undefined) return page.webSocketDebuggerUrl
1226
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT', signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
1227
+ if (created.ok) {
1228
+ const target = await created.json()
1229
+ if (typeof target.webSocketDebuggerUrl === 'string') return target.webSocketDebuggerUrl
1230
+ }
1231
+ } catch {
1232
+ /* Chrome is still coming up */
1233
+ }
1234
+ await wait(200)
1235
+ }
1236
+ throw new Error('sidecar: Chrome has no page target')
1237
+ }
1238
+
1239
+ /**
1240
+ * Attach one viewer to the frame stream.
1241
+ * @param {(frame: any) => void} sink - called per frame.
1242
+ * @returns {Promise<() => void>} detach.
1243
+ */
1244
+ async watch(sink) {
1245
+ const page = await this.ensure()
1246
+ page.frames.add(sink)
1247
+ page.pointerSinks.add(sink)
1248
+ await page.cast()
1249
+ return () => {
1250
+ page.frames.delete(sink)
1251
+ page.pointerSinks.delete(sink)
1252
+ if (page.frames.size === 0) void page.stopCast()
1253
+ }
1254
+ }
1255
+
1256
+ touch() {
1257
+ if (this.idle !== null) clearTimeout(this.idle)
1258
+ this.idle = setTimeout(() => {
1259
+ this.idle = null
1260
+ /* Someone is watching the mirror: the page stays up, and the clock starts
1261
+ again so the browser is closed only once it is truly unused. */
1262
+ if ((this.page?.frames.size ?? 0) > 0) {
1263
+ this.touch()
1264
+ return
1265
+ }
1266
+ void this.shutdown()
1267
+ }, IDLE_MS)
1268
+ this.idle.unref?.()
1269
+ }
1270
+
1271
+ /** @returns {Promise<void>} resolves once Chrome is gone. */
1272
+ async shutdown() {
1273
+ /* Ask Chrome to close first: a browser that is killed outright may lose the
1274
+ last minute of cookies, and keeping the person signed in is the point. */
1275
+ if (this.page !== null) await this.saveSessionCookies()
1276
+ if (this.cdp !== null) {
1277
+ await this.cdp.send('Browser.close').catch(() => {})
1278
+ await new Promise((resolve) => setTimeout(resolve, 400))
1279
+ }
1280
+ this.page = null
1281
+ this.cdp?.close()
1282
+ this.cdp = null
1283
+ if (this.child !== null) {
1284
+ try {
1285
+ this.child.kill('SIGTERM')
1286
+ } catch {
1287
+ /* already gone */
1288
+ }
1289
+ this.child = null
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Chrome drops session cookies when it closes, and most sign-ins are session
1295
+ * cookies. Keeping them here is what lets a person stay signed in across a
1296
+ * reload of the app, which is the whole point of the profile.
1297
+ * @returns {Promise<void>} done.
1298
+ */
1299
+ async saveSessionCookies() {
1300
+ try {
1301
+ const jar = (await this.page.cookies()).filter((c) => c.session === true || Number(c.expires) < 0)
1302
+ const file = sessionJar(profileDir(this.profile))
1303
+ if (jar.length === 0) {
1304
+ rmSync(file, { force: true })
1305
+ return
1306
+ }
1307
+ writeFileSync(file, JSON.stringify(jar), { mode: 0o600 })
1308
+ } catch {
1309
+ /* a browser that is already gone has nothing to save */
1310
+ }
1311
+ }
1312
+
1313
+ /** @returns {Promise<void>} puts back what saveSessionCookies kept. */
1314
+ async restoreSessionCookies() {
1315
+ let jar = []
1316
+ try {
1317
+ jar = JSON.parse(String(readFileSync(sessionJar(profileDir(this.profile)), 'utf8')))
1318
+ } catch {
1319
+ return
1320
+ }
1321
+ if (!Array.isArray(jar)) return
1322
+ for (const cookie of jar.slice(0, 200)) {
1323
+ const domain = String(cookie?.domain ?? '').replace(/^\./, '')
1324
+ if (domain === '') continue
1325
+ try {
1326
+ await this.page.setCookie({
1327
+ name: String(cookie.name),
1328
+ value: String(cookie.value),
1329
+ domain,
1330
+ path: String(cookie.path ?? '/'),
1331
+ secure: cookie.secure === true,
1332
+ httpOnly: cookie.httpOnly === true,
1333
+ sameSite: cookie.sameSite,
1334
+ })
1335
+ } catch {
1336
+ /* one stale cookie must not stop the others */
1337
+ }
1338
+ }
1339
+ }
1340
+
1341
+ /** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
1342
+ async state() {
1343
+ const profiles = (() => {
1344
+ try {
1345
+ return readdirSync(rootDir()).filter((name) => statSync(join(rootDir(), name)).isDirectory())
1346
+ } catch {
1347
+ return []
1348
+ }
1349
+ })()
1350
+ return {
1351
+ alive: this.alive, // ui-copy-ignore
1352
+ profile: this.profile, // ui-copy-ignore
1353
+ headless: this.headless, // ui-copy-ignore
1354
+ url: this.page?.url ?? '', // ui-copy-ignore
1355
+ title: this.page?.title ?? '',
1356
+ viewers: this.page?.frames.size ?? 0,
1357
+ frames: this.page?.seq ?? 0,
1358
+ crashed: this.page?.crashed === true,
1359
+ pointer: this.page?.pointer ?? null,
1360
+ profiles,
1361
+ error: this.alive ? '' : this.lastError,
1362
+ }
1363
+ }
1364
+ }