dsh-inner-browser-use 0.34.28 → 0.34.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -18
- package/README.zh.md +26 -14
- package/client.js +223 -23
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +1 -1
- package/docs/BENCH.md +209 -34
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +21 -15
- package/docs/VERIFY.md +20 -10
- package/index.js +186 -24
- package/package.json +17 -3
- package/read.js +633 -29
- package/runtime/flash-app/main.js +232 -0
- package/runtime/flash-app/package.json +1 -0
- package/runtime/flash-setup.mjs +151 -0
- package/runtime/swf.mjs +233 -0
- package/scripts/check-flash-local.mjs +104 -0
- package/scripts/check-flash-mirror.mjs +73 -0
- package/scripts/check-flash-tools.mjs +74 -0
- package/scripts/check-flash-window.mjs +58 -0
- package/scripts/doctor.mjs +3 -1
- package/sidecar.js +1098 -96
- package/tools.js +1068 -144
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
/*
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
565
|
-
|
|
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)
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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)
|
|
704
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
|
+
}
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
738
|
-
height:
|
|
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:
|
|
790
|
-
maxWidth:
|
|
791
|
-
maxHeight:
|
|
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
|
-
/**
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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:
|
|
816
|
-
h:
|
|
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
|
-
|
|
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 <
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1054
|
-
|
|
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
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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)
|
|
@@ -1674,6 +2603,28 @@ export class Sidecar {
|
|
|
1674
2603
|
return page === null ? [] : page.tabs()
|
|
1675
2604
|
}
|
|
1676
2605
|
|
|
2606
|
+
/**
|
|
2607
|
+
* The windows this chat may see: the browser's own pages minus the ones other
|
|
2608
|
+
* chats are driving. A chat's column shows its own windows, never a
|
|
2609
|
+
* neighbour's.
|
|
2610
|
+
* @param {string} session - chat asking.
|
|
2611
|
+
* @returns {Promise<Array<{title: string, url: string, current: boolean, index: number}>>} its windows.
|
|
2612
|
+
*/
|
|
2613
|
+
async windowsFor(session) {
|
|
2614
|
+
const list = await this.tabs(session)
|
|
2615
|
+
const page = this.pageFor(session) ?? this.page
|
|
2616
|
+
const foreign = new Set(typeof page?.siblings === 'function' ? page.siblings().map(String) : [])
|
|
2617
|
+
const shown = []
|
|
2618
|
+
for (let at = 0; at < list.length; at += 1) {
|
|
2619
|
+
const row = list[at]
|
|
2620
|
+
if (foreign.has(String(row.targetId))) continue
|
|
2621
|
+
/* The index is the browser's own row, not this shorter list's: switching
|
|
2622
|
+
goes back through `tabs()` unfiltered. */
|
|
2623
|
+
shown.push({ title: String(row.title ?? ''), url: String(row.url ?? ''), current: row.current === true, index: at })
|
|
2624
|
+
}
|
|
2625
|
+
return shown
|
|
2626
|
+
}
|
|
2627
|
+
|
|
1677
2628
|
/**
|
|
1678
2629
|
* Move the mirror to another tab, leaving the old one open where it was.
|
|
1679
2630
|
* @param {number} index - a row from `tabs()`.
|
|
@@ -1686,6 +2637,10 @@ export class Sidecar {
|
|
|
1686
2637
|
* @returns {SidecarPage} the same page.
|
|
1687
2638
|
*/
|
|
1688
2639
|
bind(page) {
|
|
2640
|
+
page.flashBridge = this.flashBridge
|
|
2641
|
+
/* The launch window is landscape; shrink it to the capture budget before
|
|
2642
|
+
anyone asks, so the first judged frame is not a 2 fps one. */
|
|
2643
|
+
if (this.flashBridge !== null) void page.setViewport(page.view.width, page.view.height).catch(() => {})
|
|
1689
2644
|
page.siblings = () =>
|
|
1690
2645
|
[...this.pages.values(), this.page]
|
|
1691
2646
|
.filter((other) => other !== null && other !== undefined && other !== page)
|
|
@@ -1709,7 +2664,7 @@ export class Sidecar {
|
|
|
1709
2664
|
if (mine !== null) {
|
|
1710
2665
|
const cdp = new Cdp(tab.webSocketUrl)
|
|
1711
2666
|
await cdp.ready
|
|
1712
|
-
const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port).start()) // ui-copy-ignore
|
|
2667
|
+
const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port, () => this.holding()).start()) // ui-copy-ignore
|
|
1713
2668
|
this.pages.set(String(session), page)
|
|
1714
2669
|
mine.cdp.send('Page.stopScreencast').catch(() => {})
|
|
1715
2670
|
mine.cdp.close()
|
|
@@ -1719,7 +2674,7 @@ export class Sidecar {
|
|
|
1719
2674
|
}
|
|
1720
2675
|
const cdp = new Cdp(tab.webSocketUrl)
|
|
1721
2676
|
await cdp.ready
|
|
1722
|
-
const page = await this.bind(await new SidecarPage(cdp, this.profile, was.port).start()) // ui-copy-ignore
|
|
2677
|
+
const page = await this.bind(await new SidecarPage(cdp, this.profile, was.port, () => this.holding()).start()) // ui-copy-ignore
|
|
1723
2678
|
this.cdp = cdp
|
|
1724
2679
|
this.page = page
|
|
1725
2680
|
this.lastUrl = tab.url
|
|
@@ -1762,7 +2717,12 @@ export class Sidecar {
|
|
|
1762
2717
|
async recover(url) {
|
|
1763
2718
|
const port = this.page?.port ?? 0 // ui-copy-ignore
|
|
1764
2719
|
if (port === 0) throw new Error('sidecar: no browser to recover into') // ui-copy-ignore
|
|
1765
|
-
|
|
2720
|
+
let address = url === '' || url === undefined ? 'about:blank' : String(url)
|
|
2721
|
+
if (address === 'about:blank' && String(this.lastUrl ?? '') !== '' && this.lastUrl !== 'about:blank') address = String(this.lastUrl)
|
|
2722
|
+
if (this.flash) {
|
|
2723
|
+
await this.page.navigate(address)
|
|
2724
|
+
return this.page
|
|
2725
|
+
}
|
|
1766
2726
|
const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
|
|
1767
2727
|
if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
|
|
1768
2728
|
const target = await created.json()
|
|
@@ -1770,7 +2730,17 @@ export class Sidecar {
|
|
|
1770
2730
|
const cdp = new Cdp(target.webSocketDebuggerUrl)
|
|
1771
2731
|
await cdp.ready
|
|
1772
2732
|
this.cdp = cdp
|
|
1773
|
-
this.page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
|
|
2733
|
+
this.page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
|
|
2734
|
+
/* The new tab may have finished loading before we attached, in which case
|
|
2735
|
+
no navigation event ever reaches us and the page would report the blank
|
|
2736
|
+
address it was created with. Ask it where it actually is. */
|
|
2737
|
+
let here = ''
|
|
2738
|
+
try {
|
|
2739
|
+
const out = await cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true })
|
|
2740
|
+
here = String(out?.result?.value ?? '')
|
|
2741
|
+
} catch {}
|
|
2742
|
+
if (here !== '' && here !== 'about:blank') this.page.lastUrl = here
|
|
2743
|
+
else if (address !== 'about:blank') await this.page.navigate(address)
|
|
1774
2744
|
await this.page.cast()
|
|
1775
2745
|
this.touch()
|
|
1776
2746
|
return this.page
|
|
@@ -1905,13 +2875,21 @@ export class Sidecar {
|
|
|
1905
2875
|
}
|
|
1906
2876
|
|
|
1907
2877
|
async shutdown() {
|
|
1908
|
-
/*
|
|
1909
|
-
|
|
1910
|
-
|
|
2878
|
+
/* A browser this process did not start belongs to somebody else — another
|
|
2879
|
+
app on the same profile, or a script that connected to it — and closing
|
|
2880
|
+
it would close the person's window. Let go of it instead: drop the socket
|
|
2881
|
+
and leave the browser running. */
|
|
2882
|
+
const mine = this.owns === true
|
|
2883
|
+
if (mine && this.page !== null) {
|
|
2884
|
+
/* Ask Chrome to close first: a browser that is killed outright may lose
|
|
2885
|
+
the last minute of cookies, and keeping the person signed in is the
|
|
2886
|
+
point. */
|
|
2887
|
+
await this.saveSessionCookies()
|
|
2888
|
+
snapshotLogin(profileDir(this.profile))
|
|
2889
|
+
}
|
|
1911
2890
|
if (this.backup !== undefined) clearInterval(this.backup)
|
|
1912
2891
|
this.backup = undefined
|
|
1913
|
-
|
|
1914
|
-
if (this.cdp !== null) {
|
|
2892
|
+
if (this.cdp !== null && mine) {
|
|
1915
2893
|
await this.cdp.send('Browser.close').catch(() => {})
|
|
1916
2894
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
1917
2895
|
}
|
|
@@ -1920,14 +2898,32 @@ export class Sidecar {
|
|
|
1920
2898
|
this.cdp?.close()
|
|
1921
2899
|
this.cdp = null
|
|
1922
2900
|
if (this.child !== null) {
|
|
2901
|
+
const child = this.child
|
|
1923
2902
|
try {
|
|
1924
2903
|
/* A browser on Windows is a process tree: terminating the launcher alone
|
|
1925
2904
|
leaves renderers and the profile lock behind. Browser.close above is
|
|
1926
2905
|
the graceful path; this is the one for a browser that ignored it. */
|
|
1927
|
-
if (process.platform === 'win32' && Number.isInteger(
|
|
1928
|
-
spawnSync('taskkill', ['/pid', String(
|
|
2906
|
+
if (process.platform === 'win32' && Number.isInteger(child.pid)) {
|
|
2907
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
1929
2908
|
} else {
|
|
1930
|
-
|
|
2909
|
+
child.kill('SIGTERM')
|
|
2910
|
+
/* Electron with an offscreen window can ignore SIGTERM while a plugin
|
|
2911
|
+
paints; a leftover helper keeps the profile locked and shows up as a
|
|
2912
|
+
stray process on the person's desktop. Escalate by pid. */
|
|
2913
|
+
await new Promise((resolve) => {
|
|
2914
|
+
const timer = setTimeout(() => {
|
|
2915
|
+
try {
|
|
2916
|
+
child.kill('SIGKILL')
|
|
2917
|
+
} catch {
|
|
2918
|
+
/* already gone */
|
|
2919
|
+
}
|
|
2920
|
+
resolve()
|
|
2921
|
+
}, 1500)
|
|
2922
|
+
child.once('exit', () => {
|
|
2923
|
+
clearTimeout(timer)
|
|
2924
|
+
resolve()
|
|
2925
|
+
})
|
|
2926
|
+
})
|
|
1931
2927
|
}
|
|
1932
2928
|
} catch {
|
|
1933
2929
|
/* already gone */
|
|
@@ -2034,6 +3030,12 @@ export class Sidecar {
|
|
|
2034
3030
|
frames: this.page?.seq ?? 0,
|
|
2035
3031
|
crashed: this.page?.crashed === true,
|
|
2036
3032
|
pointer: this.page?.pointer ?? null,
|
|
3033
|
+
/* Frames that repeated the pane's picture and were dropped instead of
|
|
3034
|
+
sent: the number a static page spends on nothing. */
|
|
3035
|
+
quiet: this.page?.quiet ?? 0,
|
|
3036
|
+
/* Layout size of the page the pane is showing: a coordinate from a
|
|
3037
|
+
screenshot is in this space, not in the window's. */
|
|
3038
|
+
viewport: this.page?.view ?? null,
|
|
2037
3039
|
profiles,
|
|
2038
3040
|
error: this.alive ? '' : this.lastError,
|
|
2039
3041
|
}
|