dsh-inner-browser-use 0.34.28 → 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
@@ -9,6 +9,8 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process'
9
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'
12
14
 
13
15
  /**
14
16
  * Windows keeps a browser under Program Files (machine-wide) or LocalAppData
@@ -50,13 +52,56 @@ export const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Apple
50
52
  * the sharpness is what makes the mirror feel like the page. Quality stops
51
53
  * short of lossless so a moving picture still arrives on the next frame.
52
54
  */
53
- 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
+
54
59
 
55
60
  /** Frames are dropped rather than queued: a lagging viewer must not slow the page. */
56
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
+ }
57
95
 
58
96
  /** Idle lifetime, so a forgotten sidecar does not sit on a login forever. */
59
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
60
105
 
61
106
  /** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
62
107
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -66,15 +111,66 @@ function firstBinary() {
66
111
  return CHROME_BINARIES.find((path) => existsSync(path))
67
112
  }
68
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
+
69
132
  /* A chat's tab address outlives a plugin reload: the browser is still running
70
133
  with that tab, and the chat that returns should get its page back, not a
71
134
  blank one. */
72
135
  const tabsDir = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
73
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
+ }
74
166
 
75
167
  /** Root of everything this plugin keeps for the sidecar. */
76
- function rootDir() {
77
- 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')
78
174
  mkdirSync(dir, { recursive: true })
79
175
  return dir
80
176
  }
@@ -186,6 +282,32 @@ export function deathNote(gone) {
186
282
  * @param {string} [dir] - where the log lives; the state directory by default.
187
283
  * @returns {void}
188
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
+
189
311
  export function noteDeath(gone, dir = tabsDir) {
190
312
  if (gone.asked === true) return
191
313
  try {
@@ -291,7 +413,15 @@ class Cdp {
291
413
  else entry.resolve(message.result ?? {})
292
414
  return
293
415
  }
294
- 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
+ }
295
425
  })
296
426
  this.socket.addEventListener('close', () => {
297
427
  this.closed = true
@@ -344,8 +474,13 @@ class Cdp {
344
474
  */
345
475
  export class SidecarPage {
346
476
  /** @param {Cdp} cdp - the page session. @param {string} profile - profile name. */
347
- constructor(cdp, profile, port) { // ui-copy-ignore
477
+ constructor(cdp, profile, port, hold = () => 0) { // ui-copy-ignore
348
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
349
484
  this.profile = profile
350
485
  this.port = port // ui-copy-ignore
351
486
  this.epoch = 0
@@ -356,15 +491,34 @@ export class SidecarPage {
356
491
  this.latest = ''
357
492
  this.seq = 0
358
493
  this.lastFrameAt = 0
494
+ this.sentAt = 0
359
495
  /* Total JPEG bytes seen, for the bench and for noticing when a page got
360
496
  expensive to watch. */
361
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
362
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
363
512
  this.url = ''
364
513
  this.lastUrl = ''
365
514
  this.crashed = false
366
515
  this.title = ''
367
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
368
522
  this.inflight = 0
369
523
  this.touched = Date.now()
370
524
  /* A dialog blocks the page until someone answers it, so it is state, not an
@@ -372,10 +526,29 @@ export class SidecarPage {
372
526
  this.dialog = null
373
527
  this.console = []
374
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
375
544
  /* What the page asked for, so a page that fills itself from addresses can
376
545
  be understood without a screenshot or a retry. */
377
546
  this.network = []
378
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
379
552
  /* frame id -> the world its page script runs in. A control inside an iframe
380
553
  is only reachable through that world; the frame list is read when the
381
554
  read model needs it. */
@@ -385,6 +558,20 @@ export class SidecarPage {
385
558
  follows it, because a person looking at the column expects that too. */
386
559
  this.children = new Map()
387
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
388
575
  /* The tab this page drives, and the tabs other chats drive. A tab another
389
576
  chat owns is never a popup of this one: closing it, or navigating this
390
577
  page to its address, would take a page away from somebody else. */
@@ -432,8 +619,33 @@ export class SidecarPage {
432
619
  this.children.delete(String(message.params?.sessionId ?? ''))
433
620
  return
434
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
+ }
435
638
  if (message.method === 'Target.targetInfoChanged') {
436
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
+ }
437
649
  const targetId = String(info.targetId ?? '')
438
650
  for (const child of this.children.values()) if (child.targetId === targetId) child.url = String(info.url ?? '')
439
651
  return
@@ -448,14 +660,16 @@ export class SidecarPage {
448
660
  }
449
661
  if (message.method === 'Runtime.exceptionThrown') {
450
662
  const detail = message.params?.exceptionDetails ?? {}
451
- 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 })
452
665
  if (this.console.length > 200) this.console.splice(0, 50)
453
666
  return
454
667
  }
455
668
  if (message.method === 'Runtime.consoleAPICalled') {
456
669
  const params = message.params ?? {}
457
670
  const text = (params.args ?? []).map((arg) => String(arg.value ?? arg.description ?? '')).join(' ').slice(0, 300)
458
- 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 })
459
673
  if (this.console.length > 200) this.console.splice(0, 50)
460
674
  return
461
675
  }
@@ -464,6 +678,11 @@ export class SidecarPage {
464
678
  const request = params.request ?? {}
465
679
  const id = String(params.requestId ?? '')
466
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
467
686
  this.requests.set(id, {
468
687
  method: String(request.method ?? 'GET'),
469
688
  url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
@@ -476,8 +695,17 @@ export class SidecarPage {
476
695
  if (message.method === 'Network.responseReceived') {
477
696
  const params = message.params ?? {}
478
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
+ }
479
705
  if (known === undefined) return
480
706
  known.status = Number(params.response?.status ?? 0)
707
+ this.troubleSeq += 1
708
+ known.seq = this.troubleSeq
481
709
  this.network.push(known)
482
710
  if (this.network.length > 120) this.network.splice(0, 40)
483
711
  return
@@ -485,7 +713,8 @@ export class SidecarPage {
485
713
  if (message.method === 'Network.loadingFailed') {
486
714
  const params = message.params ?? {}
487
715
  if (params.canceled === true) return
488
- 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 })
489
718
  if (this.failed.length > 100) this.failed.splice(0, 25)
490
719
  }
491
720
  }
@@ -552,28 +781,138 @@ export class SidecarPage {
552
781
  async adoptOpened() {
553
782
  const foreign = typeof this.siblings === 'function' ? this.siblings().map(String) : []
554
783
  const theirs = new Set(foreign)
555
- let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
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
556
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
+ }
557
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. */
558
797
  if (target === null) {
559
- /* The target list is written before the popup finished loading, so give a
560
- click that opens one a moment to say where it is going. */
561
- for (let attempt = 0; attempt < 3 && target === null; attempt += 1) {
562
- if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150))
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) {
563
802
  const found = await this.findPopup(foreign)
564
- if (found !== null && found.url !== '') target = found
565
- else if (found !== null && attempt === 2) target = found
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))
566
810
  }
567
811
  }
568
- 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()
569
818
  const url = String(target.url ?? '')
570
- await this.cdp.send('Target.closeTarget', { targetId: target.targetId }).catch(() => {})
571
- 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
572
830
  if (!/^https?:|^file:/.test(url)) return ''
573
831
  await this.cdp.send('Page.navigate', { url }).catch(() => {})
574
832
  return `opened ${url}`
575
833
  }
576
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
+
577
916
  /**
578
917
  * The frames of the page, main frame first, each with the context its scripts
579
918
  * run in. Read on demand: a page with no iframes pays one call.
@@ -675,42 +1014,71 @@ export class SidecarPage {
675
1014
  mkdirSync(folder, { recursive: true })
676
1015
  this.downloadsFolder = folder
677
1016
  this.cdp.on((message) => {
678
- this.watch(message)
679
- if (message.method !== 'Page.screencastFrame') return
680
- const { data, sessionId, metadata } = message.params ?? {}
681
- void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
682
- if (typeof data !== 'string' || data === '') return
683
- this.latest = data
684
- this.lastFrameAt = Date.now()
685
- this.seq += 1
686
- this.bytes += data.length
687
- if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
688
- if (this.inflight >= MAX_INFLIGHT_FRAMES) return
689
- this.inflight += 1
690
- const frame = {
691
- seq: this.seq, // ui-copy-ignore
692
- w: VIEW.width, // ui-copy-ignore
693
- h: VIEW.height, // ui-copy-ignore
694
- jpeg: data, // ui-copy-ignore
695
- url: this.url, // ui-copy-ignore
696
- title: this.title, // ui-copy-ignore
697
- pointer: this.pointer, // ui-copy-ignore
698
- }
699
- for (const sink of this.frames) {
700
- try {
701
- sink(frame)
702
- } catch {
703
- /* 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
+ }
704
1061
  }
1062
+ this.inflight -= 1
1063
+ } catch (error) {
1064
+ this.fault = String(error?.message ?? error) // ui-copy-ignore
705
1065
  }
706
- this.inflight -= 1
707
1066
  })
708
1067
  this.cdp.on((message) => {
709
1068
  if (message.method === 'Page.frameNavigated' && message.params?.frame?.parentId === undefined) {
710
- 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()
711
1076
  void this.readTitle()
712
1077
  }
713
- 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()
714
1082
  })
715
1083
  /* None of these settings depends on another: asking for all of them at
716
1084
  once is one round trip instead of four, and a cold start is where it
@@ -722,6 +1090,9 @@ export class SidecarPage {
722
1090
  /* Popups and out-of-process frames are separate targets. Attaching without
723
1091
  waiting keeps them usable, and lets the mirror follow a new tab. */
724
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(() => {}),
725
1096
  ])
726
1097
  /* An instance left behind by a stopped app can still be sitting on a
727
1098
  dialog; a paused renderer answers nothing, so clear it first. */
@@ -734,8 +1105,8 @@ export class SidecarPage {
734
1105
  const [, version] = await Promise.all([
735
1106
  this.cdp.send('Runtime.enable').then(() =>
736
1107
  this.cdp.send('Emulation.setDeviceMetricsOverride', {
737
- width: VIEW.width, // ui-copy-ignore
738
- height: VIEW.height, // ui-copy-ignore
1108
+ width: this.view.width, // ui-copy-ignore
1109
+ height: this.view.height, // ui-copy-ignore
739
1110
  deviceScaleFactor: 1,
740
1111
  mobile: false, // ui-copy-ignore
741
1112
  }),
@@ -773,8 +1144,31 @@ export class SidecarPage {
773
1144
  }
774
1145
  }
775
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
+
776
1164
  /** @returns {Promise<void>} resolves once the page is casting. */
777
1165
  async cast() {
1166
+ if (this.flashBridge !== null) {
1167
+ if (this.casting) return
1168
+ this.casting = true
1169
+ void this.pollBridge()
1170
+ return
1171
+ }
778
1172
  if (this.casting) {
779
1173
  /* A page nobody repaints still owes the pane a picture, so silence for a
780
1174
  few seconds means the cast died rather than being idle. */
@@ -786,9 +1180,9 @@ export class SidecarPage {
786
1180
  `optimizeForSpeed` keeps the encoder from adding to the round trip. */
787
1181
  await this.cdp.send('Page.startScreencast', {
788
1182
  format: 'jpeg', // ui-copy-ignore
789
- quality: 72, // ui-copy-ignore
790
- maxWidth: MIRROR.width, // ui-copy-ignore
791
- maxHeight: MIRROR.height,
1183
+ quality: this.castQuality, // ui-copy-ignore
1184
+ maxWidth: this.view.width, // ui-copy-ignore
1185
+ maxHeight: this.view.height,
792
1186
  optimizeForSpeed: true,
793
1187
  everyNthFrame: 1,
794
1188
  }).catch(() => {
@@ -797,26 +1191,95 @@ export class SidecarPage {
797
1191
  void this.still()
798
1192
  }
799
1193
 
800
- /** Sends one full-frame picture now: a mirror that waits for motion shows an empty pane on a page that is not moving. */
801
- async still() {
802
- let data = ''
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
+ }
803
1243
  try {
804
1244
  const shot = await this.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 72, fromSurface: true })
805
- data = String(shot?.data ?? '')
1245
+ return String(shot?.data ?? '')
806
1246
  } catch {
807
- return
1247
+ return ''
808
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) {
809
1269
  if (data === '' || this.frames.size === 0) return
810
1270
  this.latest = data
1271
+ if (this.repeats(data) === true) return
1272
+ if (this.tooSoon()) return
811
1273
  this.lastFrameAt = Date.now()
812
1274
  this.seq += 1
813
1275
  const frame = {
814
1276
  seq: this.seq, // ui-copy-ignore
815
- w: VIEW.width, // ui-copy-ignore
816
- h: VIEW.height, // ui-copy-ignore
1277
+ w: this.view.width, // ui-copy-ignore
1278
+ h: this.view.height, // ui-copy-ignore
817
1279
  jpeg: data, // ui-copy-ignore
818
1280
  url: this.url, // ui-copy-ignore
819
1281
  title: this.title, // ui-copy-ignore
1282
+ hold: this.holdOf(), // ui-copy-ignore
820
1283
  pointer: this.pointer, // ui-copy-ignore
821
1284
  }
822
1285
  for (const sink of this.frames) {
@@ -828,10 +1291,53 @@ export class SidecarPage {
828
1291
  }
829
1292
  }
830
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
+ }
1335
+ }
1336
+
831
1337
  async stopCast() {
832
1338
  if (!this.casting) return
833
1339
  this.casting = false
834
- await this.cdp.send('Page.stopScreencast').catch(() => {})
1340
+ if (this.flashBridge === null) await this.cdp.send('Page.stopScreencast').catch(() => {})
835
1341
  }
836
1342
 
837
1343
  /**
@@ -840,6 +1346,7 @@ export class SidecarPage {
840
1346
  */
841
1347
  async navigate(url) {
842
1348
  this.touched = Date.now()
1349
+ this.navAt = Date.now()
843
1350
  this.lastUrl = String(url)
844
1351
  await this.cdp.send('Page.navigate', { url })
845
1352
  /* A blank page is where the wait ends, not a place still to arrive: asking
@@ -859,10 +1366,13 @@ export class SidecarPage {
859
1366
  own error page: the network stack is still coming up. One reload of that
860
1367
  address is the difference between the chat seeing the site and the chat
861
1368
  seeing a browser error in the mirror. */
862
- if (this.url.startsWith('chrome-error://')) {
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) {
863
1373
  await wait(300)
864
1374
  await this.cdp.send('Page.reload', {}).catch(() => {})
865
- for (let i = 0; i < 80; i += 1) {
1375
+ for (let i = 0; i < 40; i += 1) {
866
1376
  await wait(100)
867
1377
  await this.readTitle()
868
1378
  if (this.url !== '' && !this.url.startsWith('chrome-error://') && this.url !== 'about:blank') break
@@ -891,6 +1401,35 @@ export class SidecarPage {
891
1401
  }
892
1402
  }
893
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
+
894
1433
  async reload() {
895
1434
  this.touched = Date.now()
896
1435
  await this.cdp.send('Page.reload', {})
@@ -910,11 +1449,28 @@ export class SidecarPage {
910
1449
  return { url: this.url, moved: true }
911
1450
  }
912
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
+
913
1468
  /** @param {{x: number, y: number}} point - where the pointer goes. */
914
1469
  movePointer(point) {
915
1470
  this.pointer = { x: Math.round(point.x), y: Math.round(point.y) }
1471
+ this.lastPointerAt = Date.now()
916
1472
  this.touched = Date.now()
917
- 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
918
1474
  for (const sink of this.pointerSinks) {
919
1475
  try {
920
1476
  sink(message)
@@ -967,6 +1523,7 @@ export class SidecarPage {
967
1523
  }
968
1524
 
969
1525
  async hover(point) {
1526
+ if (this.flashBridge !== null && (await this.flashInput('mouseMove', point)) === true) return this.cursorAt(point)
970
1527
  await this.input('Input.dispatchMouseEvent', {
971
1528
  type: 'mouseMoved', // ui-copy-ignore
972
1529
  x: Math.round(point.x),
@@ -984,8 +1541,32 @@ export class SidecarPage {
984
1541
  * @param {{x: number, y: number, button?: string, clickCount?: number}} point - CSS pixels.
985
1542
  * @returns {Promise<void>} resolves when Chrome took the event.
986
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
+
987
1567
  async mouse(type, point) {
988
1568
  this.movePointer(point)
1569
+ if (this.flashBridge !== null && (await this.flashInput(type === 'mousePressed' ? 'mouseDown' : type === 'mouseReleased' ? 'mouseUp' : 'mouseMove', point)) === true) return
989
1570
  await this.input('Input.dispatchMouseEvent', {
990
1571
  type,
991
1572
  x: point.x,
@@ -1003,6 +1584,19 @@ export class SidecarPage {
1003
1584
  */
1004
1585
  async wheel(args) {
1005
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
+ }
1006
1600
  await this.input('Input.dispatchMouseEvent', {
1007
1601
  type: 'mouseWheel', // ui-copy-ignore
1008
1602
  x: args.x, // ui-copy-ignore
@@ -1013,6 +1607,41 @@ export class SidecarPage {
1013
1607
  })
1014
1608
  }
1015
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
+
1016
1645
  /**
1017
1646
  * @param {string} type - `keyDown` or `keyUp`.
1018
1647
  * @param {{key: string, code?: string, keyCode?: number, text?: string, modifiers?: number}} key - one key.
@@ -1020,9 +1649,11 @@ export class SidecarPage {
1020
1649
  */
1021
1650
  async key(type, key) {
1022
1651
  this.touched = Date.now()
1652
+ if (this.flashBridge !== null && (await this.flashKey(type, key)) === true) return
1023
1653
  const virtual = key.keyCode ?? key.windowsVirtualKeyCode ?? 0
1024
1654
  await this.input('Input.dispatchKeyEvent', {
1025
- 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 } : {}),
1026
1657
  key: key.key, // ui-copy-ignore
1027
1658
  code: key.code ?? '', // ui-copy-ignore
1028
1659
  windowsVirtualKeyCode: virtual,
@@ -1030,6 +1661,7 @@ export class SidecarPage {
1030
1661
  text: key.text, // ui-copy-ignore
1031
1662
  unmodifiedText: key.text,
1032
1663
  modifiers: key.modifiers ?? 0,
1664
+ ...(Array.isArray(key.commands) && key.commands.length > 0 ? { commands: key.commands } : {}),
1033
1665
  })
1034
1666
  }
1035
1667
 
@@ -1050,8 +1682,11 @@ export class SidecarPage {
1050
1682
  fingers, but nobody wants to wait fifteen seconds for a paragraph. */
1051
1683
  const long = chars.length > 160
1052
1684
  const burst = Math.max(1, Number(options.burst ?? (long ? 8 : 3)))
1053
- const min = Number(options.min ?? (long ? 10 : 26))
1054
- const max = Math.max(min + 1, Number(options.max ?? (long ? 46 : 110)))
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)))
1055
1690
  const gap = () => min + Math.random() * (max - min)
1056
1691
  let at = 0
1057
1692
  while (at < chars.length) {
@@ -1065,6 +1700,10 @@ export class SidecarPage {
1065
1700
  await this.input('Input.insertText', { text: chunk })
1066
1701
  } else {
1067
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
+ }
1068
1707
  await this.input('Input.dispatchKeyEvent', {
1069
1708
  type: 'keyDown', // ui-copy-ignore
1070
1709
  key: one, // ui-copy-ignore
@@ -1078,11 +1717,93 @@ export class SidecarPage {
1078
1717
  at += size
1079
1718
  const last = chars[at - 1] ?? ''
1080
1719
  /* Stop longer after a full stop or a comma, the way a person does. */
1081
- const pause = /[。!?,、;:\n.,!?;:]/.test(last) ? gap() * 2.2 + 90 : gap()
1720
+ const pause = /[。!?,、;:\n.,!?;:]/.test(last) ? gap() * 2 + 30 : gap()
1082
1721
  await wait(at < chars.length ? pause : 0)
1083
1722
  }
1084
1723
  }
1085
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
+
1086
1807
  /** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
1087
1808
  async insertText(text) {
1088
1809
  this.touched = Date.now()
@@ -1234,18 +1955,28 @@ export class Sidecar {
1234
1955
  constructor(options = {}) {
1235
1956
  this.logger = options.logger
1236
1957
  this.child = null
1958
+ this.owns = false
1237
1959
  this.stopping = false
1238
1960
  this.cdp = null
1239
1961
  this.page = null
1240
1962
  /* One tab per chat: a second chat opening an address gets a tab of its
1241
1963
  own, so neither can navigate the other's page away. */
1242
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
1243
1969
  /* A tab being opened for a chat: a second call for the same chat waits for
1244
1970
  it instead of opening a twin. */
1245
1971
  this.opening = new Map()
1246
1972
  /* A measurement or a test may want a profile of its own; the person's
1247
1973
  browser keeps the default one. */
1248
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
1249
1980
  this.headless = true
1250
1981
  this.starting = null
1251
1982
  this.idle = null
@@ -1337,12 +2068,16 @@ export class Sidecar {
1337
2068
  /* Only chats, and only addresses: a stray key from a probe or an older run
1338
2069
  must not sit in this file pretending to be a chat that can be restored. */
1339
2070
  if (!/^session-/.test(id) || address === '' || address === 'about:blank') return
1340
- let saved = {}
1341
- try {
1342
- saved = JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}
1343
- } catch {
1344
- saved = {}
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
+ }
1345
2079
  }
2080
+ const saved = tabsCache
1346
2081
  if (saved[id] === address) return
1347
2082
  saved[id] = address
1348
2083
  try {
@@ -1391,6 +2126,7 @@ export class Sidecar {
1391
2126
  if (want !== '' && String(page.url ?? '') !== want) await page.navigate(want)
1392
2127
  return page
1393
2128
  }
2129
+ if (this.pages.size >= MAX_TABS) await this.trimTabs()
1394
2130
  const work = this.openTab(id, url).finally(() => {
1395
2131
  this.opening?.delete(id)
1396
2132
  })
@@ -1398,6 +2134,101 @@ export class Sidecar {
1398
2134
  return work
1399
2135
  }
1400
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
+
1401
2232
  /**
1402
2233
  * @param {string} id - chat id.
1403
2234
  * @param {string} [url] - address to open in it.
@@ -1416,13 +2247,23 @@ export class Sidecar {
1416
2247
  browser was already showing, rather than at a blank page nobody asked for. */
1417
2248
  const remembered = this.rememberedTab(id)
1418
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
+ }
1419
2260
  const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1420
2261
  if (!created.ok) throw new Error('sidecar: could not open a tab')
1421
2262
  const target = await created.json()
1422
2263
  if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: new tab has no socket')
1423
2264
  const cdp = new Cdp(target.webSocketDebuggerUrl)
1424
2265
  await cdp.ready
1425
- const page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
2266
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
1426
2267
  this.pages.set(id, page)
1427
2268
  this.touch()
1428
2269
  this.rememberTab(id, address)
@@ -1443,8 +2284,12 @@ export class Sidecar {
1443
2284
  restarted, a tab closed by hand. Handing that corpse back leaves the
1444
2285
  mirror on a blank picture forever, so it has to answer before it is
1445
2286
  trusted again. */
1446
- const alive = await mine.ping()
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()
1447
2291
  if (alive.ok) {
2292
+ mine.answeredAt = Date.now()
1448
2293
  /* A blank tab after a restart is not where this chat was: the address it
1449
2294
  last had is on disk, and that is the one worth returning to. */
1450
2295
  if (String(mine.url ?? '') === 'about:blank') {
@@ -1469,12 +2314,18 @@ export class Sidecar {
1469
2314
  const wanted = options.headless === undefined ? this.headless : options.headless !== false
1470
2315
  const modeChanged = wanted !== this.headless
1471
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)
1472
2323
  /* A windowless browser and a windowed one are different processes, so asking
1473
2324
  for the other one has to restart it. Setting the flag alone left the request
1474
2325
  with no effect at all: a site that refuses a windowless browser stayed out
1475
2326
  of reach until the browser happened to close for an unrelated reason. */
1476
- if (this.alive && modeChanged) {
1477
- 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
1478
2329
  await this.shutdown()
1479
2330
  return this.ensure({ ...options, url: wasAt })
1480
2331
  }
@@ -1482,15 +2333,26 @@ export class Sidecar {
1482
2333
  const page = this.page
1483
2334
  /* A page that stopped answering is a page nobody can drive. Start a new
1484
2335
  one on the address it was showing, rather than failing every call. */
1485
- 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()
1486
2341
  if (!probe.ok && !probe.closed) {
1487
2342
  await wait(150)
1488
2343
  probe = await page.ping()
1489
2344
  }
2345
+ if (probe.ok) page.answeredAt = Date.now()
2346
+ if (probe.ok) {
2347
+ void this.pruneStrays().catch(() => {})
2348
+ void this.reapIdleTabs().catch(() => {})
2349
+ }
1490
2350
  if (!probe.ok) {
1491
2351
  page.crashed = true
1492
2352
  this.note('renderer stopped answering; opening a fresh tab')
1493
- 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
1494
2356
  /* The browser itself is usually fine: only the renderer died. Opening a
1495
2357
  fresh tab in it is much faster than starting Chrome again. */
1496
2358
  try {
@@ -1500,7 +2362,24 @@ export class Sidecar {
1500
2362
  return this.ensure({ ...options, url: wasAt })
1501
2363
  }
1502
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
+ }
1503
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
1504
2383
  const id = String(options.session ?? '')
1505
2384
  if (id !== '') {
1506
2385
  /* Another chat may be looking at another tab: this one gets its own. */
@@ -1514,7 +2393,7 @@ export class Sidecar {
1514
2393
  /* A page whose connection closed on its own (a renderer crash closes the
1515
2394
  tab's session) can be replaced in the browser that is still running. */
1516
2395
  if (this.page !== null && this.page.cdp?.closed === true) {
1517
- const wasAt = options.url ?? this.page.lastUrl ?? this.lastUrl
2396
+ const wasAt = options.url || this.page.lastUrl || this.lastUrl
1518
2397
  this.page.crashed = true
1519
2398
  this.note('renderer stopped answering; opening a fresh tab')
1520
2399
  this.starting = this.recover(wasAt).catch((error) => {
@@ -1533,18 +2412,22 @@ export class Sidecar {
1533
2412
 
1534
2413
  /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
1535
2414
  async launch(url) {
1536
- 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)
1537
2419
  /* A profile whose cookie store went missing would look signed out at the
1538
2420
  next step even though the person never signed out, so the working copy
1539
2421
  is put back before the browser is asked to read it. */
1540
2422
  restoreLogin(dir)
1541
2423
  let port = await this.runningPort(dir) // ui-copy-ignore
1542
2424
  if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
2425
+ else this.owns = ownsBrowser({ pid: process.pid, owner: markerPid(dir) })
1543
2426
  const ws = await this.pageSocket(port) // ui-copy-ignore
1544
2427
  const cdp = new Cdp(ws)
1545
2428
  await cdp.ready
1546
2429
  this.cdp = cdp
1547
- this.page = await this.bind(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
1548
2431
  if (url !== undefined && url !== '') this.lastUrl = String(url)
1549
2432
  await this.restoreSessionCookies()
1550
2433
  await this.page.cast()
@@ -1588,7 +2471,9 @@ export class Sidecar {
1588
2471
  * @returns {Promise<number>} the port Chrome opened. // ui-copy-ignore
1589
2472
  */
1590
2473
  async spawnChrome(dir) {
1591
- 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
1592
2477
  if (binary === undefined) throw new Error('sidecar: no Chrome found on this machine')
1593
2478
  const args = [
1594
2479
  `--user-data-dir=${dir}`,
@@ -1628,11 +2513,31 @@ export class Sidecar {
1628
2513
  '--window-position=-3000,-3000',
1629
2514
  'about:blank',
1630
2515
  ]
1631
- if (this.headless) args.unshift('--headless=new')
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')
1632
2524
  /* A browser started again after a stop is a new one: its death is news
1633
2525
  again, and the flag left behind by the stop must not silence it. */
1634
2526
  this.stopping = false
1635
- const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
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 })
1636
2541
  const born = Date.now()
1637
2542
  child.on('exit', (code, signal) => {
1638
2543
  /* A browser that ends without being asked is the one case nobody can see:
@@ -1646,6 +2551,30 @@ export class Sidecar {
1646
2551
  }
1647
2552
  })
1648
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
+ }
1649
2578
  const file = join(dir, 'DevToolsActivePort')
1650
2579
  for (let i = 0; i < 150; i += 1) {
1651
2580
  await wait(100)
@@ -1686,6 +2615,10 @@ export class Sidecar {
1686
2615
  * @returns {SidecarPage} the same page.
1687
2616
  */
1688
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(() => {})
1689
2622
  page.siblings = () =>
1690
2623
  [...this.pages.values(), this.page]
1691
2624
  .filter((other) => other !== null && other !== undefined && other !== page)
@@ -1709,7 +2642,7 @@ export class Sidecar {
1709
2642
  if (mine !== null) {
1710
2643
  const cdp = new Cdp(tab.webSocketUrl)
1711
2644
  await cdp.ready
1712
- const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port).start()) // ui-copy-ignore
2645
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port, () => this.holding()).start()) // ui-copy-ignore
1713
2646
  this.pages.set(String(session), page)
1714
2647
  mine.cdp.send('Page.stopScreencast').catch(() => {})
1715
2648
  mine.cdp.close()
@@ -1719,7 +2652,7 @@ export class Sidecar {
1719
2652
  }
1720
2653
  const cdp = new Cdp(tab.webSocketUrl)
1721
2654
  await cdp.ready
1722
- const page = await this.bind(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
1723
2656
  this.cdp = cdp
1724
2657
  this.page = page
1725
2658
  this.lastUrl = tab.url
@@ -1762,7 +2695,12 @@ export class Sidecar {
1762
2695
  async recover(url) {
1763
2696
  const port = this.page?.port ?? 0 // ui-copy-ignore
1764
2697
  if (port === 0) throw new Error('sidecar: no browser to recover into') // ui-copy-ignore
1765
- 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
+ }
1766
2704
  const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1767
2705
  if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
1768
2706
  const target = await created.json()
@@ -1770,7 +2708,17 @@ export class Sidecar {
1770
2708
  const cdp = new Cdp(target.webSocketDebuggerUrl)
1771
2709
  await cdp.ready
1772
2710
  this.cdp = cdp
1773
- this.page = await this.bind(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)
1774
2722
  await this.page.cast()
1775
2723
  this.touch()
1776
2724
  return this.page
@@ -1905,13 +2853,21 @@ export class Sidecar {
1905
2853
  }
1906
2854
 
1907
2855
  async shutdown() {
1908
- /* Ask Chrome to close first: a browser that is killed outright may lose the
1909
- last minute of cookies, and keeping the person signed in is the point. */
1910
- if (this.page !== null) await this.saveSessionCookies()
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
+ }
1911
2868
  if (this.backup !== undefined) clearInterval(this.backup)
1912
2869
  this.backup = undefined
1913
- snapshotLogin(profileDir(this.profile))
1914
- if (this.cdp !== null) {
2870
+ if (this.cdp !== null && mine) {
1915
2871
  await this.cdp.send('Browser.close').catch(() => {})
1916
2872
  await new Promise((resolve) => setTimeout(resolve, 400))
1917
2873
  }
@@ -1920,14 +2876,32 @@ export class Sidecar {
1920
2876
  this.cdp?.close()
1921
2877
  this.cdp = null
1922
2878
  if (this.child !== null) {
2879
+ const child = this.child
1923
2880
  try {
1924
2881
  /* A browser on Windows is a process tree: terminating the launcher alone
1925
2882
  leaves renderers and the profile lock behind. Browser.close above is
1926
2883
  the graceful path; this is the one for a browser that ignored it. */
1927
- if (process.platform === 'win32' && Number.isInteger(this.child.pid)) {
1928
- spawnSync('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { stdio: 'ignore' })
2884
+ if (process.platform === 'win32' && Number.isInteger(child.pid)) {
2885
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
1929
2886
  } else {
1930
- this.child.kill('SIGTERM')
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
+ })
1931
2905
  }
1932
2906
  } catch {
1933
2907
  /* already gone */
@@ -2034,6 +3008,12 @@ export class Sidecar {
2034
3008
  frames: this.page?.seq ?? 0,
2035
3009
  crashed: this.page?.crashed === true,
2036
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,
2037
3017
  profiles,
2038
3018
  error: this.alive ? '' : this.lastError,
2039
3019
  }