dsh-inner-browser-use 0.27.0 → 0.34.95

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 CHANGED
@@ -5,10 +5,30 @@
5
5
  * and a page keeps working while the person looks somewhere else. The column
6
6
  * shows it as a screencast, so the tab is a viewer, not the browser.
7
7
  */
8
- import { spawn } from 'node:child_process'
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { execFileSync, spawn, spawnSync } from 'node:child_process'
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
10
10
  import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { readyFlash } from './runtime/flash-setup.mjs'
14
+
15
+ /**
16
+ * Windows keeps a browser under Program Files (machine-wide) or LocalAppData
17
+ * (per-user), and Edge is Chromium too, so it speaks the same DevTools
18
+ * protocol. Chrome is still preferred: the list is read in order.
19
+ * @returns {string[]} candidates, most preferred first.
20
+ */
21
+ function windowsBrowserCandidates() {
22
+ const roots = [
23
+ process.env['PROGRAMFILES'],
24
+ process.env['PROGRAMFILES(X86)'],
25
+ process.env['LOCALAPPDATA'],
26
+ ].filter((root) => typeof root === 'string' && root !== '')
27
+ const out = []
28
+ for (const root of roots) out.push(join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'))
29
+ for (const root of roots) out.push(join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
30
+ return out
31
+ }
12
32
 
13
33
  /** Chrome builds this machine may have, most preferred first. */
14
34
  const CHROME_BINARIES = [
@@ -17,24 +37,71 @@ const CHROME_BINARIES = [
17
37
  '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
18
38
  '/usr/bin/google-chrome',
19
39
  '/usr/bin/chromium',
40
+ ...(process.platform === 'win32' ? windowsBrowserCandidates() : []),
20
41
  ]
21
42
 
22
43
  /** Viewport the screencast and the pointer share. Nothing here is a user-agent detail. */
23
44
  export const VIEW = { width: 1440, height: 900 }
24
45
 
46
+ /** What a person's Chrome on this machine sends. */
47
+ export const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36'
48
+
25
49
  /**
26
50
  * Pixels the mirror actually sends. The viewport is never downscaled: a
27
51
  * half-size JPEG turns 12px body text into mush in a column this narrow, and
28
52
  * the sharpness is what makes the mirror feel like the page. Quality stops
29
53
  * short of lossless so a moving picture still arrives on the next frame.
30
54
  */
31
- const MIRROR = { width: VIEW.width, height: VIEW.height }
55
+ /* The mirror runs at a rate the eye reads as live without spending CPU on
56
+ frames nobody asked for. */
57
+ const MIRROR = { width: VIEW.width, height: VIEW.height, interval: 66, busy: 40 }
58
+
32
59
 
33
60
  /** Frames are dropped rather than queued: a lagging viewer must not slow the page. */
34
61
  const MAX_INFLIGHT_FRAMES = 2
62
+ /* The pane is watched by a person, not recorded: a 60 fps page forwarded frame
63
+ for frame doubles the downlink and the client's decode work for a picture no
64
+ eye can tell apart at 30 fps. Kept below one screen refresh. */
65
+ /* How long a click waits for a tab to be announced before answering: enough
66
+ for the tabs a click opens directly, not for the page's own timers. */
67
+ const POPUP_GRACE_MS = 25
68
+ /* How long after a click an announced tab is still followed with no call
69
+ waiting: the page may open it from a timer or a promise. */
70
+ const FOLLOW_WINDOW_MS = 2000
71
+ const FRAME_GAP_MS = 28
72
+
73
+ /* The mirror is a photograph of a page, not an archive of one: quality that
74
+ keeps small text readable is the ceiling, and a page whose frame is heavy
75
+ gets a lighter one so the round trip stays short. */
76
+ const CAST_QUALITY = 72 // ui-copy-ignore
77
+ const QUALITY_FLOOR = 44 // ui-copy-ignore
78
+ const QUALITY_CEILING = 76 // ui-copy-ignore
79
+
80
+ /**
81
+ * Decide the next JPEG quality from the weight of the frame just seen. A frame
82
+ * near 90 KB of base64 is a heavy page: lowering quality by 8 costs a little
83
+ * sharpness and saves a lot of transport. A small frame has room to sharpen.
84
+ * @param {number} current - quality now.
85
+ * @param {number} base64 - base64 length of the frame.
86
+ * @returns {number} the quality to cast at next; `current` when it is fine.
87
+ */
88
+ export function nextQuality(current, base64) {
89
+ const size = Number(base64)
90
+ if (!Number.isFinite(size) || size <= 0) return current
91
+ if (size > 120_000) return Math.max(QUALITY_FLOOR, current - 8)
92
+ if (size < 45_000) return Math.min(QUALITY_CEILING, current + 4)
93
+ return current
94
+ }
35
95
 
36
96
  /** Idle lifetime, so a forgotten sidecar does not sit on a login forever. */
37
97
  const IDLE_MS = 15 * 60_000
98
+ /* One browser serves every chat, but a chat that is not on screen need not keep
99
+ a full renderer alive for ever: past this many chats the oldest unwatched tab
100
+ is closed, and its address brings it back on the next visit. */
101
+ const MAX_TABS = 6
102
+ /* A tab nobody has watched or driven for this long is closed even under the cap:
103
+ six chats can stay open for days, and each one is a renderer's worth of memory. */
104
+ const TAB_IDLE_MS = 10 * 60_000
38
105
 
39
106
  /** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
40
107
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -44,13 +111,267 @@ function firstBinary() {
44
111
  return CHROME_BINARIES.find((path) => existsSync(path))
45
112
  }
46
113
 
114
+ /* A movie page needs a Flash runtime, and current Chrome has none. Electron 11
115
+ still carries Chromium 87, the last build that took PPAPI, so the same
116
+ debugging protocol drives a browser that can play the page. Neither the
117
+ browser nor the plugin is in this package: the switch fetches one and reuses
118
+ the other, both on the person's own machine. */
119
+ const FLASH_APP = fileURLToPath(new URL('./runtime/flash-app/', import.meta.url))
120
+
121
+ /**
122
+ * Everything a Flash page needs, or nothing when this machine cannot play one.
123
+ * @returns {{binary: string, plugin: string, version: string} | null} the runtime.
124
+ */
125
+ export function flashRuntime() {
126
+ const ready = readyFlash()
127
+ if (ready === null) return null
128
+ const version = String(process.env.DSH_INNER_BROWSER_FLASH_VERSION ?? '32.0.0.371')
129
+ return { binary: ready.electron, plugin: ready.plugin, version }
130
+ }
131
+
132
+ /* A chat's tab address outlives a plugin reload: the browser is still running
133
+ with that tab, and the chat that returns should get its page back, not a
134
+ blank one. */
135
+ const tabsDir = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
136
+ const tabsFile = join(tabsDir, 'tabs.json')
137
+ /** @type {Record<string, string> | null} */
138
+ let tabsCache = null
139
+ /* Which runtime the person chose outlives a plugin reload for the same reason
140
+ the tab address does: a reload must not quietly turn the game window back
141
+ into a Chrome window that cannot play it. */
142
+ const flashFile = join(tabsDir, 'flash.json')
143
+
144
+ /** @returns {boolean} true when the Flash runtime was chosen last time. */
145
+ function readFlashChoice() {
146
+ try {
147
+ return JSON.parse(readFileSync(flashFile, 'utf8')).on === true
148
+ } catch {
149
+ return false
150
+ }
151
+ }
152
+
153
+ /** @param {boolean} on - record the runtime choice. */
154
+ let flashChoice = null
155
+ function writeFlashChoice(on) {
156
+ /* Every tool call states its runtime, but almost never changes it: writing
157
+ the same byte per keystroke was a disk write inside the typing path. */
158
+ const value = on === true
159
+ if (flashChoice === value) return
160
+ try {
161
+ mkdirSync(tabsDir, { recursive: true })
162
+ writeFileSync(flashFile, JSON.stringify({ on: value }))
163
+ flashChoice = value
164
+ } catch {}
165
+ }
166
+
47
167
  /** Root of everything this plugin keeps for the sidecar. */
48
- function rootDir() {
49
- const dir = join(homedir(), '.dsh', 'inner-browser-use', 'chrome')
168
+ export function rootDir() {
169
+ /* A probe, a test or a second app that was told to keep its files elsewhere
170
+ keeps its browsers elsewhere too. Pointing this at the person's directory
171
+ would let it find and drive the browser they are looking at. */
172
+ const base = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
173
+ const dir = join(base, 'chrome')
50
174
  mkdirSync(dir, { recursive: true })
51
175
  return dir
52
176
  }
53
177
 
178
+ /**
179
+ * Whether a target is not this page's to take: its own tab or one another chat
180
+ * is driving.
181
+ * @param {string} targetId - candidate target.
182
+ * @param {string} mine - the asking page's own target.
183
+ * @param {string[]} [foreign] - targets other chats drive.
184
+ * @returns {boolean} true when the candidate must be left alone.
185
+ */
186
+ export function isForeign(targetId, mine, foreign = []) {
187
+ const id = String(targetId ?? '')
188
+ if (id === '') return true
189
+ if (id === String(mine ?? '')) return true
190
+ return foreign.map(String).includes(id)
191
+ }
192
+
193
+ /**
194
+ * Where the cookies of a profile are kept in the browser's own storage.
195
+ */
196
+ /**
197
+ * The topmost process this plugin lives in: the application itself, not the
198
+ * node instance a reload replaces. A reload changes `process.pid`, so a watcher
199
+ * keyed to it kills the shared browser the moment the old instance goes — which
200
+ * is how the mirror went blank after a reload.
201
+ * @returns {number} the application pid, or 0 when it cannot be walked.
202
+ */
203
+ function appPid() {
204
+ let pid = process.pid
205
+ let top = pid
206
+ for (let hop = 0; hop < 12; hop += 1) {
207
+ let parent = 0
208
+ try {
209
+ parent = Number(String(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8' })).trim())
210
+ } catch {
211
+ break
212
+ }
213
+ if (!Number.isInteger(parent) || parent <= 1) break
214
+ top = parent
215
+ pid = parent
216
+ }
217
+ return top === process.pid ? 0 : top
218
+ }
219
+
220
+ /**
221
+ * Whether a process command line is the application itself, rather than the node
222
+ * instance a reload replaces. A watcher keyed to the node instance would kill
223
+ * the shared browser on every reload, which is exactly what made the column go
224
+ * blank; a command line with `--expose-internals` is that instance.
225
+ * @param {string} command - what `ps -o command=` said.
226
+ * @returns {boolean} true for the app binary itself.
227
+ */
228
+ export function looksLikeApp(command) {
229
+ const line = String(command ?? '')
230
+ if (line.includes('--expose-internals')) return false
231
+ return line.includes('.app/Contents/MacOS/')
232
+ }
233
+
234
+ /**
235
+ * @param {number} pid - a process.
236
+ * @returns {string} its command line, or '' when it is gone.
237
+ */
238
+ function commandOf(pid) {
239
+ if (!Number.isInteger(pid) || pid <= 1) return ''
240
+ try {
241
+ return String(execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' })).trim()
242
+ } catch {
243
+ return ''
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Whether this run may arm the watcher at all: never on Windows, where the
249
+ * browser tree ends with the application through `taskkill /T`, and only inside
250
+ * the application itself, since a note keyed to anything else would close the
251
+ * shared browser on a plugin reload.
252
+ * @returns {boolean} true when `handoff()` can write a watcher note.
253
+ */
254
+ export function canArmWatcher() {
255
+ if (process.platform === 'win32') return false
256
+ const app = appPid()
257
+ return app !== 0 && looksLikeApp(commandOf(app)) === true
258
+ }
259
+
260
+ /** The watcher that closes the browser when the app goes: one per app run. */
261
+ function watcherFile(dir) {
262
+ return join(dir, 'watcher.json')
263
+ }
264
+
265
+ /**
266
+ * One line describing a browser that went away by itself, so the next person to
267
+ * look knows whether it was asked to stop, crashed, or was killed from outside.
268
+ * @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string}} gone - what the child reported.
269
+ * @returns {string} a line without a newline.
270
+ */
271
+ export function deathNote(gone) {
272
+ const at = new Date().toISOString()
273
+ const code = gone.code === null || gone.code === undefined ? 'none' : String(gone.code)
274
+ return `${at} browser pid ${String(gone.pid ?? 0)} profile ${String(gone.profile ?? 'default')} exit code ${code} signal ${String(gone.signal ?? 'none')}`
275
+ }
276
+
277
+ /**
278
+ * Appends one line to the browser log and never troubles the caller, because a
279
+ * browser that died while nothing was watching should not also break the call
280
+ * that noticed.
281
+ * @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string, asked?: boolean}} gone - what happened.
282
+ * @param {string} [dir] - where the log lives; the state directory by default.
283
+ * @returns {void}
284
+ */
285
+ /**
286
+ * Whether this process is the one that started this browser. A browser found on
287
+ * a profile may have been started by another process — a second app, a script
288
+ * that forgot to quit — and closing it would take the person's own window with
289
+ * it. Only the process that wrote the marker may ask it to close.
290
+ * @param {{pid?: number, owner?: number}} said - this process, and the marker's pid.
291
+ * @returns {boolean} true while this process started it.
292
+ */
293
+ export function ownsBrowser(said) {
294
+ const mine = Number(said?.pid ?? 0)
295
+ const owner = Number(said?.owner ?? 0)
296
+ if (!Number.isInteger(mine) || mine <= 0) return false
297
+ return mine === owner
298
+ }
299
+
300
+ /** @param {string} dir - profile directory. @returns {number} the pid that started this browser, or 0. */
301
+ function markerPid(dir) {
302
+ try {
303
+ const said = JSON.parse(readFileSync(join(dir, 'owner.json'), 'utf8')) // ui-copy-ignore
304
+ const pid = Number(said?.pid ?? 0) // ui-copy-ignore
305
+ return Number.isInteger(pid) && pid > 0 ? pid : 0
306
+ } catch {
307
+ return 0
308
+ }
309
+ }
310
+
311
+ export function noteDeath(gone, dir = tabsDir) {
312
+ if (gone.asked === true) return
313
+ try {
314
+ mkdirSync(dir, { recursive: true })
315
+ appendFileSync(join(dir, 'browser.log'), `${deathNote(gone)}\n`)
316
+ } catch {
317
+ /* a log is a convenience, not a promise */
318
+ }
319
+ }
320
+
321
+ const COOKIE_FILES = ['Default/Cookies', 'Local State']
322
+
323
+ /**
324
+ * @param {string} dir - a profile directory.
325
+ * @returns {string} where its cookie snapshots live.
326
+ */
327
+ export function loginBackupDir(dir) {
328
+ // A Windows profile path has no '/', so splitting on it returned the whole
329
+ // path and the backup landed at `.../login/C:\Users\…` — a mkdir that fails.
330
+ const last = dir.split(/[\\/]/u).filter(Boolean).pop() ?? 'default'
331
+ return join(tabsDir, 'login', last)
332
+ }
333
+
334
+ /**
335
+ * Copies the cookie store aside while it is still good, so a profile that loses
336
+ * its cookies another way can be handed the ones that were working.
337
+ * @param {string} dir - a profile directory.
338
+ * @returns {number} how many snapshots are kept.
339
+ */
340
+ export function snapshotLogin(dir) {
341
+ const files = COOKIE_FILES.map((name) => ({ name, from: join(dir, name) })).filter((row) => existsSync(row.from))
342
+ if (files.length === 0) return 0
343
+ const to = loginBackupDir(dir)
344
+ mkdirSync(to, { recursive: true })
345
+ const stamp = String(Date.now())
346
+ for (const row of files) copyFileSync(row.from, join(to, `${stamp}-${row.name.replace(/\//g, '_')}`))
347
+ const mine = readdirSync(to).filter((name) => name.startsWith(`${stamp}-`))
348
+ const older = readdirSync(to).filter((name) => !mine.includes(name)).sort()
349
+ const generations = 5
350
+ const keep = older.slice(-generations * files.length)
351
+ for (const name of older) if (!keep.includes(name)) rmSync(join(to, name), { force: true })
352
+ return older.length + mine.length
353
+ }
354
+
355
+ /**
356
+ * Puts the newest snapshot back when the profile has no cookie store at all.
357
+ * @param {string} dir - a profile directory.
358
+ * @returns {boolean} whether anything was restored.
359
+ */
360
+ export function restoreLogin(dir) {
361
+ const cookie = join(dir, 'Default/Cookies')
362
+ if (existsSync(cookie) && statSync(cookie).size > 1000) return false
363
+ const from = loginBackupDir(dir)
364
+ if (!existsSync(from)) return false
365
+ const names = readdirSync(from).sort()
366
+ const newest = names.filter((name) => name.includes('-Default_Cookies')).slice(-1)[0]
367
+ const state = names.filter((name) => name.includes('-Local_State')).slice(-1)[0]
368
+ if (newest === undefined) return false
369
+ mkdirSync(join(dir, 'Default'), { recursive: true })
370
+ copyFileSync(join(from, newest), cookie)
371
+ if (state !== undefined) copyFileSync(join(from, state), join(dir, 'Local State'))
372
+ return true
373
+ }
374
+
54
375
  /** @param {string} name - profile name. @returns {string} its directory. */
55
376
  function profileDir(name) {
56
377
  const dir = join(rootDir(), String(name || 'default').replace(/[^\w.-]/g, '_'))
@@ -92,7 +413,15 @@ class Cdp {
92
413
  else entry.resolve(message.result ?? {})
93
414
  return
94
415
  }
95
- for (const listener of this.listeners) listener(message)
416
+ for (const listener of this.listeners) {
417
+ try {
418
+ listener(message)
419
+ } catch {
420
+ /* A listener that throws — a frame handler calling something the page
421
+ no longer has — costs that one event. It must not take the host
422
+ process with it. */
423
+ }
424
+ }
96
425
  })
97
426
  this.socket.addEventListener('close', () => {
98
427
  this.closed = true
@@ -145,8 +474,13 @@ class Cdp {
145
474
  */
146
475
  export class SidecarPage {
147
476
  /** @param {Cdp} cdp - the page session. @param {string} profile - profile name. */
148
- constructor(cdp, profile, port) { // ui-copy-ignore
477
+ constructor(cdp, profile, port, hold = () => 0) { // ui-copy-ignore
149
478
  this.cdp = cdp
479
+ /* Whether the person is driving. The page cannot reach the sidecar that
480
+ owns this person record, so the answer arrives as a callable and this
481
+ class never assumes it has a `holding` of its own. Anything that is not
482
+ a callable is not a reason to throw from a paint or input callback. */
483
+ this.holdOf = typeof hold === 'function' ? hold : () => 0
150
484
  this.profile = profile
151
485
  this.port = port // ui-copy-ignore
152
486
  this.epoch = 0
@@ -156,15 +490,35 @@ export class SidecarPage {
156
490
  this.pointerSinks = new Set()
157
491
  this.latest = ''
158
492
  this.seq = 0
493
+ this.lastFrameAt = 0
494
+ this.sentAt = 0
159
495
  /* Total JPEG bytes seen, for the bench and for noticing when a page got
160
496
  expensive to watch. */
161
497
  this.bytes = 0
498
+ /* The page lays out at the size of the column it is shown in, not at a
499
+ fixed desktop size: a mirror with letterbox bands is a page that does
500
+ not fill its frame. */
501
+ this.view = { width: VIEW.width, height: VIEW.height }
502
+ /* What the pane already has: an unchanged picture is not worth a second
503
+ copy, but silence still needs a heartbeat or the pane thinks the stream
504
+ died. */
505
+ this.lastShot = null
506
+ this.lastShotAt = 0
507
+ this.quiet = 0
162
508
  this.pointer = { x: VIEW.width / 2, y: VIEW.height / 2 }
509
+ this.lastPointerAt = 0
510
+ /* Set when this page is drawn by the Flash runtime instead of a screencast. */
511
+ this.flashBridge = null
163
512
  this.url = ''
164
513
  this.lastUrl = ''
165
514
  this.crashed = false
166
515
  this.title = ''
167
516
  this.casting = false
517
+ /* The cast quality moves with what the page costs to transport. */
518
+ /* A big viewport carries more pixels per frame; it starts lighter and the
519
+ first heavy frame settles the rest. */
520
+ this.castQuality = VIEW.width * VIEW.height > 1_000_000 ? 60 : CAST_QUALITY // ui-copy-ignore
521
+ this.adaptAt = 0
168
522
  this.inflight = 0
169
523
  this.touched = Date.now()
170
524
  /* A dialog blocks the page until someone answers it, so it is state, not an
@@ -172,10 +526,29 @@ export class SidecarPage {
172
526
  this.dialog = null
173
527
  this.console = []
174
528
  this.failed = []
529
+ /* A page that stops working usually says why in its console or its network
530
+ log, and the model only looks there after guessing. Each entry carries a
531
+ number so the next read can mention what happened since the last one. */
532
+ this.troubleSeq = 0
533
+ this.troubleSeen = 0
534
+ /* When a frame last began or finished loading. A click that started no load
535
+ changed the page in place, and waiting on it would be wasted time. */
536
+ this.navStarted = 0
537
+ this.navStopped = 0
538
+ /* When the main frame's load last finished. A navigation that is over is
539
+ known from this event, without asking a renderer that a page's own
540
+ scripts may be holding. */
541
+ this.loadFired = 0
542
+ /* A browser reset a crashed tab to about:blank; that is not a new tab. */
543
+ this.blanked = false
175
544
  /* What the page asked for, so a page that fills itself from addresses can
176
545
  be understood without a screenshot or a retry. */
177
546
  this.network = []
178
547
  this.requests = new Map()
548
+ /* A PDF is a document the mirror cannot draw: Chrome leaves the page blank
549
+ and a model looking at it would think the address was empty. What it is
550
+ and where the bytes went is state, not a guess. */
551
+ this.pdf = null
179
552
  /* frame id -> the world its page script runs in. A control inside an iframe
180
553
  is only reachable through that world; the frame list is read when the
181
554
  read model needs it. */
@@ -185,6 +558,25 @@ export class SidecarPage {
185
558
  follows it, because a person looking at the column expects that too. */
186
559
  this.children = new Map()
187
560
  this.opened = null
561
+ /* Woken when discovery announces a popup, so a click without one does not
562
+ wait the whole fallback window to learn there is nothing to follow. */
563
+ this.popupDoor = null
564
+ /* A popup is announced before it knows where it will go; the address
565
+ arrives a moment later. Waiting for it is what lets the click follow. */
566
+ this.urlDoor = null
567
+ /* A popup that shows up after the click has already answered is still the
568
+ page the click opened: for a moment after a click, an announced tab is
569
+ followed even though no call is waiting for it. */
570
+ this.followUntil = 0
571
+ this.followTimer = null
572
+ /* When the address was last asked for: how long a failure took tells a
573
+ browser still starting up apart from a network that said no. */
574
+ this.navAt = 0
575
+ /* The tab this page drives, and the tabs other chats drive. A tab another
576
+ chat owns is never a popup of this one: closing it, or navigating this
577
+ page to its address, would take a page away from somebody else. */
578
+ this.targetId = ''
579
+ this.siblings = () => []
188
580
  this.port = Number(port ?? 0) // ui-copy-ignore
189
581
  }
190
582
 
@@ -227,8 +619,33 @@ export class SidecarPage {
227
619
  this.children.delete(String(message.params?.sessionId ?? ''))
228
620
  return
229
621
  }
622
+ if (message.method === 'Target.targetCreated') {
623
+ const info = message.params?.targetInfo ?? {}
624
+ /* A page target that is not this tab is a popup this click opened:
625
+ remember it while it is still blank, so no polling is needed later. */
626
+ if (String(info.type ?? '') === 'page' && String(info.targetId ?? '') !== String(this.targetId ?? '')) {
627
+ const siblings = typeof this.siblings === 'function' ? this.siblings().map(String) : []
628
+ if (!siblings.includes(String(info.targetId ?? ''))) {
629
+ this.opened = { targetId: String(info.targetId ?? ''), url: String(info.url ?? ''), at: Date.now() }
630
+ this.wakePopup()
631
+ if (Date.now() < Number(this.followUntil ?? 0)) {
632
+ void this.adoptOpened().catch(() => '')
633
+ }
634
+ }
635
+ }
636
+ return
637
+ }
230
638
  if (message.method === 'Target.targetInfoChanged') {
231
639
  const info = message.params?.targetInfo ?? {}
640
+ if (this.opened !== null && this.opened !== undefined && String(info.targetId ?? '') === this.opened.targetId) this.opened = { targetId: String(this.opened.targetId), url: String(info.url ?? this.opened.url) }
641
+ const door = this.urlDoor
642
+ if (door !== null && door !== undefined && String(info.url ?? '') !== '') {
643
+ try {
644
+ door({ id: String(info.targetId ?? ''), url: String(info.url) })
645
+ } catch {
646
+ /* the wait is already gone */
647
+ }
648
+ }
232
649
  const targetId = String(info.targetId ?? '')
233
650
  for (const child of this.children.values()) if (child.targetId === targetId) child.url = String(info.url ?? '')
234
651
  return
@@ -243,14 +660,16 @@ export class SidecarPage {
243
660
  }
244
661
  if (message.method === 'Runtime.exceptionThrown') {
245
662
  const detail = message.params?.exceptionDetails ?? {}
246
- this.console.push({ type: 'error', text: String(detail.exception?.description ?? detail.text ?? '').slice(0, 300) })
663
+ this.troubleSeq += 1
664
+ this.console.push({ type: 'error', text: String(detail.exception?.description ?? detail.text ?? '').slice(0, 300), seq: this.troubleSeq })
247
665
  if (this.console.length > 200) this.console.splice(0, 50)
248
666
  return
249
667
  }
250
668
  if (message.method === 'Runtime.consoleAPICalled') {
251
669
  const params = message.params ?? {}
252
670
  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 })
671
+ this.troubleSeq += 1
672
+ this.console.push({ type: String(params.type ?? 'log'), text, seq: this.troubleSeq })
254
673
  if (this.console.length > 200) this.console.splice(0, 50)
255
674
  return
256
675
  }
@@ -259,6 +678,11 @@ export class SidecarPage {
259
678
  const request = params.request ?? {}
260
679
  const id = String(params.requestId ?? '')
261
680
  if (id === '') return
681
+ /* A new document replaces whatever document this browser was showing,
682
+ including a PDF that was never drawn. The PDF viewer is the exception:
683
+ it navigates the same tab to its own page, and the PDF is still what
684
+ the tab shows. */
685
+ if (String(params.type ?? '') === 'Document' && !String(request.url ?? '').startsWith('chrome-extension://')) this.pdf = null
262
686
  this.requests.set(id, {
263
687
  method: String(request.method ?? 'GET'),
264
688
  url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
@@ -271,8 +695,17 @@ export class SidecarPage {
271
695
  if (message.method === 'Network.responseReceived') {
272
696
  const params = message.params ?? {}
273
697
  const known = this.requests.get(String(params.requestId ?? ''))
698
+ if (String(params.type ?? '') === 'Document' && String(params.response?.mimeType ?? '').includes('pdf')) {
699
+ this.pdf = {
700
+ url: String(params.response?.url ?? '').slice(0, 300), // ui-copy-ignore
701
+ requestId: String(params.requestId ?? ''),
702
+ bytes: Number(params.response?.encodedDataLength ?? 0),
703
+ }
704
+ }
274
705
  if (known === undefined) return
275
706
  known.status = Number(params.response?.status ?? 0)
707
+ this.troubleSeq += 1
708
+ known.seq = this.troubleSeq
276
709
  this.network.push(known)
277
710
  if (this.network.length > 120) this.network.splice(0, 40)
278
711
  return
@@ -280,7 +713,8 @@ export class SidecarPage {
280
713
  if (message.method === 'Network.loadingFailed') {
281
714
  const params = message.params ?? {}
282
715
  if (params.canceled === true) return
283
- this.failed.push({ error: String(params.errorText ?? ''), type: String(params.type ?? '') })
716
+ this.troubleSeq += 1
717
+ this.failed.push({ error: String(params.errorText ?? ''), type: String(params.type ?? ''), seq: this.troubleSeq })
284
718
  if (this.failed.length > 100) this.failed.splice(0, 25)
285
719
  }
286
720
  }
@@ -307,13 +741,15 @@ export class SidecarPage {
307
741
  * one exists and where it went.
308
742
  * @returns {Promise<{targetId: string, url: string}|null>} the popup.
309
743
  */
310
- async findPopup() {
744
+ async findPopup(foreign = []) {
311
745
  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(() => '')
746
+ const mine = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
747
+ const main = mine !== '' ? mine : this.targetId
748
+ const theirs = new Set(foreign.map(String))
313
749
  const list = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
314
750
  .then((response) => (response.ok ? response.json() : []))
315
751
  .catch(() => [])
316
- const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main)
752
+ const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main && !theirs.has(String(item.id ?? '')))
317
753
  const popup = pages.find((item) => /^https?:|^file:/.test(String(item.url ?? '')))
318
754
  if (popup !== undefined) return { targetId: String(popup.id), url: String(popup.url) }
319
755
  const blank = pages[0]
@@ -343,27 +779,140 @@ export class SidecarPage {
343
779
  * @returns {Promise<string>} what happened, for the tool result.
344
780
  */
345
781
  async adoptOpened() {
346
- let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
782
+ const foreign = typeof this.siblings === 'function' ? this.siblings().map(String) : []
783
+ const theirs = new Set(foreign)
784
+ /* A tab someone opened for another reason is not what this click opened:
785
+ only a target announced in the last couple of seconds counts. */
786
+ const fresh = this.opened !== null && this.opened !== undefined && Date.now() - Number(this.opened.at ?? 0) < 2000
787
+ let target = fresh ? { targetId: this.opened.targetId, url: this.opened.url } : null
347
788
  this.opened = null
789
+ if (target !== null && String(target.url ?? '') === '') {
790
+ const said = await this.waitUrl(target.targetId, 250)
791
+ if (said !== '') target = { targetId: target.targetId, url: said }
792
+ }
793
+ if (target !== null && isForeign(target.targetId, this.targetId, foreign)) target = null
794
+ /* A popup is announced by discovery the moment it exists; the fallback
795
+ probe is only for the case where the click never made one, so it gets a
796
+ single short look instead of the old half-second of polling. */
348
797
  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
798
+ /* Look, wait for the announcement, look again: a new tab is announced
799
+ before the browser lists it, so one look is not enough. */
800
+ const deadline = Date.now() + POPUP_GRACE_MS
801
+ while (target === null && Date.now() < deadline) {
802
+ const found = await this.findPopup(foreign)
803
+ if (found !== null) {
804
+ target = found
805
+ break
806
+ }
807
+ const left = deadline - Date.now()
808
+ if (left <= 0) break
809
+ await this.waitPopup(Math.min(8, left))
356
810
  }
357
811
  }
358
- if (target === null) return ''
812
+ if (target === null) {
813
+ /* Nothing yet: it may still come. Following it then costs no latency. */
814
+ this.armFollow()
815
+ return ''
816
+ }
817
+ this.disarmFollow()
359
818
  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
819
+ /* Close the tab the click opened, then use its own page for the visit. The
820
+ CDP close is the one that works; the HTTP door is only for a browser that
821
+ refused it, because waiting on it costs a round trip per popup click. */
822
+ let closed = false
823
+ try {
824
+ await this.cdp.send('Target.closeTarget', { targetId: target.targetId })
825
+ closed = true
826
+ } catch {
827
+ /* the target is already gone; the HTTP door below can still try */
828
+ }
829
+ if (!closed && 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
830
  if (!/^https?:|^file:/.test(url)) return ''
363
831
  await this.cdp.send('Page.navigate', { url }).catch(() => {})
364
832
  return `opened ${url}`
365
833
  }
366
834
 
835
+ /** Wake a pending popup wait: discovery has news. */
836
+ wakePopup() {
837
+ const door = this.popupDoor
838
+ if (door === null || door === undefined) return
839
+ this.popupDoor = null
840
+ try {
841
+ door()
842
+ } catch {
843
+ /* the wait is already gone */
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Follow announced tabs for a while even though the click already answered.
849
+ * A page that opens its window from a timer is still doing what the click
850
+ * meant; the person would have ended up there too.
851
+ *
852
+ * @param {number} [ms] - how long to keep following.
853
+ */
854
+ armFollow(ms = FOLLOW_WINDOW_MS) {
855
+ this.followUntil = Date.now() + Math.max(0, ms)
856
+ if (this.followTimer !== null) clearTimeout(this.followTimer)
857
+ this.followTimer = setTimeout(() => {
858
+ this.followTimer = null
859
+ this.followUntil = 0
860
+ }, Math.max(0, ms))
861
+ if (typeof this.followTimer?.unref === 'function') this.followTimer.unref()
862
+ }
863
+
864
+ /** A call is starting: whatever the last click might still open is not ours. */
865
+ disarmFollow() {
866
+ this.followUntil = 0
867
+ if (this.followTimer !== null) clearTimeout(this.followTimer)
868
+ this.followTimer = null
869
+ }
870
+
871
+ /**
872
+ * Wait for a popup to say where it is going. Chrome announces a new tab with
873
+ * an empty address and fills it in a moment later; a click that closes the tab
874
+ * because the address is not there yet loses the page the click meant to open.
875
+ *
876
+ * @param {string} targetId - the tab to wait for.
877
+ * @param {number} ms - the ceiling.
878
+ * @returns {Promise<string>} the address, or '' when the window ends.
879
+ */
880
+ waitUrl(targetId, ms) {
881
+ return new Promise((done) => {
882
+ const wanted = String(targetId ?? '')
883
+ const finish = (url) => {
884
+ this.urlDoor = null
885
+ done(String(url ?? ''))
886
+ }
887
+ const timer = setTimeout(() => finish(''), Math.max(0, ms))
888
+ this.urlDoor = ({ id, url }) => {
889
+ if (String(id ?? '') !== wanted) return
890
+ clearTimeout(timer)
891
+ finish(url)
892
+ }
893
+ })
894
+ }
895
+
896
+ /**
897
+ * Wait for a popup announcement, no longer than `ms`: a page that opens
898
+ * nothing costs the window, a page that does is followed at once.
899
+ *
900
+ * @param {number} ms - the ceiling.
901
+ * @returns {Promise<void>} settles when a popup arrives or the window ends.
902
+ */
903
+ waitPopup(ms) {
904
+ return new Promise((done) => {
905
+ const timer = setTimeout(() => {
906
+ this.popupDoor = null
907
+ done()
908
+ }, Math.max(0, ms))
909
+ this.popupDoor = () => {
910
+ clearTimeout(timer)
911
+ done()
912
+ }
913
+ })
914
+ }
915
+
367
916
  /**
368
917
  * The frames of the page, main frame first, each with the context its scripts
369
918
  * run in. Read on demand: a page with no iframes pays one call.
@@ -456,70 +1005,117 @@ export class SidecarPage {
456
1005
 
457
1006
  /** Keep one listener that fans frames out to every viewer. */
458
1007
  async start() {
459
- await this.cdp.send('Network.enable').catch(() => {})
1008
+ this.targetId = String(
1009
+ await this.cdp.send('Target.getTargetInfo').then((info) => info?.targetInfo?.targetId ?? '').catch(() => ''),
1010
+ )
460
1011
  /* A click that downloads should leave a file, not nothing: point downloads
461
1012
  at the profile so the driver can report where they went. */
462
1013
  const folder = downloadsDir(profileDir(this.profile))
463
1014
  mkdirSync(folder, { recursive: true })
464
1015
  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
1016
  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 */
1017
+ /* A CDP event is an event: one bad frame must not take the application
1018
+ down with it, so the whole handler is a fault boundary. */
1019
+ try {
1020
+ this.watch(message)
1021
+ if (message.method !== 'Page.screencastFrame') return
1022
+ const { data, sessionId, metadata } = message.params ?? {}
1023
+ void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
1024
+ if (typeof data !== 'string' || data === '') return
1025
+ this.latest = data
1026
+ this.lastFrameAt = Date.now()
1027
+ this.seq += 1
1028
+ this.bytes += data.length
1029
+ /* Every so often, let the weight of the last frame decide the quality
1030
+ of the next one. Restarting the cast is cheap and rare; a heavy
1031
+ page pays for itself in every frame of every later action. */
1032
+ if ((this.seq % 24 === 0 || this.seq === 1) && Date.now() - this.adaptAt > 3000) {
1033
+ this.adaptAt = Date.now()
1034
+ const wanted = nextQuality(this.castQuality, data.length)
1035
+ if (wanted !== this.castQuality) void this.recast(wanted)
1036
+ }
1037
+ if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
1038
+ /* A page that is not moving sends the same bytes again and again; the
1039
+ pane already shows that picture, so it is acked and dropped. */
1040
+ if (this.frames.size > 0 && this.repeats(data) === true) return
1041
+ if (this.tooSoon()) return
1042
+ this.inflight += 1
1043
+ const frame = {
1044
+ seq: this.seq, // ui-copy-ignore
1045
+ w: this.view.width, // ui-copy-ignore
1046
+ h: this.view.height, // ui-copy-ignore
1047
+ jpeg: data, // ui-copy-ignore
1048
+ url: this.url, // ui-copy-ignore
1049
+ title: this.title, // ui-copy-ignore
1050
+ pointer: this.pointer, // ui-copy-ignore
1051
+ /* Whether the person is driving right now: the pane hides the agent's
1052
+ arrow while they are, so two cursors never claim the same hand. */
1053
+ hold: this.holdOf(), // ui-copy-ignore
1054
+ }
1055
+ for (const sink of this.frames) {
1056
+ try {
1057
+ sink(frame)
1058
+ } catch {
1059
+ /* a closed viewer is dropped by its own stream handler */
1060
+ }
495
1061
  }
1062
+ this.inflight -= 1
1063
+ } catch (error) {
1064
+ this.fault = String(error?.message ?? error) // ui-copy-ignore
496
1065
  }
497
- this.inflight -= 1
498
1066
  })
499
1067
  this.cdp.on((message) => {
500
1068
  if (message.method === 'Page.frameNavigated' && message.params?.frame?.parentId === undefined) {
501
- this.url = String(message.params.frame.url ?? '')
1069
+ const next = String(message.params.frame.url ?? '')
1070
+ if ((next === '' || next === 'about:blank') && this.url !== '' && this.url !== 'about:blank') this.blanked = true
1071
+ this.url = next
1072
+ void this.readTitle()
1073
+ }
1074
+ if (message.method === 'Page.loadEventFired') {
1075
+ this.loadFired = Date.now()
502
1076
  void this.readTitle()
503
1077
  }
504
- if (message.method === 'Page.loadEventFired') void this.readTitle()
1078
+ /* A click that starts a navigation and a click that only changes the page
1079
+ are different: the first needs waiting on, the second does not. */
1080
+ if (message.method === 'Page.frameStartedLoading') this.navStarted = Date.now()
1081
+ if (message.method === 'Page.frameStoppedLoading') this.navStopped = Date.now()
505
1082
  })
506
- await this.cdp.send('Page.enable')
1083
+ /* None of these settings depends on another: asking for all of them at
1084
+ once is one round trip instead of four, and a cold start is where it
1085
+ shows. */
1086
+ await Promise.all([
1087
+ this.cdp.send('Page.enable'),
1088
+ this.cdp.send('Network.enable').catch(() => {}),
1089
+ this.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: folder }).catch(() => {}),
1090
+ /* Popups and out-of-process frames are separate targets. Attaching without
1091
+ waiting keeps them usable, and lets the mirror follow a new tab. */
1092
+ this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => {}),
1093
+ /* Discovery is what makes a popup visible the instant it is created,
1094
+ instead of after the click has already returned. */
1095
+ this.cdp.send('Target.setDiscoverTargets', { discover: true }).catch(() => {}),
1096
+ ])
507
1097
  /* An instance left behind by a stopped app can still be sitting on a
508
1098
  dialog; a paused renderer answers nothing, so clear it first. */
509
1099
  await Promise.race([
510
1100
  this.cdp.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}),
511
1101
  new Promise((resolve) => setTimeout(resolve, 800)),
512
1102
  ])
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
- })
1103
+ /* The metrics and the version string are independent too: the UA override
1104
+ only needs the version, not a turn of its own. */
1105
+ const [, version] = await Promise.all([
1106
+ this.cdp.send('Runtime.enable').then(() =>
1107
+ this.cdp.send('Emulation.setDeviceMetricsOverride', {
1108
+ width: this.view.width, // ui-copy-ignore
1109
+ height: this.view.height, // ui-copy-ignore
1110
+ deviceScaleFactor: 1,
1111
+ mobile: false, // ui-copy-ignore
1112
+ }),
1113
+ ),
1114
+ this.cdp.send('Browser.getVersion').catch(() => ({ product: '' })),
1115
+ ])
520
1116
  /* A headless build announces itself in the UA, and some sites refuse that
521
1117
  string outright. The page is a normal Chrome here, so it says so. */
522
- const { product } = await this.cdp.send('Browser.getVersion').catch(() => ({ product: '' }))
1118
+ const product = String(version?.product ?? '')
523
1119
  if (String(product).includes('HeadlessChrome')) {
524
1120
  const version = String(product).replace(/.*HeadlessChrome\//, '')
525
1121
  await this.cdp.send('Emulation.setUserAgentOverride', {
@@ -548,28 +1144,200 @@ export class SidecarPage {
548
1144
  }
549
1145
  }
550
1146
 
1147
+ /**
1148
+ * Change the quality the mirror is cast at, without leaving the pane black.
1149
+ * @param {number} quality - the new JPEG quality.
1150
+ * @returns {Promise<void>} resolves once the new cast is asked for.
1151
+ */
1152
+ async recast(quality) {
1153
+ if (this.casting !== true || this.flashBridge !== null) return
1154
+ try {
1155
+ this.castQuality = quality
1156
+ await this.cdp.send('Page.stopScreencast').catch(() => {})
1157
+ this.casting = false
1158
+ await this.cast()
1159
+ } catch {
1160
+ /* a page that died mid-recast will be picked up by the next view */
1161
+ }
1162
+ }
1163
+
551
1164
  /** @returns {Promise<void>} resolves once the page is casting. */
552
1165
  async cast() {
553
- if (this.casting) return
1166
+ if (this.flashBridge !== null) {
1167
+ if (this.casting) return
1168
+ this.casting = true
1169
+ void this.pollBridge()
1170
+ return
1171
+ }
1172
+ if (this.casting) {
1173
+ /* A page nobody repaints still owes the pane a picture, so silence for a
1174
+ few seconds means the cast died rather than being idle. */
1175
+ if (Date.now() - this.lastFrameAt < 3000) return
1176
+ this.casting = false
1177
+ }
554
1178
  this.casting = true
555
1179
  /* Full-resolution mirror at a quality that keeps small text readable;
556
1180
  `optimizeForSpeed` keeps the encoder from adding to the round trip. */
557
1181
  await this.cdp.send('Page.startScreencast', {
558
1182
  format: 'jpeg', // ui-copy-ignore
559
- quality: 72, // ui-copy-ignore
560
- maxWidth: MIRROR.width, // ui-copy-ignore
561
- maxHeight: MIRROR.height,
1183
+ quality: this.castQuality, // ui-copy-ignore
1184
+ maxWidth: this.view.width, // ui-copy-ignore
1185
+ maxHeight: this.view.height,
562
1186
  optimizeForSpeed: true,
563
1187
  everyNthFrame: 1,
564
1188
  }).catch(() => {
565
1189
  this.casting = false
566
1190
  })
1191
+ void this.still()
1192
+ }
1193
+
1194
+ /**
1195
+ * The column changed size: lay the page out at that size so the picture fills
1196
+ * the frame instead of sitting letterboxed inside it.
1197
+ * @param {number} width - CSS pixels of the frame. @param {number} height - CSS pixels of the frame.
1198
+ */
1199
+ async setViewport(width, height) {
1200
+ const w = Math.round(Number(width))
1201
+ const h = Math.round(Number(height))
1202
+ /* A narrow column with a long page is still a browser: refusing its size
1203
+ leaves the picture letterboxed in the pane, which is what makes a mirror
1204
+ look like it does not fill the column. */
1205
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w < 200 || h < 200) return this.view
1206
+ if (w > 2560 || h > 2560) return this.view
1207
+ if (w === this.view.width && h === this.view.height) return this.view
1208
+ this.view = { width: w, height: h }
1209
+ this.touched = Date.now()
1210
+ if (this.flashBridge !== null) {
1211
+ /* The Flash window is the picture: resize it so the game fills the pane
1212
+ instead of sitting letterboxed in a landscape window. */
1213
+ await fetch(`http://127.0.0.1:${this.flashBridge}/resize?w=${w}&h=${h}`).catch(() => {})
1214
+ return this.view
1215
+ }
1216
+ await this.cdp.send('Emulation.setDeviceMetricsOverride', {
1217
+ width: w,
1218
+ height: h,
1219
+ deviceScaleFactor: 1,
1220
+ mobile: false, // ui-copy-ignore
1221
+ }).catch(() => {})
1222
+ /* A Flash window is captured from a hidden app: there is no screencast to
1223
+ restart, and its own resize path owns the picture. */
1224
+ if (this.casting === true) {
1225
+ await this.cdp.send('Page.stopScreencast').catch(() => {})
1226
+ this.casting = false
1227
+ await this.cast()
1228
+ }
1229
+ return this.view
1230
+ }
1231
+
1232
+ /** @returns {Promise<string>} one JPEG of the page, base64, or empty. */
1233
+ async picture() {
1234
+ if (this.flashBridge !== null) {
1235
+ try {
1236
+ const response = await fetch(`http://127.0.0.1:${this.flashBridge}/frame.jpg`) // ui-copy-ignore
1237
+ if (!response.ok) return ''
1238
+ return Buffer.from(await response.arrayBuffer()).toString('base64')
1239
+ } catch {
1240
+ return ''
1241
+ }
1242
+ }
1243
+ try {
1244
+ const shot = await this.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 72, fromSurface: true })
1245
+ return String(shot?.data ?? '')
1246
+ } catch {
1247
+ return ''
1248
+ }
1249
+ }
1250
+
1251
+ /**
1252
+ * Whether the pane was sent a picture a moment ago. Frames still update
1253
+ * `latest` and the counters; only the downlink is paced.
1254
+ * @returns {boolean} true when this frame should not be forwarded.
1255
+ */
1256
+ tooSoon() {
1257
+ const now = Date.now()
1258
+ /* Someone moving the mouse or holding the page is watching closely: their
1259
+ frame goes out at full rate. A page animating on its own is paced. */
1260
+ const busy = now - this.lastPointerAt < 1500
1261
+ const gap = busy ? 0 : FRAME_GAP_MS
1262
+ if (this.frames.size > 0 && now - this.sentAt < gap) return true
1263
+ this.sentAt = now
1264
+ return false
1265
+ }
1266
+
1267
+ /** Feeds one picture to every viewer of this browser. @param {string} data - base64 JPEG. */
1268
+ publish(data) {
1269
+ if (data === '' || this.frames.size === 0) return
1270
+ this.latest = data
1271
+ if (this.repeats(data) === true) return
1272
+ if (this.tooSoon()) return
1273
+ this.lastFrameAt = Date.now()
1274
+ this.seq += 1
1275
+ const frame = {
1276
+ seq: this.seq, // ui-copy-ignore
1277
+ w: this.view.width, // ui-copy-ignore
1278
+ h: this.view.height, // ui-copy-ignore
1279
+ jpeg: data, // ui-copy-ignore
1280
+ url: this.url, // ui-copy-ignore
1281
+ title: this.title, // ui-copy-ignore
1282
+ hold: this.holdOf(), // ui-copy-ignore
1283
+ pointer: this.pointer, // ui-copy-ignore
1284
+ }
1285
+ for (const sink of this.frames) {
1286
+ try {
1287
+ sink(frame)
1288
+ } catch {
1289
+ /* a closed viewer is dropped by its own stream handler */
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ /**
1295
+ * Whether this picture repeats the one the pane is already showing.
1296
+ * @param {string} jpeg - base64 JPEG of the new frame.
1297
+ * @returns {boolean} true when sending it would just cost bytes.
1298
+ */
1299
+ repeats(jpeg, gap = 1000) {
1300
+ const prev = this.lastShot
1301
+ const same = prev !== null && prev === jpeg
1302
+ const quietFor = Date.now() - this.lastShotAt
1303
+ this.lastShot = jpeg
1304
+ if (same === true && quietFor < gap) {
1305
+ this.quiet += 1
1306
+ return true
1307
+ }
1308
+ this.lastShotAt = Date.now()
1309
+ return false
1310
+ }
1311
+
1312
+ /** Sends one full-frame picture now: a mirror that waits for motion shows an empty pane on a page that is not moving. */
1313
+ async still() {
1314
+ this.publish(await this.picture())
1315
+ }
1316
+
1317
+ /** Keeps a Flash page's mirror moving at a browser-like rate without a screencast. */
1318
+ async pollBridge() {
1319
+ while (this.casting) {
1320
+ const started = Date.now()
1321
+ if (Date.now() - this.lastFrameAt > 1500) this.publish(await this.picture())
1322
+ else {
1323
+ const frame = await this.picture()
1324
+ if (frame !== this.latest) this.publish(frame)
1325
+ }
1326
+ const spent = Date.now() - started
1327
+ /* The Flash bridge answers from a ready JPEG, so its ceiling is the
1328
+ bridge's own pump rather than the browser's repaint interval. Poll
1329
+ fast while the page is moving, slowly when it is still: a still page
1330
+ costs one fetch per beat for nothing, a moving one is the mirror. */
1331
+ const moving = Date.now() - this.lastFrameAt < 400
1332
+ const interval = moving ? MIRROR.busy : MIRROR.interval
1333
+ await wait(Math.max(0, interval - spent))
1334
+ }
567
1335
  }
568
1336
 
569
1337
  async stopCast() {
570
1338
  if (!this.casting) return
571
1339
  this.casting = false
572
- await this.cdp.send('Page.stopScreencast').catch(() => {})
1340
+ if (this.flashBridge === null) await this.cdp.send('Page.stopScreencast').catch(() => {})
573
1341
  }
574
1342
 
575
1343
  /**
@@ -578,14 +1346,40 @@ export class SidecarPage {
578
1346
  */
579
1347
  async navigate(url) {
580
1348
  this.touched = Date.now()
1349
+ this.navAt = Date.now()
581
1350
  this.lastUrl = String(url)
582
1351
  await this.cdp.send('Page.navigate', { url })
1352
+ /* A blank page is where the wait ends, not a place still to arrive: asking
1353
+ for about:blank and then waiting for a non-blank address spent the whole
1354
+ 20 s budget, which is how a brand-new chat's first tab took forever. */
1355
+ if (String(url) === '' || String(url) === 'about:blank') {
1356
+ await wait(80)
1357
+ await this.readTitle()
1358
+ return { url: this.url, title: this.title }
1359
+ }
583
1360
  for (let i = 0; i < 200; i += 1) {
584
1361
  await wait(100)
585
1362
  await this.readTitle()
586
1363
  if (this.url !== '' && this.url !== 'about:blank') break
587
1364
  }
588
- return { url: this.url, title: this.title }
1365
+ /* A brand-new browser sometimes answers the very first navigation with its
1366
+ own error page: the network stack is still coming up. One reload of that
1367
+ address is the difference between the chat seeing the site and the chat
1368
+ seeing a browser error in the mirror. */
1369
+ /* Only a browser that was still starting up answers instantly with its own
1370
+ error page; one that took seconds to fail is the network's answer, and
1371
+ asking it again costs another ten seconds of the same. */
1372
+ if (this.url.startsWith('chrome-error://') && Date.now() - this.navAt < 2500) {
1373
+ await wait(300)
1374
+ await this.cdp.send('Page.reload', {}).catch(() => {})
1375
+ for (let i = 0; i < 40; i += 1) {
1376
+ await wait(100)
1377
+ await this.readTitle()
1378
+ if (this.url !== '' && !this.url.startsWith('chrome-error://') && this.url !== 'about:blank') break
1379
+ }
1380
+ }
1381
+ const bad = this.url.startsWith('chrome-error://') ? 'the page did not load' : ''
1382
+ return bad === '' ? { url: this.url, title: this.title } : { url: this.url, title: this.title, bad }
589
1383
  }
590
1384
 
591
1385
  /**
@@ -607,6 +1401,35 @@ export class SidecarPage {
607
1401
  }
608
1402
  }
609
1403
 
1404
+ /**
1405
+ * What went wrong since the last time this was asked: failed requests, error
1406
+ * statuses and JS exceptions, in one line. Silently empty when nothing did.
1407
+ * @returns {string} a line to append to a read, or ''.
1408
+ */
1409
+ troubleOf() {
1410
+ const after = Number(this.troubleSeen ?? 0)
1411
+ const errors = (this.console ?? []).filter((row) => row.type === 'error' && Number(row.seq ?? 0) > after)
1412
+ const bad = (this.network ?? []).filter((row) => Number(row.seq ?? 0) > after && Number(row.status ?? 0) >= 400)
1413
+ const lost = (this.failed ?? []).filter((row) => Number(row.seq ?? 0) > after && !String(row.error ?? '').includes('ERR_ABORTED'))
1414
+ const newest = [errors, bad, lost].reduce((top, rows) => rows.reduce((best, row) => Math.max(best, Number(row.seq ?? 0)), top), 0)
1415
+ if (newest === 0) return ''
1416
+ this.troubleSeen = newest
1417
+ /* A raw address is mostly origin noise; the path is what names the request. */
1418
+ const where = (url) => {
1419
+ try {
1420
+ const parsed = new URL(String(url))
1421
+ return `${parsed.pathname}${parsed.search}`.slice(0, 70)
1422
+ } catch {
1423
+ return String(url ?? '').slice(0, 70)
1424
+ }
1425
+ }
1426
+ const says = []
1427
+ if (bad.length > 0) says.push(`${bad.length} request${bad.length === 1 ? '' : 's'} failed (${String(bad[bad.length - 1].method ?? 'GET')} ${where(bad[bad.length - 1].url)} → ${bad[bad.length - 1].status})`)
1428
+ if (lost.length > 0) says.push(`${lost.length} request${lost.length === 1 ? '' : 's'} did not load (${String(lost[lost.length - 1].error ?? '').slice(0, 40)})`)
1429
+ if (errors.length > 0) says.push(`${errors.length} JS error${errors.length === 1 ? '' : 's'} (${String(errors[errors.length - 1].text ?? '').split('\n')[0].slice(0, 90)})`)
1430
+ return `page trouble: ${says.join(' · ')} — browser_network for the full list`
1431
+ }
1432
+
610
1433
  async reload() {
611
1434
  this.touched = Date.now()
612
1435
  await this.cdp.send('Page.reload', {})
@@ -626,11 +1449,28 @@ export class SidecarPage {
626
1449
  return { url: this.url, moved: true }
627
1450
  }
628
1451
 
1452
+ /**
1453
+ * Say the hand changed owners without waiting for the next picture: on a
1454
+ * still page the agent's arrow used to sit there until the idle repaint, and
1455
+ * a person who took the mouse saw it on top of their own cursor.
1456
+ */
1457
+ announceHold() {
1458
+ const message = { type: 'pointer', w: this.view.width, h: this.view.height, pointer: this.pointer, hold: this.holdOf() } // ui-copy-ignore
1459
+ for (const sink of this.pointerSinks) {
1460
+ try {
1461
+ sink(message)
1462
+ } catch {
1463
+ /* a closed viewer is dropped by its own stream handler */
1464
+ }
1465
+ }
1466
+ }
1467
+
629
1468
  /** @param {{x: number, y: number}} point - where the pointer goes. */
630
1469
  movePointer(point) {
631
1470
  this.pointer = { x: Math.round(point.x), y: Math.round(point.y) }
1471
+ this.lastPointerAt = Date.now()
632
1472
  this.touched = Date.now()
633
- const message = { type: 'pointer', w: VIEW.width, h: VIEW.height, pointer: this.pointer }
1473
+ const message = { type: 'pointer', w: this.view.width, h: this.view.height, pointer: this.pointer, hold: this.holdOf() } // ui-copy-ignore
634
1474
  for (const sink of this.pointerSinks) {
635
1475
  try {
636
1476
  sink(message)
@@ -683,6 +1523,7 @@ export class SidecarPage {
683
1523
  }
684
1524
 
685
1525
  async hover(point) {
1526
+ if (this.flashBridge !== null && (await this.flashInput('mouseMove', point)) === true) return this.cursorAt(point)
686
1527
  await this.input('Input.dispatchMouseEvent', {
687
1528
  type: 'mouseMoved', // ui-copy-ignore
688
1529
  x: Math.round(point.x),
@@ -700,8 +1541,32 @@ export class SidecarPage {
700
1541
  * @param {{x: number, y: number, button?: string, clickCount?: number}} point - CSS pixels.
701
1542
  * @returns {Promise<void>} resolves when Chrome took the event.
702
1543
  */
1544
+ /**
1545
+ * Hands one mouse event to the Flash window directly, skipping the debugging
1546
+ * protocol on the way in.
1547
+ * @param {string} kind - mouseDown, mouseUp or mouseMove.
1548
+ * @param {{x: number, y: number, button?: string, clickCount?: number}} point - CSS pixels.
1549
+ * @returns {Promise<boolean>} true when the window took it.
1550
+ */
1551
+ async flashInput(kind, point) {
1552
+ const started = Date.now()
1553
+ try {
1554
+ const reply = await fetch(`http://127.0.0.1:${this.flashBridge}/input`, {
1555
+ method: 'POST',
1556
+ headers: { 'content-type': 'application/json' },
1557
+ body: JSON.stringify({ type: kind, x: Math.round(point.x), y: Math.round(point.y), button: point.button ?? 'left', clickCount: point.clickCount ?? (kind === 'mouseMove' ? 0 : 1) }),
1558
+ signal: AbortSignal.timeout(1200),
1559
+ })
1560
+ this.inputMs = Date.now() - started
1561
+ return reply.ok
1562
+ } catch {
1563
+ return false
1564
+ }
1565
+ }
1566
+
703
1567
  async mouse(type, point) {
704
1568
  this.movePointer(point)
1569
+ if (this.flashBridge !== null && (await this.flashInput(type === 'mousePressed' ? 'mouseDown' : type === 'mouseReleased' ? 'mouseUp' : 'mouseMove', point)) === true) return
705
1570
  await this.input('Input.dispatchMouseEvent', {
706
1571
  type,
707
1572
  x: point.x,
@@ -719,6 +1584,19 @@ export class SidecarPage {
719
1584
  */
720
1585
  async wheel(args) {
721
1586
  this.movePointer(args)
1587
+ if (this.flashBridge !== null) {
1588
+ try {
1589
+ const reply = await fetch(`http://127.0.0.1:${this.flashBridge}/input`, {
1590
+ method: 'POST',
1591
+ headers: { 'content-type': 'application/json' },
1592
+ body: JSON.stringify({ type: 'mouseWheel', x: Math.round(args.x), y: Math.round(args.y), deltaX: Math.round(args.dx ?? 0), deltaY: Math.round(args.dy ?? 0) }),
1593
+ signal: AbortSignal.timeout(1200),
1594
+ })
1595
+ if (reply.ok === true) return
1596
+ } catch {
1597
+ /* the debugging protocol below is the fallback */
1598
+ }
1599
+ }
722
1600
  await this.input('Input.dispatchMouseEvent', {
723
1601
  type: 'mouseWheel', // ui-copy-ignore
724
1602
  x: args.x, // ui-copy-ignore
@@ -729,6 +1607,41 @@ export class SidecarPage {
729
1607
  })
730
1608
  }
731
1609
 
1610
+ /**
1611
+ * One keystroke into the Flash window: a key name for control keys, a
1612
+ * character for text, both carrying the modifiers a person holds.
1613
+ * @param {string} type - keyDown or keyUp.
1614
+ * @param {{key: string, text?: string, modifiers?: number}} key - one key.
1615
+ * @returns {Promise<boolean>} true when the window took it.
1616
+ */
1617
+ async flashKey(type, key) {
1618
+ const bits = Number(key.modifiers ?? 0)
1619
+ const modifiers = []
1620
+ if ((bits & 1) !== 0) modifiers.push('alt')
1621
+ if ((bits & 2) !== 0) modifiers.push('control')
1622
+ if ((bits & 4) !== 0) modifiers.push('meta')
1623
+ if ((bits & 8) !== 0) modifiers.push('shift')
1624
+ const char = String(key.text ?? '')
1625
+ const named = String(key.key ?? '')
1626
+ const code = char !== '' ? char : named
1627
+ const events = [{ type, keyCode: code, modifiers }]
1628
+ if (type === 'keyDown' && char !== '') events.push({ type: 'char', keyCode: char, modifiers })
1629
+ try {
1630
+ for (const event of events) {
1631
+ const reply = await fetch(`http://127.0.0.1:${this.flashBridge}/input`, {
1632
+ method: 'POST',
1633
+ headers: { 'content-type': 'application/json' },
1634
+ body: JSON.stringify(event),
1635
+ signal: AbortSignal.timeout(1200),
1636
+ })
1637
+ if (reply.ok !== true) return false
1638
+ }
1639
+ return true
1640
+ } catch {
1641
+ return false
1642
+ }
1643
+ }
1644
+
732
1645
  /**
733
1646
  * @param {string} type - `keyDown` or `keyUp`.
734
1647
  * @param {{key: string, code?: string, keyCode?: number, text?: string, modifiers?: number}} key - one key.
@@ -736,9 +1649,11 @@ export class SidecarPage {
736
1649
  */
737
1650
  async key(type, key) {
738
1651
  this.touched = Date.now()
1652
+ if (this.flashBridge !== null && (await this.flashKey(type, key)) === true) return
739
1653
  const virtual = key.keyCode ?? key.windowsVirtualKeyCode ?? 0
740
1654
  await this.input('Input.dispatchKeyEvent', {
741
- type: key.text === undefined || key.text === '' ? type : (type === 'keyDown' ? 'keyDown' : type),
1655
+ type: key.autoRepeat === true && type === 'keyDown' ? 'keyDown' : type,
1656
+ ...(key.autoRepeat === true && type === 'keyDown' ? { autoRepeat: true } : {}),
742
1657
  key: key.key, // ui-copy-ignore
743
1658
  code: key.code ?? '', // ui-copy-ignore
744
1659
  windowsVirtualKeyCode: virtual,
@@ -746,9 +1661,149 @@ export class SidecarPage {
746
1661
  text: key.text, // ui-copy-ignore
747
1662
  unmodifiedText: key.text,
748
1663
  modifiers: key.modifiers ?? 0,
1664
+ ...(Array.isArray(key.commands) && key.commands.length > 0 ? { commands: key.commands } : {}),
749
1665
  })
750
1666
  }
751
1667
 
1668
+ /**
1669
+ * Type the way a person does: one character at a time, uneven gaps, a longer
1670
+ * pause where a person would think, and no clipboard paste at all. Paste is
1671
+ * the single event that tells a site a machine filled the box; per-character
1672
+ * key events do not. Longer messages are typed in small bursts so a whole
1673
+ * page of text does not take a minute.
1674
+ * @param {string} text - any unicode, including Chinese.
1675
+ * @param {{burst?: number, min?: number, max?: number}} [options] - pacing.
1676
+ * @returns {Promise<void>} resolves once the last character was accepted.
1677
+ */
1678
+ async humanType(text, options = {}) {
1679
+ this.touched = Date.now()
1680
+ const chars = Array.from(String(text))
1681
+ /* A long message is typed faster per burst: the opening still looks like
1682
+ fingers, but nobody wants to wait fifteen seconds for a paragraph. */
1683
+ const long = chars.length > 160
1684
+ const burst = Math.max(1, Number(options.burst ?? (long ? 8 : 3)))
1685
+ /* A keystroke's *shape* is what a page can check (keyDown with its own
1686
+ text, a real focus, the right input events); the gaps between keys only
1687
+ cost the caller time. A short word still gets human-sized jitter. */
1688
+ const min = Number(options.min ?? (long ? 6 : 10))
1689
+ const max = Math.max(min + 1, Number(options.max ?? (long ? 14 : 28)))
1690
+ const gap = () => min + Math.random() * (max - min)
1691
+ let at = 0
1692
+ while (at < chars.length) {
1693
+ const size = at < 12 ? 1 : burst
1694
+ const chunk = chars.slice(at, at + size).join('')
1695
+ /* One key event per character is what a keyboard produces. A multi-letter
1696
+ key event would only deliver its first letter, so a run of Latin text
1697
+ is pressed key by key; a run of Chinese arrives as an input-method
1698
+ commit, which is one event for a whole phrase. */
1699
+ if (size > 1 && /[\u3040-\u9fff\uac00-\ud7af]/.test(chunk)) {
1700
+ await this.input('Input.insertText', { text: chunk })
1701
+ } else {
1702
+ for (const one of Array.from(chunk)) {
1703
+ if (this.flashBridge !== null && (await this.flashKey('keyDown', { key: one, text: one })) === true) {
1704
+ await this.flashKey('keyUp', { key: one })
1705
+ continue
1706
+ }
1707
+ await this.input('Input.dispatchKeyEvent', {
1708
+ type: 'keyDown', // ui-copy-ignore
1709
+ key: one, // ui-copy-ignore
1710
+ text: one, // ui-copy-ignore
1711
+ unmodifiedText: one, // ui-copy-ignore
1712
+ windowsVirtualKeyCode: 0,
1713
+ nativeVirtualKeyCode: 0,
1714
+ }).catch(() => {})
1715
+ }
1716
+ }
1717
+ at += size
1718
+ const last = chars[at - 1] ?? ''
1719
+ /* Stop longer after a full stop or a comma, the way a person does. */
1720
+ const pause = /[。!?,、;:\n.,!?;:]/.test(last) ? gap() * 2 + 30 : gap()
1721
+ await wait(at < chars.length ? pause : 0)
1722
+ }
1723
+ }
1724
+
1725
+ /**
1726
+ * Types a long text in one event, the way a paste does.
1727
+ * @param {string} text - any unicode, including Chinese.
1728
+ * @returns {Promise<boolean>} whether the focused box really took the text.
1729
+ */
1730
+ async quickType(text) {
1731
+ this.touched = Date.now()
1732
+ /** @returns {Promise<string>} what the focused box holds now. */
1733
+ const holds = async () => {
1734
+ const said = await this.cdp.send('Runtime.evaluate', {
1735
+ expression: '(() => { const el = document.activeElement; return el === null ? "" : String(el.value ?? el.innerText ?? "") })()',
1736
+ returnByValue: true,
1737
+ }).catch(() => null)
1738
+ return String(said?.result?.value ?? '')
1739
+ }
1740
+ const before = await holds()
1741
+ await this.input('Input.insertText', { text }).catch(() => {})
1742
+ const after = await holds()
1743
+ /* The box is the judge: an editor that only listens to key events ignores
1744
+ this, and the caller types the slow way instead. */
1745
+ return after !== before && after.includes(String(text).trim().slice(0, 12))
1746
+ }
1747
+
1748
+ /**
1749
+ * Save the PDF this tab is showing, so its text is reachable without a render.
1750
+ * @returns {Promise<{path: string, bytes: number, url: string}>} the file, or an empty path.
1751
+ */
1752
+ async documentFile() {
1753
+ const found = this.pdf
1754
+ if (found === null || found === undefined || String(found.url ?? '') === '') return { path: '', bytes: 0, url: '' }
1755
+ if (typeof found.path === 'string' && found.path !== '') {
1756
+ return { path: found.path, bytes: Number(found.bytes ?? 0), url: String(found.url ?? '') }
1757
+ }
1758
+ /* The viewer's own page is what the tab navigated to, so the body Chrome
1759
+ hands back can be that page rather than the document. The file itself
1760
+ has the final say: a PDF starts with %PDF. */
1761
+ let bytes = null
1762
+ try {
1763
+ const answer = await this.cdp.send('Network.getResponseBody', { requestId: String(found.requestId ?? '') })
1764
+ const raw = Buffer.from(String(answer?.body ?? ''), answer?.base64Encoded === true ? 'base64' : 'utf8')
1765
+ if (raw.length > 4 && raw.subarray(0, 4).toString() === '%PDF') bytes = raw
1766
+ } catch (error) {
1767
+ bytes = null
1768
+ }
1769
+ if (bytes === null) {
1770
+ try {
1771
+ const { cookies } = await this.cdp.send('Network.getCookies', { urls: [String(found.url)] })
1772
+ const cookie = (cookies ?? []).map((one) => `${String(one.name)}=${String(one.value)}`).join('; ')
1773
+ const answer = await fetch(String(found.url), {
1774
+ headers: cookie === '' ? {} : { cookie },
1775
+ redirect: 'follow',
1776
+ })
1777
+ if (answer.ok === true) {
1778
+ const said = await answer.arrayBuffer()
1779
+ if (said.byteLength > 0 && said.byteLength <= 64 * 1024 * 1024) bytes = Buffer.from(said)
1780
+ }
1781
+ } catch (error) {
1782
+ bytes = null
1783
+ }
1784
+ }
1785
+ if (bytes === null) return { path: '', bytes: 0, url: String(found.url ?? '') }
1786
+ const folder = this.downloadsFolder ?? downloadsDir(profileDir(this.profile))
1787
+ const name = (() => {
1788
+ try {
1789
+ const last = new URL(String(found.url ?? '')).pathname.split('/').filter((part) => part !== '').pop() ?? ''
1790
+ const clean = decodeURIComponent(last).replace(/[^\w.-]+/g, '-').slice(-60)
1791
+ return clean.toLowerCase().endsWith('.pdf') ? clean : `${clean === '' ? 'document' : clean}.pdf`
1792
+ } catch (error) {
1793
+ return 'document.pdf'
1794
+ }
1795
+ })()
1796
+ try {
1797
+ mkdirSync(folder, { recursive: true })
1798
+ writeFileSync(join(folder, name), bytes)
1799
+ found.path = join(folder, name)
1800
+ found.bytes = bytes.length
1801
+ return { path: found.path, bytes: found.bytes, url: String(found.url ?? '') }
1802
+ } catch (error) {
1803
+ return { path: '', bytes: 0, url: String(found.url ?? '') }
1804
+ }
1805
+ }
1806
+
752
1807
  /** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
753
1808
  async insertText(text) {
754
1809
  this.touched = Date.now()
@@ -900,11 +1955,28 @@ export class Sidecar {
900
1955
  constructor(options = {}) {
901
1956
  this.logger = options.logger
902
1957
  this.child = null
1958
+ this.owns = false
1959
+ this.stopping = false
903
1960
  this.cdp = null
904
1961
  this.page = null
1962
+ /* One tab per chat: a second chat opening an address gets a tab of its
1963
+ own, so neither can navigate the other's page away. */
1964
+ this.pages = new Map()
1965
+ /* Tabs left behind by an earlier plugin load are closed once, on the first
1966
+ call that finds this browser: a profile used for weeks otherwise keeps a
1967
+ renderer per chat that ever opened it. */
1968
+ this.pruned = false
1969
+ /* A tab being opened for a chat: a second call for the same chat waits for
1970
+ it instead of opening a twin. */
1971
+ this.opening = new Map()
905
1972
  /* A measurement or a test may want a profile of its own; the person's
906
1973
  browser keeps the default one. */
907
1974
  this.profile = String(options.profile ?? 'default')
1975
+ /* A movie page needs a runtime Chrome no longer has; the switch is the
1976
+ person's, and it swaps the browser rather than the protocol. */
1977
+ this.flash = options.flash === undefined ? process.env.DSH_INNER_BROWSER_FLASH === '1' || readFlashChoice() : options.flash === true
1978
+ /* The Flash app serves its own frames; the screencast cannot see a plugin. */
1979
+ this.flashBridge = null
908
1980
  this.headless = true
909
1981
  this.starting = null
910
1982
  this.idle = null
@@ -971,20 +2043,289 @@ export class Sidecar {
971
2043
  }
972
2044
 
973
2045
  /**
974
- * @param {{profile?: string, headless?: boolean, url?: string}} [options] - how to start.
2046
+ * The tab a chat drives. A named chat gets a tab of its own, made once and
2047
+ * kept; an unnamed caller (a test, a screenshot script) drives the first tab.
2048
+ * @param {string} session - chat id, empty for the default tab.
2049
+ * @returns {SidecarPage | null} the tab, if the browser is up.
2050
+ */
2051
+ pageFor(session) {
2052
+ const id = String(session ?? '')
2053
+ if (id === '') return this.page
2054
+ const mine = this.pages.get(id)
2055
+ if (mine !== undefined && mine.cdp?.closed !== true) return mine
2056
+ if (mine !== undefined) this.pages.delete(id)
2057
+ return null
2058
+ }
2059
+
2060
+ /**
2061
+ * Keep a chat's tab address on disk, so a reload can bring the page back.
2062
+ * @param {string} session - chat id.
2063
+ * @param {string} url - address that chat's tab is showing.
2064
+ */
2065
+ rememberTab(session, url) {
2066
+ const id = String(session ?? '')
2067
+ const address = String(url ?? '')
2068
+ /* Only chats, and only addresses: a stray key from a probe or an older run
2069
+ must not sit in this file pretending to be a chat that can be restored. */
2070
+ if (!/^session-/.test(id) || address === '' || address === 'about:blank') return
2071
+ /* The note is read once and kept in memory: a liveness check per keystroke
2072
+ was parsing this file on every key. */
2073
+ if (tabsCache === null) {
2074
+ try {
2075
+ tabsCache = JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}
2076
+ } catch {
2077
+ tabsCache = {}
2078
+ }
2079
+ }
2080
+ const saved = tabsCache
2081
+ if (saved[id] === address) return
2082
+ saved[id] = address
2083
+ try {
2084
+ mkdirSync(tabsDir, { recursive: true })
2085
+ writeFileSync(tabsFile, JSON.stringify(saved))
2086
+ } catch {
2087
+ /* a browser that cannot write its note still works */
2088
+ }
2089
+ }
2090
+
2091
+ /**
2092
+ * @param {string} session - a chat id.
2093
+ * @returns {string} the address that chat last had, or '' when it never had one.
2094
+ */
2095
+ rememberedTab(session) {
2096
+ const id = String(session ?? '')
2097
+ if (!/^session-/.test(id)) return ''
2098
+ try {
2099
+ const saved = JSON.parse(readFileSync(tabsFile, 'utf8'))
2100
+ return String(saved?.[id] ?? '')
2101
+ } catch {
2102
+ return ''
2103
+ }
2104
+ }
2105
+
2106
+ /**
2107
+ * Give a chat its own tab, on the same browser: logins are shared, addresses
2108
+ * are not.
2109
+ * @param {string} session - chat id.
2110
+ * @param {string} [url] - address to open in it.
2111
+ * @returns {Promise<SidecarPage>} its tab.
2112
+ */
2113
+ async tabFor(session, url) {
2114
+ const id = String(session ?? '')
2115
+ const had = this.pageFor(id)
2116
+ if (had !== null) {
2117
+ if (url !== undefined && url !== '') await had.navigate(url)
2118
+ return had
2119
+ }
2120
+ /* Two calls that start together are one chat, and a chat has one tab: the
2121
+ second waits for the first tab instead of opening a twin nobody holds. */
2122
+ const underway = this.opening?.get(id)
2123
+ if (underway !== undefined) {
2124
+ const page = await underway
2125
+ const want = String(url ?? '')
2126
+ if (want !== '' && String(page.url ?? '') !== want) await page.navigate(want)
2127
+ return page
2128
+ }
2129
+ if (this.pages.size >= MAX_TABS) await this.trimTabs()
2130
+ const work = this.openTab(id, url).finally(() => {
2131
+ this.opening?.delete(id)
2132
+ })
2133
+ this.opening?.set(id, work)
2134
+ return work
2135
+ }
2136
+
2137
+ /**
2138
+ * Close tabs nobody owns. A browser reused across plugin loads keeps every tab
2139
+ * an earlier load opened; each is a whole renderer, and a single chat's work is
2140
+ * one tab. Remembered addresses are spared, so a chat that had a page still
2141
+ * finds it.
2142
+ * @returns {Promise<void>} done.
2143
+ */
2144
+ async pruneStrays() {
2145
+ if (this.pruned === true) return
2146
+ const port = Number(this.page?.port ?? 0)
2147
+ if (port === 0) return
2148
+ let list
2149
+ try {
2150
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
2151
+ list = await response.json()
2152
+ } catch {
2153
+ return
2154
+ }
2155
+ if (!Array.isArray(list)) return
2156
+ const owned = new Set()
2157
+ for (const page of [...this.pages.values(), this.page]) {
2158
+ const id = String(page?.targetId ?? '')
2159
+ if (id !== '') owned.add(id)
2160
+ }
2161
+ let remembered = []
2162
+ try {
2163
+ remembered = Object.values(JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}).map(String)
2164
+ } catch {
2165
+ remembered = []
2166
+ }
2167
+ let closed = 0
2168
+ let kept = 0
2169
+ for (const target of list) {
2170
+ if (target?.type !== 'page') continue
2171
+ const id = String(target.targetId ?? target.id ?? '')
2172
+ if (id === '') continue
2173
+ if (owned.has(id)) continue
2174
+ /* Six chats' worth of pages can come back without a reload; anything past
2175
+ that is a tab from an older load, and its address is still on disk. */
2176
+ if (remembered.includes(String(target.url ?? '')) && kept < MAX_TABS) {
2177
+ kept += 1
2178
+ continue
2179
+ }
2180
+ await fetch(`http://127.0.0.1:${String(port)}/json/close/${id}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
2181
+ closed += 1
2182
+ }
2183
+ this.pruned = true
2184
+ if (closed > 0) this.note(`closed ${String(closed)} tabs no chat was using`)
2185
+ }
2186
+
2187
+ /**
2188
+ * Close a few tabs nobody is watching so one browser does not grow a renderer
2189
+ * per chat that ever used it. Every chat's address is already on disk, so a
2190
+ * reaped tab costs nothing but the time to open it again.
2191
+ * @returns {Promise<void>} done.
2192
+ */
2193
+ async trimTabs() {
2194
+ const over = this.pages.size - (MAX_TABS - 1)
2195
+ if (over <= 0) return
2196
+ const idle = []
2197
+ for (const [id, page] of this.pages) {
2198
+ const target = String(page.targetId ?? '')
2199
+ if ((page.frames?.size ?? 0) !== 0 || target === '' || page.port === 0) continue
2200
+ idle.push({ id, page, target, port: page.port, at: Number(page.touched ?? 0) })
2201
+ }
2202
+ idle.sort((a, b) => a.at - b.at)
2203
+ for (const row of idle.slice(0, over)) {
2204
+ this.pages.delete(row.id)
2205
+ await row.page.stopCast().catch(() => {})
2206
+ await fetch(`http://127.0.0.1:${String(row.port)}/json/close/${row.target}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
2207
+ }
2208
+ }
2209
+
2210
+ /**
2211
+ * Close tabs nobody is watching that have sat untouched past the idle window.
2212
+ * The cap alone keeps at most six around; this keeps the working set near the
2213
+ * chats actually in use, which is what the memory number follows.
2214
+ * @returns {Promise<void>} done.
2215
+ */
2216
+ async reapIdleTabs() {
2217
+ const now = Date.now()
2218
+ if (now - Number(this.reapedAt ?? 0) < 60_000) return
2219
+ this.reapedAt = now
2220
+ for (const [id, page] of this.pages) {
2221
+ const target = String(page.targetId ?? '')
2222
+ if ((page.frames?.size ?? 0) !== 0 || target === '' || page.port === 0) continue
2223
+ if (page === this.page) continue
2224
+ if (now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
2225
+ this.pages.delete(id)
2226
+ await page.stopCast().catch(() => {})
2227
+ await fetch(`http://127.0.0.1:${String(page.port)}/json/close/${target}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
2228
+ this.note(`closed an idle tab — its address is kept for the chat`)
2229
+ }
2230
+ }
2231
+
2232
+ /**
2233
+ * @param {string} id - chat id.
2234
+ * @param {string} [url] - address to open in it.
2235
+ * @returns {Promise<SidecarPage>} a new tab for that chat.
2236
+ */
2237
+ async openTab(id, url) {
2238
+ const had = this.pageFor(id)
2239
+ if (had !== null) {
2240
+ if (url !== undefined && url !== '') await had.navigate(url)
2241
+ return had
2242
+ }
2243
+ if (this.page === null) await this.ensure()
2244
+ const port = this.page?.port ?? 0 // ui-copy-ignore
2245
+ if (port === 0) throw new Error('sidecar: no browser to open a tab in') // ui-copy-ignore
2246
+ /* A chat that was given a tab without an address keeps looking at what the
2247
+ browser was already showing, rather than at a blank page nobody asked for. */
2248
+ const remembered = this.rememberedTab(id)
2249
+ const address = url === undefined || url === '' ? String(remembered !== '' ? remembered : this.page?.lastUrl ?? this.lastUrl ?? 'about:blank') : String(url)
2250
+ /* The Flash-era app is one window and cannot open another, so a chat asking
2251
+ for a tab is handed that window, moved to the address it asked for. */
2252
+ if (this.flash) {
2253
+ const page = this.page
2254
+ await page.navigate(address)
2255
+ this.pages.set(id, page)
2256
+ this.touch()
2257
+ this.rememberTab(id, address)
2258
+ return page
2259
+ }
2260
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
2261
+ if (!created.ok) throw new Error('sidecar: could not open a tab')
2262
+ const target = await created.json()
2263
+ if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: new tab has no socket')
2264
+ const cdp = new Cdp(target.webSocketDebuggerUrl)
2265
+ await cdp.ready
2266
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
2267
+ this.pages.set(id, page)
2268
+ this.touch()
2269
+ this.rememberTab(id, address)
2270
+ return page
2271
+ }
2272
+
2273
+ /**
2274
+ * @param {{profile?: string, headless?: boolean, url?: string, session?: string}} [options] - how to start.
975
2275
  * @returns {Promise<SidecarPage>} the page being driven.
976
2276
  */
977
2277
  async ensure(options = {}) {
2278
+ const page = await this.ensurePage(options)
2279
+ const id = String(options.session ?? '')
2280
+ if (id === '') return page
2281
+ const mine = this.pageFor(id)
2282
+ if (mine !== null) {
2283
+ /* A chat's tab can die on its own: a crash, a browser another call
2284
+ restarted, a tab closed by hand. Handing that corpse back leaves the
2285
+ mirror on a blank picture forever, so it has to answer before it is
2286
+ trusted again. */
2287
+ /* A page that answered a moment ago is still this chat's page. Pinging on
2288
+ every keystroke put a round trip inside the typing path for nothing. */
2289
+ const fresh = Date.now() - Number(mine.answeredAt ?? 0) < 1000
2290
+ const alive = fresh ? { ok: true, closed: false } : await mine.ping()
2291
+ if (alive.ok) {
2292
+ mine.answeredAt = Date.now()
2293
+ /* A blank tab after a restart is not where this chat was: the address it
2294
+ last had is on disk, and that is the one worth returning to. */
2295
+ if (String(mine.url ?? '') === 'about:blank') {
2296
+ const back = this.rememberedTab(id)
2297
+ if (back !== '') await mine.navigate(back)
2298
+ }
2299
+ const wanted = String(options.url ?? '')
2300
+ if (wanted !== '' && String(mine.url) !== wanted) await mine.navigate(wanted)
2301
+ this.rememberTab(id, String(mine.url ?? wanted))
2302
+ return mine
2303
+ }
2304
+ this.pages.delete(id)
2305
+ return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
2306
+ }
2307
+ /* A new tab for a chat that had one before opens where that chat was: a
2308
+ blank mirror is not a place, and the first call should not land on one. */
2309
+ return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
2310
+ }
2311
+
2312
+ async ensurePage(options = {}) {
978
2313
  if (options.profile !== undefined) this.profile = options.profile
979
2314
  const wanted = options.headless === undefined ? this.headless : options.headless !== false
980
2315
  const modeChanged = wanted !== this.headless
981
2316
  this.headless = wanted
2317
+ /* Flash is another runtime, not another flag: the page has to move to the
2318
+ browser that still speaks PPAPI, and back when the switch goes off. */
2319
+ const wantFlash = options.flash === undefined ? this.flash : options.flash === true
2320
+ const flashChanged = wantFlash !== this.flash
2321
+ this.flash = wantFlash
2322
+ writeFlashChoice(wantFlash)
982
2323
  /* A windowless browser and a windowed one are different processes, so asking
983
2324
  for the other one has to restart it. Setting the flag alone left the request
984
2325
  with no effect at all: a site that refuses a windowless browser stayed out
985
2326
  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
2327
+ if (this.alive && (modeChanged || flashChanged)) {
2328
+ const wasAt = options.url || this.page?.lastUrl || this.lastUrl
988
2329
  await this.shutdown()
989
2330
  return this.ensure({ ...options, url: wasAt })
990
2331
  }
@@ -992,15 +2333,26 @@ export class Sidecar {
992
2333
  const page = this.page
993
2334
  /* A page that stopped answering is a page nobody can drive. Start a new
994
2335
  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()
2336
+ /* Every tool call used to pay a liveness round trip, and a keystroke
2337
+ burst paid it per key. An answer from the last second is still an
2338
+ answer; a page that dies in between fails its action loudly instead. */
2339
+ const fresh = Date.now() - Number(page.answeredAt ?? 0) < 1000
2340
+ let probe = page.dialog !== null && page.dialog !== undefined ? { ok: true } : fresh ? { ok: true, closed: false } : await page.ping()
996
2341
  if (!probe.ok && !probe.closed) {
997
2342
  await wait(150)
998
2343
  probe = await page.ping()
999
2344
  }
2345
+ if (probe.ok) page.answeredAt = Date.now()
2346
+ if (probe.ok) {
2347
+ void this.pruneStrays().catch(() => {})
2348
+ void this.reapIdleTabs().catch(() => {})
2349
+ }
1000
2350
  if (!probe.ok) {
1001
2351
  page.crashed = true
1002
2352
  this.note('renderer stopped answering; opening a fresh tab')
1003
- const wasAt = options.url ?? page.lastUrl ?? this.lastUrl
2353
+ /* An empty string is a page that never got an address, not a decision to
2354
+ stay blank: fall through to whatever this browser last showed. */
2355
+ const wasAt = options.url || page.lastUrl || this.lastUrl
1004
2356
  /* The browser itself is usually fine: only the renderer died. Opening a
1005
2357
  fresh tab in it is much faster than starting Chrome again. */
1006
2358
  try {
@@ -1010,7 +2362,30 @@ export class Sidecar {
1010
2362
  return this.ensure({ ...options, url: wasAt })
1011
2363
  }
1012
2364
  }
2365
+ const wasBlanked = page.blanked === true && (String(page.url ?? '') === '' || String(page.url ?? '') === 'about:blank')
2366
+ if (wasBlanked) {
2367
+ page.blanked = false
2368
+ page.crashed = true
2369
+ this.note('renderer stopped answering; opening a fresh tab')
2370
+ try {
2371
+ return await this.recover(page.lastUrl || this.lastUrl)
2372
+ } catch {
2373
+ await this.shutdown()
2374
+ return this.ensure({ ...options, url: page.lastUrl || this.lastUrl })
2375
+ }
2376
+ }
1013
2377
  this.touch()
2378
+ /* Keep the last real address the browser was seen on: a renderer crash
2379
+ can blank the tab's own record before anyone asks, and coming back to
2380
+ about:blank loses the page the person was reading. */
2381
+ const seen = String(this.page?.url ?? '')
2382
+ if (seen !== '' && seen !== 'about:blank') this.lastUrl = seen
2383
+ const id = String(options.session ?? '')
2384
+ if (id !== '') {
2385
+ /* Another chat may be looking at another tab: this one gets its own. */
2386
+ const mine = await this.tabFor(id, options.url)
2387
+ return mine
2388
+ }
1014
2389
  if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
1015
2390
  return this.page
1016
2391
  }
@@ -1018,7 +2393,7 @@ export class Sidecar {
1018
2393
  /* A page whose connection closed on its own (a renderer crash closes the
1019
2394
  tab's session) can be replaced in the browser that is still running. */
1020
2395
  if (this.page !== null && this.page.cdp?.closed === true) {
1021
- const wasAt = options.url ?? this.page.lastUrl ?? this.lastUrl
2396
+ const wasAt = options.url || this.page.lastUrl || this.lastUrl
1022
2397
  this.page.crashed = true
1023
2398
  this.note('renderer stopped answering; opening a fresh tab')
1024
2399
  this.starting = this.recover(wasAt).catch((error) => {
@@ -1037,19 +2412,33 @@ export class Sidecar {
1037
2412
 
1038
2413
  /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
1039
2414
  async launch(url) {
1040
- const dir = profileDir(this.profile)
2415
+ /* Chromium 87 opening the profile a current Chrome owns would downgrade it
2416
+ and lock it, so the Flash-era browser keeps a profile of its own. The two
2417
+ never share a directory; a game signed into one is signed in there only. */
2418
+ const dir = this.flash ? `${profileDir(this.profile)}-flash` : profileDir(this.profile)
2419
+ /* A profile whose cookie store went missing would look signed out at the
2420
+ next step even though the person never signed out, so the working copy
2421
+ is put back before the browser is asked to read it. */
2422
+ restoreLogin(dir)
1041
2423
  let port = await this.runningPort(dir) // ui-copy-ignore
1042
2424
  if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
2425
+ else this.owns = ownsBrowser({ pid: process.pid, owner: markerPid(dir) })
1043
2426
  const ws = await this.pageSocket(port) // ui-copy-ignore
1044
2427
  const cdp = new Cdp(ws)
1045
2428
  await cdp.ready
1046
2429
  this.cdp = cdp
1047
- this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
2430
+ this.page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
1048
2431
  if (url !== undefined && url !== '') this.lastUrl = String(url)
1049
2432
  await this.restoreSessionCookies()
1050
2433
  await this.page.cast()
1051
2434
  if (url !== undefined && url !== '') await this.page.navigate(url)
1052
2435
  this.touch()
2436
+ /* Sign-ins outlive this browser: what works now is worth a copy, taken
2437
+ while the cookies are known good rather than after they are lost. */
2438
+ snapshotLogin(dir)
2439
+ this.backup?.()
2440
+ this.backup = setInterval(() => snapshotLogin(dir), 10 * 60_000)
2441
+ this.backup.unref?.()
1053
2442
  this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
1054
2443
  return this.page
1055
2444
  }
@@ -1082,7 +2471,9 @@ export class Sidecar {
1082
2471
  * @returns {Promise<number>} the port Chrome opened. // ui-copy-ignore
1083
2472
  */
1084
2473
  async spawnChrome(dir) {
1085
- const binary = firstBinary()
2474
+ const flash = this.flash ? flashRuntime() : null
2475
+ if (this.flash && flash === null) throw new Error('sidecar: no Flash runtime on this machine')
2476
+ const binary = flash === null ? firstBinary() : flash.binary
1086
2477
  if (binary === undefined) throw new Error('sidecar: no Chrome found on this machine')
1087
2478
  const args = [
1088
2479
  `--user-data-dir=${dir}`,
@@ -1113,11 +2504,45 @@ export class Sidecar {
1113
2504
  '--password-store=basic',
1114
2505
  '--lang=zh-CN',
1115
2506
  `--window-size=${VIEW.width},${VIEW.height}`,
2507
+ /* A windowless build says "HeadlessChrome" in every request, and a site
2508
+ that checks for it answers the whole browser with a parked page. The
2509
+ browser is windowless, not a robot: say what a person's Chrome says. */
2510
+ `--user-agent=${DESKTOP_UA}`,
2511
+ /* A site that refuses a headless build still gets a window, but never on
2512
+ the desktop: the mirror in the column is the only thing to look at. */
2513
+ '--window-position=-3000,-3000',
1116
2514
  'about:blank',
1117
2515
  ]
1118
- if (this.headless) args.unshift('--headless=new')
1119
- const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
1120
- child.on('exit', () => {
2516
+ if (flash !== null) {
2517
+ /* Electron takes these switches through its own command line, not the
2518
+ argv it hands the app, so the runtime app reads them from here. */
2519
+ flash.port = Number(process.env.DSH_INNER_BROWSER_FLASH_PORT ?? 9200 + Math.floor(Math.random() * 400))
2520
+ flash.bridgePort = flash.port + 1
2521
+ args.length = 0
2522
+ args.push(FLASH_APP)
2523
+ } else if (this.headless) args.unshift('--headless=new')
2524
+ /* A browser started again after a stop is a new one: its death is news
2525
+ again, and the flag left behind by the stop must not silence it. */
2526
+ this.stopping = false
2527
+ const flashEnv = flash === null ? undefined : {
2528
+ ...process.env,
2529
+ DSH_FLASH_PORT: String(flash.port),
2530
+ DSH_FLASH_BRIDGE_PORT: String(flash.bridgePort),
2531
+ DSH_FLASH_UDD: dir,
2532
+ DSH_FLASH_PLUGIN: flash.plugin,
2533
+ DSH_FLASH_VERSION: flash.version,
2534
+ DSH_FLASH_URL: 'about:blank',
2535
+ }
2536
+ if (flashEnv !== undefined) {
2537
+ /* The harness runs Node with this set; Electron would start as Node. */
2538
+ delete flashEnv.ELECTRON_RUN_AS_NODE
2539
+ }
2540
+ const child = spawn(binary, args, { detached: false, stdio: 'ignore', env: flashEnv })
2541
+ const born = Date.now()
2542
+ child.on('exit', (code, signal) => {
2543
+ /* A browser that ends without being asked is the one case nobody can see:
2544
+ Chrome's own stderr goes nowhere, so leave a line behind. */
2545
+ noteDeath({ pid: child.pid, code, signal, profile: this.profile, asked: this.stopping === true || Date.now() - born < 3000 })
1121
2546
  if (this.child === child) {
1122
2547
  this.child = null
1123
2548
  this.cdp?.close()
@@ -1126,6 +2551,30 @@ export class Sidecar {
1126
2551
  }
1127
2552
  })
1128
2553
  this.child = child
2554
+ /* Leave behind who started it: a browser found on this profile by another
2555
+ process must not be closed by that process on its way out. */
2556
+ try {
2557
+ writeFileSync(join(dir, 'owner.json'), JSON.stringify({ pid: process.pid, at: Date.now() })) // ui-copy-ignore
2558
+ this.owns = true
2559
+ } catch {
2560
+ /* A profile that cannot take a marker is still usable, but it counts as
2561
+ somebody else's browser: no closing it from here. */
2562
+ }
2563
+ if (flash !== null) {
2564
+ for (let i = 0; i < 150; i += 1) {
2565
+ await wait(100)
2566
+ try {
2567
+ const response = await fetch(`http://127.0.0.1:${flash.port}/json/version`, { signal: AbortSignal.timeout(800) }) // ui-copy-ignore
2568
+ if (response.ok) {
2569
+ this.flashBridge = flash.bridgePort
2570
+ return flash.port // ui-copy-ignore
2571
+ }
2572
+ } catch {
2573
+ /* not up yet */
2574
+ }
2575
+ }
2576
+ throw new Error('sidecar: the Flash runtime did not open a debugging port') // ui-copy-ignore
2577
+ }
1129
2578
  const file = join(dir, 'DevToolsActivePort')
1130
2579
  for (let i = 0; i < 150; i += 1) {
1131
2580
  await wait(100)
@@ -1149,8 +2598,9 @@ export class Sidecar {
1149
2598
  * The browser's pages, in the order the browser lists them.
1150
2599
  * @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
1151
2600
  */
1152
- async tabs() {
1153
- return this.page === null ? [] : this.page.tabs()
2601
+ async tabs(session = '') {
2602
+ const page = this.pageFor(session) ?? this.page
2603
+ return page === null ? [] : page.tabs()
1154
2604
  }
1155
2605
 
1156
2606
  /**
@@ -1158,15 +2608,51 @@ export class Sidecar {
1158
2608
  * @param {number} index - a row from `tabs()`.
1159
2609
  * @returns {Promise<SidecarPage>} the page now being driven.
1160
2610
  */
1161
- async useTab(index) {
1162
- const list = await this.tabs()
2611
+ /**
2612
+ * Tells a page which targets belong to other chats, so a popup lookup cannot
2613
+ * pick somebody else's tab.
2614
+ * @param {SidecarPage} page - a page this browser drives.
2615
+ * @returns {SidecarPage} the same page.
2616
+ */
2617
+ bind(page) {
2618
+ page.flashBridge = this.flashBridge
2619
+ /* The launch window is landscape; shrink it to the capture budget before
2620
+ anyone asks, so the first judged frame is not a 2 fps one. */
2621
+ if (this.flashBridge !== null) void page.setViewport(page.view.width, page.view.height).catch(() => {})
2622
+ page.siblings = () =>
2623
+ [...this.pages.values(), this.page]
2624
+ .filter((other) => other !== null && other !== undefined && other !== page)
2625
+ .map((other) => String(other.targetId ?? ''))
2626
+ .filter((id) => id !== '')
2627
+ return page
2628
+ }
2629
+
2630
+ async useTab(index, session = '') {
2631
+ const list = await this.tabs(session)
1163
2632
  const tab = list[Number(index)]
1164
2633
  if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
2634
+ const owned = String(session ?? '') !== ''
2635
+ const mine = owned ? this.pageFor(session) : null
2636
+ if (tab.current && mine !== null) return mine
2637
+ if (tab.current && !owned) return this.page
1165
2638
  if (tab.current) return this.page
1166
- const was = this.page
2639
+ const was = mine ?? this.page
2640
+ /* A chat that owns a tab keeps owning one: switching moves the chat, not
2641
+ the browser's first tab that a different chat may be driving. */
2642
+ if (mine !== null) {
2643
+ const cdp = new Cdp(tab.webSocketUrl)
2644
+ await cdp.ready
2645
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port, () => this.holding()).start()) // ui-copy-ignore
2646
+ this.pages.set(String(session), page)
2647
+ mine.cdp.send('Page.stopScreencast').catch(() => {})
2648
+ mine.cdp.close()
2649
+ await page.cast()
2650
+ this.touch()
2651
+ return page
2652
+ }
1167
2653
  const cdp = new Cdp(tab.webSocketUrl)
1168
2654
  await cdp.ready
1169
- const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
2655
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, was.port, () => this.holding()).start()) // ui-copy-ignore
1170
2656
  this.cdp = cdp
1171
2657
  this.page = page
1172
2658
  this.lastUrl = tab.url
@@ -1181,18 +2667,25 @@ export class Sidecar {
1181
2667
  * Close a tab; the mirror moves to a neighbour when the closed one was shown.
1182
2668
  * @param {number} index - a row from `tabs()`.
1183
2669
  */
1184
- async closeTab(index) {
1185
- const list = await this.tabs()
2670
+ async closeTab(index, session = '') {
2671
+ const list = await this.tabs(session)
1186
2672
  const tab = list[Number(index)]
1187
2673
  if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1188
- let port = this.page?.port ?? 0 // ui-copy-ignore
2674
+ const mine = String(session ?? '') === '' ? null : this.pageFor(session)
2675
+ let port = (mine ?? this.page)?.port ?? 0 // ui-copy-ignore
1189
2676
  let alone = true
1190
2677
  if (tab.current) {
1191
2678
  const other = list.findIndex((item) => !item.current)
1192
2679
  alone = other < 0
1193
- if (!alone) await this.useTab(other)
2680
+ if (!alone) await this.useTab(other, session)
1194
2681
  }
1195
2682
  if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
2683
+ if (alone && mine !== null) {
2684
+ /* The chat still needs somewhere to go: a fresh tab in the same browser. */
2685
+ this.pages.delete(String(session))
2686
+ await this.tabFor(String(session), 'about:blank')
2687
+ return
2688
+ }
1196
2689
  if (alone) {
1197
2690
  await this.shutdown()
1198
2691
  await this.ensure()
@@ -1202,7 +2695,12 @@ export class Sidecar {
1202
2695
  async recover(url) {
1203
2696
  const port = this.page?.port ?? 0 // ui-copy-ignore
1204
2697
  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)
2698
+ let address = url === '' || url === undefined ? 'about:blank' : String(url)
2699
+ if (address === 'about:blank' && String(this.lastUrl ?? '') !== '' && this.lastUrl !== 'about:blank') address = String(this.lastUrl)
2700
+ if (this.flash) {
2701
+ await this.page.navigate(address)
2702
+ return this.page
2703
+ }
1206
2704
  const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1207
2705
  if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
1208
2706
  const target = await created.json()
@@ -1210,7 +2708,17 @@ export class Sidecar {
1210
2708
  const cdp = new Cdp(target.webSocketDebuggerUrl)
1211
2709
  await cdp.ready
1212
2710
  this.cdp = cdp
1213
- this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
2711
+ this.page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
2712
+ /* The new tab may have finished loading before we attached, in which case
2713
+ no navigation event ever reaches us and the page would report the blank
2714
+ address it was created with. Ask it where it actually is. */
2715
+ let here = ''
2716
+ try {
2717
+ const out = await cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true })
2718
+ here = String(out?.result?.value ?? '')
2719
+ } catch {}
2720
+ if (here !== '' && here !== 'about:blank') this.page.lastUrl = here
2721
+ else if (address !== 'about:blank') await this.page.navigate(address)
1214
2722
  await this.page.cast()
1215
2723
  this.touch()
1216
2724
  return this.page
@@ -1241,8 +2749,8 @@ export class Sidecar {
1241
2749
  * @param {(frame: any) => void} sink - called per frame.
1242
2750
  * @returns {Promise<() => void>} detach.
1243
2751
  */
1244
- async watch(sink) {
1245
- const page = await this.ensure()
2752
+ async watch(sink, session = '') {
2753
+ const page = await this.ensure({ session })
1246
2754
  page.frames.add(sink)
1247
2755
  page.pointerSinks.add(sink)
1248
2756
  await page.cast()
@@ -1259,7 +2767,9 @@ export class Sidecar {
1259
2767
  this.idle = null
1260
2768
  /* Someone is watching the mirror: the page stays up, and the clock starts
1261
2769
  again so the browser is closed only once it is truly unused. */
1262
- if ((this.page?.frames.size ?? 0) > 0) {
2770
+ let watched = this.page?.frames.size ?? 0
2771
+ for (const page of this.pages.values()) watched += page.frames?.size ?? 0
2772
+ if (watched > 0) {
1263
2773
  this.touch()
1264
2774
  return
1265
2775
  }
@@ -1269,20 +2779,130 @@ export class Sidecar {
1269
2779
  }
1270
2780
 
1271
2781
  /** @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. */
2782
+ /**
2783
+ * Leaves the browser running for whoever loads this plugin next, and puts the
2784
+ * sign-in on disk first. A plugin reload used to close Chrome, so the column
2785
+ * lost the page and every sign-in paid for a fresh launch; the browser is
2786
+ * meant to outlive the driver. A detached watcher closes the browser once
2787
+ * this app is really gone, so nothing is left behind.
2788
+ * @returns {Promise<void>} done, with the browser still up.
2789
+ */
2790
+ async handoff() {
2791
+ this.stopping = true
1275
2792
  if (this.page !== null) await this.saveSessionCookies()
1276
- if (this.cdp !== null) {
2793
+ if (this.backup !== undefined) clearInterval(this.backup)
2794
+ this.backup = undefined
2795
+ try {
2796
+ snapshotLogin(profileDir(this.profile))
2797
+ } catch {
2798
+ /* a profile that cannot be copied is not a reason to drop the browser */
2799
+ }
2800
+ const chrome = Number(this.child?.pid ?? 0)
2801
+ if (chrome === 0) return
2802
+ /* One watcher per application run, watching the application rather than
2803
+ this instance: a plugin reload is not the app closing. */
2804
+ /* Read at call time, so a test can point the note somewhere harmless. */
2805
+ const dir = process.env.DSH_INNER_BROWSER_STATE_DIR || tabsDir
2806
+ const app = appPid()
2807
+ /* A watcher may only watch the application: one keyed to the node instance
2808
+ a reload replaces closes the browser at that reload. When the walk cannot
2809
+ name the app, no watcher is armed at all rather than a wrong one. */
2810
+ if (app === 0 || looksLikeApp(commandOf(app)) === false) return
2811
+ try {
2812
+ const said = JSON.parse(readFileSync(watcherFile(dir), 'utf8'))
2813
+ const watching = Number(said?.appPid ?? 0)
2814
+ const alive = (() => {
2815
+ try {
2816
+ process.kill(Number(said?.pid ?? 0), 0)
2817
+ return true
2818
+ } catch {
2819
+ return false
2820
+ }
2821
+ })()
2822
+ /* Same run and same browser: the watcher already covers it. A browser
2823
+ that was restarted needs the note pointed at the new pid, or the app
2824
+ going away would leave the new one running. */
2825
+ if (alive && Number(said?.chrome ?? 0) === chrome && watching === Number(said?.appPid) && (app === 0 || watching === app)) return
2826
+ if (alive) {
2827
+ try {
2828
+ process.kill(Number(said?.pid ?? 0))
2829
+ } catch {
2830
+ /* a watcher that already left needs no goodbye */
2831
+ }
2832
+ }
2833
+ } catch {
2834
+ /* no watcher yet, or one whose note is unreadable */
2835
+ }
2836
+ /* Detached and unref'd: the app must not wait for this, and it must outlive
2837
+ the plugin instance it was started for. Windows has no `/bin/sh`, so there
2838
+ the lifetime rides on the browser itself: `taskkill /T` already ends the
2839
+ whole tree when this process is gone, and spawning a POSIX watcher here
2840
+ would only throw ENOENT and be swallowed. */
2841
+ if (process.platform === 'win32') return
2842
+ try {
2843
+ const guard = spawn('/bin/sh', ['-c', `while kill -0 ${app} 2>/dev/null; do sleep 5; done; kill ${chrome} 2>/dev/null`], {
2844
+ detached: true,
2845
+ stdio: 'ignore',
2846
+ })
2847
+ guard.unref()
2848
+ mkdirSync(dir, { recursive: true })
2849
+ writeFileSync(watcherFile(dir), JSON.stringify({ appPid: app, pid: guard.pid ?? 0, chrome, at: Date.now() }))
2850
+ } catch {
2851
+ /* a watcher that cannot start only means the browser outlives the app */
2852
+ }
2853
+ }
2854
+
2855
+ async shutdown() {
2856
+ /* A browser this process did not start belongs to somebody else — another
2857
+ app on the same profile, or a script that connected to it — and closing
2858
+ it would close the person's window. Let go of it instead: drop the socket
2859
+ and leave the browser running. */
2860
+ const mine = this.owns === true
2861
+ if (mine && this.page !== null) {
2862
+ /* Ask Chrome to close first: a browser that is killed outright may lose
2863
+ the last minute of cookies, and keeping the person signed in is the
2864
+ point. */
2865
+ await this.saveSessionCookies()
2866
+ snapshotLogin(profileDir(this.profile))
2867
+ }
2868
+ if (this.backup !== undefined) clearInterval(this.backup)
2869
+ this.backup = undefined
2870
+ if (this.cdp !== null && mine) {
1277
2871
  await this.cdp.send('Browser.close').catch(() => {})
1278
2872
  await new Promise((resolve) => setTimeout(resolve, 400))
1279
2873
  }
1280
2874
  this.page = null
2875
+ this.pages.clear()
1281
2876
  this.cdp?.close()
1282
2877
  this.cdp = null
1283
2878
  if (this.child !== null) {
2879
+ const child = this.child
1284
2880
  try {
1285
- this.child.kill('SIGTERM')
2881
+ /* A browser on Windows is a process tree: terminating the launcher alone
2882
+ leaves renderers and the profile lock behind. Browser.close above is
2883
+ the graceful path; this is the one for a browser that ignored it. */
2884
+ if (process.platform === 'win32' && Number.isInteger(child.pid)) {
2885
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
2886
+ } else {
2887
+ child.kill('SIGTERM')
2888
+ /* Electron with an offscreen window can ignore SIGTERM while a plugin
2889
+ paints; a leftover helper keeps the profile locked and shows up as a
2890
+ stray process on the person's desktop. Escalate by pid. */
2891
+ await new Promise((resolve) => {
2892
+ const timer = setTimeout(() => {
2893
+ try {
2894
+ child.kill('SIGKILL')
2895
+ } catch {
2896
+ /* already gone */
2897
+ }
2898
+ resolve()
2899
+ }, 1500)
2900
+ child.once('exit', () => {
2901
+ clearTimeout(timer)
2902
+ resolve()
2903
+ })
2904
+ })
2905
+ }
1286
2906
  } catch {
1287
2907
  /* already gone */
1288
2908
  }
@@ -1319,9 +2939,20 @@ export class Sidecar {
1319
2939
  return
1320
2940
  }
1321
2941
  if (!Array.isArray(jar)) return
2942
+ /* A cookie the browser already holds is newer than the note: the site may
2943
+ have rotated its session id since the note was written, and putting the
2944
+ old value back is exactly how a working sign-in turns into a logout. */
2945
+ let live = []
2946
+ try {
2947
+ live = await this.page.cookies()
2948
+ } catch {
2949
+ live = []
2950
+ }
2951
+ const held = new Set(live.map((c) => `${String(c.domain ?? '').replace(/^\./, '')}|${String(c.path ?? '/')}|${String(c.name ?? '')}`))
1322
2952
  for (const cookie of jar.slice(0, 200)) {
1323
2953
  const domain = String(cookie?.domain ?? '').replace(/^\./, '')
1324
2954
  if (domain === '') continue
2955
+ if (held.has(`${domain}|${String(cookie?.path ?? '/')}|${String(cookie.name)}`)) continue
1325
2956
  try {
1326
2957
  await this.page.setCookie({
1327
2958
  name: String(cookie.name),
@@ -1339,6 +2970,22 @@ export class Sidecar {
1339
2970
  }
1340
2971
 
1341
2972
  /** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
2973
+ /**
2974
+ * What the browser holds for one chat, when the caller names it.
2975
+ * @param {string} [session] - chat id.
2976
+ * @returns {{url: string, title: string, viewers: number, alive: boolean, remembered: string}} its page.
2977
+ */
2978
+ async chatState(session = '') {
2979
+ const id = String(session ?? '')
2980
+ /* A chat whose tab is gone still knows where it was: that address is what
2981
+ the pane offers to bring back, so a lost picture is one click, not a
2982
+ question about which site it used to be. */
2983
+ const remembered = this.rememberedTab(id)
2984
+ const page = id === '' ? null : this.pageFor(id)
2985
+ if (page === null || page === undefined) return { url: '', title: '', viewers: 0, alive: false, remembered } // ui-copy-ignore
2986
+ return { url: String(page.url ?? ''), title: String(page.title ?? ''), viewers: page.frames?.size ?? 0, alive: true, remembered } // ui-copy-ignore
2987
+ }
2988
+
1342
2989
  async state() {
1343
2990
  const profiles = (() => {
1344
2991
  try {
@@ -1352,11 +2999,21 @@ export class Sidecar {
1352
2999
  profile: this.profile, // ui-copy-ignore
1353
3000
  headless: this.headless, // ui-copy-ignore
1354
3001
  url: this.page?.url ?? '', // ui-copy-ignore
3002
+ pages: this.pages.size,
1355
3003
  title: this.page?.title ?? '',
1356
3004
  viewers: this.page?.frames.size ?? 0,
3005
+ /* Everyone watching any chat's picture: the pane in the right column is
3006
+ one of these, so 0 means no column is attached right now. */
3007
+ watchers: [...this.pages.values()].reduce((sum, page) => sum + (page.frames?.size ?? 0), 0),
1357
3008
  frames: this.page?.seq ?? 0,
1358
3009
  crashed: this.page?.crashed === true,
1359
3010
  pointer: this.page?.pointer ?? null,
3011
+ /* Frames that repeated the pane's picture and were dropped instead of
3012
+ sent: the number a static page spends on nothing. */
3013
+ quiet: this.page?.quiet ?? 0,
3014
+ /* Layout size of the page the pane is showing: a coordinate from a
3015
+ screenshot is in this space, not in the window's. */
3016
+ viewport: this.page?.view ?? null,
1360
3017
  profiles,
1361
3018
  error: this.alive ? '' : this.lastError,
1362
3019
  }