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