dsh-inner-browser-use 0.27.0 → 0.34.28
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/client.js +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +376 -0
- package/docs/ROADMAP.md +88 -0
- package/index.js +320 -29
- package/package.json +5 -2
- package/read.js +111 -10
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +635 -83
package/sidecar.js
CHANGED
|
@@ -5,11 +5,29 @@
|
|
|
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
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Windows keeps a browser under Program Files (machine-wide) or LocalAppData
|
|
15
|
+
* (per-user), and Edge is Chromium too, so it speaks the same DevTools
|
|
16
|
+
* protocol. Chrome is still preferred: the list is read in order.
|
|
17
|
+
* @returns {string[]} candidates, most preferred first.
|
|
18
|
+
*/
|
|
19
|
+
function windowsBrowserCandidates() {
|
|
20
|
+
const roots = [
|
|
21
|
+
process.env['PROGRAMFILES'],
|
|
22
|
+
process.env['PROGRAMFILES(X86)'],
|
|
23
|
+
process.env['LOCALAPPDATA'],
|
|
24
|
+
].filter((root) => typeof root === 'string' && root !== '')
|
|
25
|
+
const out = []
|
|
26
|
+
for (const root of roots) out.push(join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'))
|
|
27
|
+
for (const root of roots) out.push(join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/** Chrome builds this machine may have, most preferred first. */
|
|
14
32
|
const CHROME_BINARIES = [
|
|
15
33
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
@@ -17,11 +35,15 @@ const CHROME_BINARIES = [
|
|
|
17
35
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
18
36
|
'/usr/bin/google-chrome',
|
|
19
37
|
'/usr/bin/chromium',
|
|
38
|
+
...(process.platform === 'win32' ? windowsBrowserCandidates() : []),
|
|
20
39
|
]
|
|
21
40
|
|
|
22
41
|
/** Viewport the screencast and the pointer share. Nothing here is a user-agent detail. */
|
|
23
42
|
export const VIEW = { width: 1440, height: 900 }
|
|
24
43
|
|
|
44
|
+
/** What a person's Chrome on this machine sends. */
|
|
45
|
+
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'
|
|
46
|
+
|
|
25
47
|
/**
|
|
26
48
|
* Pixels the mirror actually sends. The viewport is never downscaled: a
|
|
27
49
|
* half-size JPEG turns 12px body text into mush in a column this narrow, and
|
|
@@ -44,6 +66,12 @@ function firstBinary() {
|
|
|
44
66
|
return CHROME_BINARIES.find((path) => existsSync(path))
|
|
45
67
|
}
|
|
46
68
|
|
|
69
|
+
/* A chat's tab address outlives a plugin reload: the browser is still running
|
|
70
|
+
with that tab, and the chat that returns should get its page back, not a
|
|
71
|
+
blank one. */
|
|
72
|
+
const tabsDir = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
|
|
73
|
+
const tabsFile = join(tabsDir, 'tabs.json')
|
|
74
|
+
|
|
47
75
|
/** Root of everything this plugin keeps for the sidecar. */
|
|
48
76
|
function rootDir() {
|
|
49
77
|
const dir = join(homedir(), '.dsh', 'inner-browser-use', 'chrome')
|
|
@@ -51,6 +79,177 @@ function rootDir() {
|
|
|
51
79
|
return dir
|
|
52
80
|
}
|
|
53
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Whether a target is not this page's to take: its own tab or one another chat
|
|
84
|
+
* is driving.
|
|
85
|
+
* @param {string} targetId - candidate target.
|
|
86
|
+
* @param {string} mine - the asking page's own target.
|
|
87
|
+
* @param {string[]} [foreign] - targets other chats drive.
|
|
88
|
+
* @returns {boolean} true when the candidate must be left alone.
|
|
89
|
+
*/
|
|
90
|
+
export function isForeign(targetId, mine, foreign = []) {
|
|
91
|
+
const id = String(targetId ?? '')
|
|
92
|
+
if (id === '') return true
|
|
93
|
+
if (id === String(mine ?? '')) return true
|
|
94
|
+
return foreign.map(String).includes(id)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where the cookies of a profile are kept in the browser's own storage.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* The topmost process this plugin lives in: the application itself, not the
|
|
102
|
+
* node instance a reload replaces. A reload changes `process.pid`, so a watcher
|
|
103
|
+
* keyed to it kills the shared browser the moment the old instance goes — which
|
|
104
|
+
* is how the mirror went blank after a reload.
|
|
105
|
+
* @returns {number} the application pid, or 0 when it cannot be walked.
|
|
106
|
+
*/
|
|
107
|
+
function appPid() {
|
|
108
|
+
let pid = process.pid
|
|
109
|
+
let top = pid
|
|
110
|
+
for (let hop = 0; hop < 12; hop += 1) {
|
|
111
|
+
let parent = 0
|
|
112
|
+
try {
|
|
113
|
+
parent = Number(String(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8' })).trim())
|
|
114
|
+
} catch {
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(parent) || parent <= 1) break
|
|
118
|
+
top = parent
|
|
119
|
+
pid = parent
|
|
120
|
+
}
|
|
121
|
+
return top === process.pid ? 0 : top
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a process command line is the application itself, rather than the node
|
|
126
|
+
* instance a reload replaces. A watcher keyed to the node instance would kill
|
|
127
|
+
* the shared browser on every reload, which is exactly what made the column go
|
|
128
|
+
* blank; a command line with `--expose-internals` is that instance.
|
|
129
|
+
* @param {string} command - what `ps -o command=` said.
|
|
130
|
+
* @returns {boolean} true for the app binary itself.
|
|
131
|
+
*/
|
|
132
|
+
export function looksLikeApp(command) {
|
|
133
|
+
const line = String(command ?? '')
|
|
134
|
+
if (line.includes('--expose-internals')) return false
|
|
135
|
+
return line.includes('.app/Contents/MacOS/')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {number} pid - a process.
|
|
140
|
+
* @returns {string} its command line, or '' when it is gone.
|
|
141
|
+
*/
|
|
142
|
+
function commandOf(pid) {
|
|
143
|
+
if (!Number.isInteger(pid) || pid <= 1) return ''
|
|
144
|
+
try {
|
|
145
|
+
return String(execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' })).trim()
|
|
146
|
+
} catch {
|
|
147
|
+
return ''
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether this run may arm the watcher at all: never on Windows, where the
|
|
153
|
+
* browser tree ends with the application through `taskkill /T`, and only inside
|
|
154
|
+
* the application itself, since a note keyed to anything else would close the
|
|
155
|
+
* shared browser on a plugin reload.
|
|
156
|
+
* @returns {boolean} true when `handoff()` can write a watcher note.
|
|
157
|
+
*/
|
|
158
|
+
export function canArmWatcher() {
|
|
159
|
+
if (process.platform === 'win32') return false
|
|
160
|
+
const app = appPid()
|
|
161
|
+
return app !== 0 && looksLikeApp(commandOf(app)) === true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The watcher that closes the browser when the app goes: one per app run. */
|
|
165
|
+
function watcherFile(dir) {
|
|
166
|
+
return join(dir, 'watcher.json')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One line describing a browser that went away by itself, so the next person to
|
|
171
|
+
* look knows whether it was asked to stop, crashed, or was killed from outside.
|
|
172
|
+
* @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string}} gone - what the child reported.
|
|
173
|
+
* @returns {string} a line without a newline.
|
|
174
|
+
*/
|
|
175
|
+
export function deathNote(gone) {
|
|
176
|
+
const at = new Date().toISOString()
|
|
177
|
+
const code = gone.code === null || gone.code === undefined ? 'none' : String(gone.code)
|
|
178
|
+
return `${at} browser pid ${String(gone.pid ?? 0)} profile ${String(gone.profile ?? 'default')} exit code ${code} signal ${String(gone.signal ?? 'none')}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Appends one line to the browser log and never troubles the caller, because a
|
|
183
|
+
* browser that died while nothing was watching should not also break the call
|
|
184
|
+
* that noticed.
|
|
185
|
+
* @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string, asked?: boolean}} gone - what happened.
|
|
186
|
+
* @param {string} [dir] - where the log lives; the state directory by default.
|
|
187
|
+
* @returns {void}
|
|
188
|
+
*/
|
|
189
|
+
export function noteDeath(gone, dir = tabsDir) {
|
|
190
|
+
if (gone.asked === true) return
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dir, { recursive: true })
|
|
193
|
+
appendFileSync(join(dir, 'browser.log'), `${deathNote(gone)}\n`)
|
|
194
|
+
} catch {
|
|
195
|
+
/* a log is a convenience, not a promise */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const COOKIE_FILES = ['Default/Cookies', 'Local State']
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} dir - a profile directory.
|
|
203
|
+
* @returns {string} where its cookie snapshots live.
|
|
204
|
+
*/
|
|
205
|
+
export function loginBackupDir(dir) {
|
|
206
|
+
// A Windows profile path has no '/', so splitting on it returned the whole
|
|
207
|
+
// path and the backup landed at `.../login/C:\Users\…` — a mkdir that fails.
|
|
208
|
+
const last = dir.split(/[\\/]/u).filter(Boolean).pop() ?? 'default'
|
|
209
|
+
return join(tabsDir, 'login', last)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Copies the cookie store aside while it is still good, so a profile that loses
|
|
214
|
+
* its cookies another way can be handed the ones that were working.
|
|
215
|
+
* @param {string} dir - a profile directory.
|
|
216
|
+
* @returns {number} how many snapshots are kept.
|
|
217
|
+
*/
|
|
218
|
+
export function snapshotLogin(dir) {
|
|
219
|
+
const files = COOKIE_FILES.map((name) => ({ name, from: join(dir, name) })).filter((row) => existsSync(row.from))
|
|
220
|
+
if (files.length === 0) return 0
|
|
221
|
+
const to = loginBackupDir(dir)
|
|
222
|
+
mkdirSync(to, { recursive: true })
|
|
223
|
+
const stamp = String(Date.now())
|
|
224
|
+
for (const row of files) copyFileSync(row.from, join(to, `${stamp}-${row.name.replace(/\//g, '_')}`))
|
|
225
|
+
const mine = readdirSync(to).filter((name) => name.startsWith(`${stamp}-`))
|
|
226
|
+
const older = readdirSync(to).filter((name) => !mine.includes(name)).sort()
|
|
227
|
+
const generations = 5
|
|
228
|
+
const keep = older.slice(-generations * files.length)
|
|
229
|
+
for (const name of older) if (!keep.includes(name)) rmSync(join(to, name), { force: true })
|
|
230
|
+
return older.length + mine.length
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Puts the newest snapshot back when the profile has no cookie store at all.
|
|
235
|
+
* @param {string} dir - a profile directory.
|
|
236
|
+
* @returns {boolean} whether anything was restored.
|
|
237
|
+
*/
|
|
238
|
+
export function restoreLogin(dir) {
|
|
239
|
+
const cookie = join(dir, 'Default/Cookies')
|
|
240
|
+
if (existsSync(cookie) && statSync(cookie).size > 1000) return false
|
|
241
|
+
const from = loginBackupDir(dir)
|
|
242
|
+
if (!existsSync(from)) return false
|
|
243
|
+
const names = readdirSync(from).sort()
|
|
244
|
+
const newest = names.filter((name) => name.includes('-Default_Cookies')).slice(-1)[0]
|
|
245
|
+
const state = names.filter((name) => name.includes('-Local_State')).slice(-1)[0]
|
|
246
|
+
if (newest === undefined) return false
|
|
247
|
+
mkdirSync(join(dir, 'Default'), { recursive: true })
|
|
248
|
+
copyFileSync(join(from, newest), cookie)
|
|
249
|
+
if (state !== undefined) copyFileSync(join(from, state), join(dir, 'Local State'))
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
54
253
|
/** @param {string} name - profile name. @returns {string} its directory. */
|
|
55
254
|
function profileDir(name) {
|
|
56
255
|
const dir = join(rootDir(), String(name || 'default').replace(/[^\w.-]/g, '_'))
|
|
@@ -156,6 +355,7 @@ export class SidecarPage {
|
|
|
156
355
|
this.pointerSinks = new Set()
|
|
157
356
|
this.latest = ''
|
|
158
357
|
this.seq = 0
|
|
358
|
+
this.lastFrameAt = 0
|
|
159
359
|
/* Total JPEG bytes seen, for the bench and for noticing when a page got
|
|
160
360
|
expensive to watch. */
|
|
161
361
|
this.bytes = 0
|
|
@@ -185,6 +385,11 @@ export class SidecarPage {
|
|
|
185
385
|
follows it, because a person looking at the column expects that too. */
|
|
186
386
|
this.children = new Map()
|
|
187
387
|
this.opened = null
|
|
388
|
+
/* The tab this page drives, and the tabs other chats drive. A tab another
|
|
389
|
+
chat owns is never a popup of this one: closing it, or navigating this
|
|
390
|
+
page to its address, would take a page away from somebody else. */
|
|
391
|
+
this.targetId = ''
|
|
392
|
+
this.siblings = () => []
|
|
188
393
|
this.port = Number(port ?? 0) // ui-copy-ignore
|
|
189
394
|
}
|
|
190
395
|
|
|
@@ -307,13 +512,15 @@ export class SidecarPage {
|
|
|
307
512
|
* one exists and where it went.
|
|
308
513
|
* @returns {Promise<{targetId: string, url: string}|null>} the popup.
|
|
309
514
|
*/
|
|
310
|
-
async findPopup() {
|
|
515
|
+
async findPopup(foreign = []) {
|
|
311
516
|
if (this.port <= 0) return null // ui-copy-ignore
|
|
312
|
-
const
|
|
517
|
+
const mine = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
|
|
518
|
+
const main = mine !== '' ? mine : this.targetId
|
|
519
|
+
const theirs = new Set(foreign.map(String))
|
|
313
520
|
const list = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
|
|
314
521
|
.then((response) => (response.ok ? response.json() : []))
|
|
315
522
|
.catch(() => [])
|
|
316
|
-
const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main)
|
|
523
|
+
const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main && !theirs.has(String(item.id ?? '')))
|
|
317
524
|
const popup = pages.find((item) => /^https?:|^file:/.test(String(item.url ?? '')))
|
|
318
525
|
if (popup !== undefined) return { targetId: String(popup.id), url: String(popup.url) }
|
|
319
526
|
const blank = pages[0]
|
|
@@ -343,14 +550,17 @@ export class SidecarPage {
|
|
|
343
550
|
* @returns {Promise<string>} what happened, for the tool result.
|
|
344
551
|
*/
|
|
345
552
|
async adoptOpened() {
|
|
553
|
+
const foreign = typeof this.siblings === 'function' ? this.siblings().map(String) : []
|
|
554
|
+
const theirs = new Set(foreign)
|
|
346
555
|
let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
|
|
347
556
|
this.opened = null
|
|
557
|
+
if (target !== null && isForeign(target.targetId, this.targetId, foreign)) target = null
|
|
348
558
|
if (target === null) {
|
|
349
559
|
/* The target list is written before the popup finished loading, so give a
|
|
350
560
|
click that opens one a moment to say where it is going. */
|
|
351
561
|
for (let attempt = 0; attempt < 3 && target === null; attempt += 1) {
|
|
352
562
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150))
|
|
353
|
-
const found = await this.findPopup()
|
|
563
|
+
const found = await this.findPopup(foreign)
|
|
354
564
|
if (found !== null && found.url !== '') target = found
|
|
355
565
|
else if (found !== null && attempt === 2) target = found
|
|
356
566
|
}
|
|
@@ -456,16 +666,14 @@ export class SidecarPage {
|
|
|
456
666
|
|
|
457
667
|
/** Keep one listener that fans frames out to every viewer. */
|
|
458
668
|
async start() {
|
|
459
|
-
|
|
669
|
+
this.targetId = String(
|
|
670
|
+
await this.cdp.send('Target.getTargetInfo').then((info) => info?.targetInfo?.targetId ?? '').catch(() => ''),
|
|
671
|
+
)
|
|
460
672
|
/* A click that downloads should leave a file, not nothing: point downloads
|
|
461
673
|
at the profile so the driver can report where they went. */
|
|
462
674
|
const folder = downloadsDir(profileDir(this.profile))
|
|
463
675
|
mkdirSync(folder, { recursive: true })
|
|
464
676
|
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
677
|
this.cdp.on((message) => {
|
|
470
678
|
this.watch(message)
|
|
471
679
|
if (message.method !== 'Page.screencastFrame') return
|
|
@@ -473,6 +681,7 @@ export class SidecarPage {
|
|
|
473
681
|
void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
|
|
474
682
|
if (typeof data !== 'string' || data === '') return
|
|
475
683
|
this.latest = data
|
|
684
|
+
this.lastFrameAt = Date.now()
|
|
476
685
|
this.seq += 1
|
|
477
686
|
this.bytes += data.length
|
|
478
687
|
if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
|
|
@@ -503,23 +712,39 @@ export class SidecarPage {
|
|
|
503
712
|
}
|
|
504
713
|
if (message.method === 'Page.loadEventFired') void this.readTitle()
|
|
505
714
|
})
|
|
506
|
-
|
|
715
|
+
/* None of these settings depends on another: asking for all of them at
|
|
716
|
+
once is one round trip instead of four, and a cold start is where it
|
|
717
|
+
shows. */
|
|
718
|
+
await Promise.all([
|
|
719
|
+
this.cdp.send('Page.enable'),
|
|
720
|
+
this.cdp.send('Network.enable').catch(() => {}),
|
|
721
|
+
this.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: folder }).catch(() => {}),
|
|
722
|
+
/* Popups and out-of-process frames are separate targets. Attaching without
|
|
723
|
+
waiting keeps them usable, and lets the mirror follow a new tab. */
|
|
724
|
+
this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => {}),
|
|
725
|
+
])
|
|
507
726
|
/* An instance left behind by a stopped app can still be sitting on a
|
|
508
727
|
dialog; a paused renderer answers nothing, so clear it first. */
|
|
509
728
|
await Promise.race([
|
|
510
729
|
this.cdp.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}),
|
|
511
730
|
new Promise((resolve) => setTimeout(resolve, 800)),
|
|
512
731
|
])
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
732
|
+
/* The metrics and the version string are independent too: the UA override
|
|
733
|
+
only needs the version, not a turn of its own. */
|
|
734
|
+
const [, version] = await Promise.all([
|
|
735
|
+
this.cdp.send('Runtime.enable').then(() =>
|
|
736
|
+
this.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
737
|
+
width: VIEW.width, // ui-copy-ignore
|
|
738
|
+
height: VIEW.height, // ui-copy-ignore
|
|
739
|
+
deviceScaleFactor: 1,
|
|
740
|
+
mobile: false, // ui-copy-ignore
|
|
741
|
+
}),
|
|
742
|
+
),
|
|
743
|
+
this.cdp.send('Browser.getVersion').catch(() => ({ product: '' })),
|
|
744
|
+
])
|
|
520
745
|
/* A headless build announces itself in the UA, and some sites refuse that
|
|
521
746
|
string outright. The page is a normal Chrome here, so it says so. */
|
|
522
|
-
const
|
|
747
|
+
const product = String(version?.product ?? '')
|
|
523
748
|
if (String(product).includes('HeadlessChrome')) {
|
|
524
749
|
const version = String(product).replace(/.*HeadlessChrome\//, '')
|
|
525
750
|
await this.cdp.send('Emulation.setUserAgentOverride', {
|
|
@@ -550,7 +775,12 @@ export class SidecarPage {
|
|
|
550
775
|
|
|
551
776
|
/** @returns {Promise<void>} resolves once the page is casting. */
|
|
552
777
|
async cast() {
|
|
553
|
-
if (this.casting)
|
|
778
|
+
if (this.casting) {
|
|
779
|
+
/* A page nobody repaints still owes the pane a picture, so silence for a
|
|
780
|
+
few seconds means the cast died rather than being idle. */
|
|
781
|
+
if (Date.now() - this.lastFrameAt < 3000) return
|
|
782
|
+
this.casting = false
|
|
783
|
+
}
|
|
554
784
|
this.casting = true
|
|
555
785
|
/* Full-resolution mirror at a quality that keeps small text readable;
|
|
556
786
|
`optimizeForSpeed` keeps the encoder from adding to the round trip. */
|
|
@@ -564,6 +794,38 @@ export class SidecarPage {
|
|
|
564
794
|
}).catch(() => {
|
|
565
795
|
this.casting = false
|
|
566
796
|
})
|
|
797
|
+
void this.still()
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Sends one full-frame picture now: a mirror that waits for motion shows an empty pane on a page that is not moving. */
|
|
801
|
+
async still() {
|
|
802
|
+
let data = ''
|
|
803
|
+
try {
|
|
804
|
+
const shot = await this.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 72, fromSurface: true })
|
|
805
|
+
data = String(shot?.data ?? '')
|
|
806
|
+
} catch {
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
if (data === '' || this.frames.size === 0) return
|
|
810
|
+
this.latest = data
|
|
811
|
+
this.lastFrameAt = Date.now()
|
|
812
|
+
this.seq += 1
|
|
813
|
+
const frame = {
|
|
814
|
+
seq: this.seq, // ui-copy-ignore
|
|
815
|
+
w: VIEW.width, // ui-copy-ignore
|
|
816
|
+
h: VIEW.height, // ui-copy-ignore
|
|
817
|
+
jpeg: data, // ui-copy-ignore
|
|
818
|
+
url: this.url, // ui-copy-ignore
|
|
819
|
+
title: this.title, // ui-copy-ignore
|
|
820
|
+
pointer: this.pointer, // ui-copy-ignore
|
|
821
|
+
}
|
|
822
|
+
for (const sink of this.frames) {
|
|
823
|
+
try {
|
|
824
|
+
sink(frame)
|
|
825
|
+
} catch {
|
|
826
|
+
/* a closed viewer is dropped by its own stream handler */
|
|
827
|
+
}
|
|
828
|
+
}
|
|
567
829
|
}
|
|
568
830
|
|
|
569
831
|
async stopCast() {
|
|
@@ -580,12 +842,34 @@ export class SidecarPage {
|
|
|
580
842
|
this.touched = Date.now()
|
|
581
843
|
this.lastUrl = String(url)
|
|
582
844
|
await this.cdp.send('Page.navigate', { url })
|
|
845
|
+
/* A blank page is where the wait ends, not a place still to arrive: asking
|
|
846
|
+
for about:blank and then waiting for a non-blank address spent the whole
|
|
847
|
+
20 s budget, which is how a brand-new chat's first tab took forever. */
|
|
848
|
+
if (String(url) === '' || String(url) === 'about:blank') {
|
|
849
|
+
await wait(80)
|
|
850
|
+
await this.readTitle()
|
|
851
|
+
return { url: this.url, title: this.title }
|
|
852
|
+
}
|
|
583
853
|
for (let i = 0; i < 200; i += 1) {
|
|
584
854
|
await wait(100)
|
|
585
855
|
await this.readTitle()
|
|
586
856
|
if (this.url !== '' && this.url !== 'about:blank') break
|
|
587
857
|
}
|
|
588
|
-
|
|
858
|
+
/* A brand-new browser sometimes answers the very first navigation with its
|
|
859
|
+
own error page: the network stack is still coming up. One reload of that
|
|
860
|
+
address is the difference between the chat seeing the site and the chat
|
|
861
|
+
seeing a browser error in the mirror. */
|
|
862
|
+
if (this.url.startsWith('chrome-error://')) {
|
|
863
|
+
await wait(300)
|
|
864
|
+
await this.cdp.send('Page.reload', {}).catch(() => {})
|
|
865
|
+
for (let i = 0; i < 80; i += 1) {
|
|
866
|
+
await wait(100)
|
|
867
|
+
await this.readTitle()
|
|
868
|
+
if (this.url !== '' && !this.url.startsWith('chrome-error://') && this.url !== 'about:blank') break
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const bad = this.url.startsWith('chrome-error://') ? 'the page did not load' : ''
|
|
872
|
+
return bad === '' ? { url: this.url, title: this.title } : { url: this.url, title: this.title, bad }
|
|
589
873
|
}
|
|
590
874
|
|
|
591
875
|
/**
|
|
@@ -749,6 +1033,56 @@ export class SidecarPage {
|
|
|
749
1033
|
})
|
|
750
1034
|
}
|
|
751
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* Type the way a person does: one character at a time, uneven gaps, a longer
|
|
1038
|
+
* pause where a person would think, and no clipboard paste at all. Paste is
|
|
1039
|
+
* the single event that tells a site a machine filled the box; per-character
|
|
1040
|
+
* key events do not. Longer messages are typed in small bursts so a whole
|
|
1041
|
+
* page of text does not take a minute.
|
|
1042
|
+
* @param {string} text - any unicode, including Chinese.
|
|
1043
|
+
* @param {{burst?: number, min?: number, max?: number}} [options] - pacing.
|
|
1044
|
+
* @returns {Promise<void>} resolves once the last character was accepted.
|
|
1045
|
+
*/
|
|
1046
|
+
async humanType(text, options = {}) {
|
|
1047
|
+
this.touched = Date.now()
|
|
1048
|
+
const chars = Array.from(String(text))
|
|
1049
|
+
/* A long message is typed faster per burst: the opening still looks like
|
|
1050
|
+
fingers, but nobody wants to wait fifteen seconds for a paragraph. */
|
|
1051
|
+
const long = chars.length > 160
|
|
1052
|
+
const burst = Math.max(1, Number(options.burst ?? (long ? 8 : 3)))
|
|
1053
|
+
const min = Number(options.min ?? (long ? 10 : 26))
|
|
1054
|
+
const max = Math.max(min + 1, Number(options.max ?? (long ? 46 : 110)))
|
|
1055
|
+
const gap = () => min + Math.random() * (max - min)
|
|
1056
|
+
let at = 0
|
|
1057
|
+
while (at < chars.length) {
|
|
1058
|
+
const size = at < 12 ? 1 : burst
|
|
1059
|
+
const chunk = chars.slice(at, at + size).join('')
|
|
1060
|
+
/* One key event per character is what a keyboard produces. A multi-letter
|
|
1061
|
+
key event would only deliver its first letter, so a run of Latin text
|
|
1062
|
+
is pressed key by key; a run of Chinese arrives as an input-method
|
|
1063
|
+
commit, which is one event for a whole phrase. */
|
|
1064
|
+
if (size > 1 && /[\u3040-\u9fff\uac00-\ud7af]/.test(chunk)) {
|
|
1065
|
+
await this.input('Input.insertText', { text: chunk })
|
|
1066
|
+
} else {
|
|
1067
|
+
for (const one of Array.from(chunk)) {
|
|
1068
|
+
await this.input('Input.dispatchKeyEvent', {
|
|
1069
|
+
type: 'keyDown', // ui-copy-ignore
|
|
1070
|
+
key: one, // ui-copy-ignore
|
|
1071
|
+
text: one, // ui-copy-ignore
|
|
1072
|
+
unmodifiedText: one, // ui-copy-ignore
|
|
1073
|
+
windowsVirtualKeyCode: 0,
|
|
1074
|
+
nativeVirtualKeyCode: 0,
|
|
1075
|
+
}).catch(() => {})
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
at += size
|
|
1079
|
+
const last = chars[at - 1] ?? ''
|
|
1080
|
+
/* Stop longer after a full stop or a comma, the way a person does. */
|
|
1081
|
+
const pause = /[。!?,、;:\n.,!?;:]/.test(last) ? gap() * 2.2 + 90 : gap()
|
|
1082
|
+
await wait(at < chars.length ? pause : 0)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
752
1086
|
/** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
|
|
753
1087
|
async insertText(text) {
|
|
754
1088
|
this.touched = Date.now()
|
|
@@ -900,8 +1234,15 @@ export class Sidecar {
|
|
|
900
1234
|
constructor(options = {}) {
|
|
901
1235
|
this.logger = options.logger
|
|
902
1236
|
this.child = null
|
|
1237
|
+
this.stopping = false
|
|
903
1238
|
this.cdp = null
|
|
904
1239
|
this.page = null
|
|
1240
|
+
/* One tab per chat: a second chat opening an address gets a tab of its
|
|
1241
|
+
own, so neither can navigate the other's page away. */
|
|
1242
|
+
this.pages = new Map()
|
|
1243
|
+
/* A tab being opened for a chat: a second call for the same chat waits for
|
|
1244
|
+
it instead of opening a twin. */
|
|
1245
|
+
this.opening = new Map()
|
|
905
1246
|
/* A measurement or a test may want a profile of its own; the person's
|
|
906
1247
|
browser keeps the default one. */
|
|
907
1248
|
this.profile = String(options.profile ?? 'default')
|
|
@@ -971,10 +1312,159 @@ export class Sidecar {
|
|
|
971
1312
|
}
|
|
972
1313
|
|
|
973
1314
|
/**
|
|
974
|
-
*
|
|
1315
|
+
* The tab a chat drives. A named chat gets a tab of its own, made once and
|
|
1316
|
+
* kept; an unnamed caller (a test, a screenshot script) drives the first tab.
|
|
1317
|
+
* @param {string} session - chat id, empty for the default tab.
|
|
1318
|
+
* @returns {SidecarPage | null} the tab, if the browser is up.
|
|
1319
|
+
*/
|
|
1320
|
+
pageFor(session) {
|
|
1321
|
+
const id = String(session ?? '')
|
|
1322
|
+
if (id === '') return this.page
|
|
1323
|
+
const mine = this.pages.get(id)
|
|
1324
|
+
if (mine !== undefined && mine.cdp?.closed !== true) return mine
|
|
1325
|
+
if (mine !== undefined) this.pages.delete(id)
|
|
1326
|
+
return null
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Keep a chat's tab address on disk, so a reload can bring the page back.
|
|
1331
|
+
* @param {string} session - chat id.
|
|
1332
|
+
* @param {string} url - address that chat's tab is showing.
|
|
1333
|
+
*/
|
|
1334
|
+
rememberTab(session, url) {
|
|
1335
|
+
const id = String(session ?? '')
|
|
1336
|
+
const address = String(url ?? '')
|
|
1337
|
+
/* Only chats, and only addresses: a stray key from a probe or an older run
|
|
1338
|
+
must not sit in this file pretending to be a chat that can be restored. */
|
|
1339
|
+
if (!/^session-/.test(id) || address === '' || address === 'about:blank') return
|
|
1340
|
+
let saved = {}
|
|
1341
|
+
try {
|
|
1342
|
+
saved = JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}
|
|
1343
|
+
} catch {
|
|
1344
|
+
saved = {}
|
|
1345
|
+
}
|
|
1346
|
+
if (saved[id] === address) return
|
|
1347
|
+
saved[id] = address
|
|
1348
|
+
try {
|
|
1349
|
+
mkdirSync(tabsDir, { recursive: true })
|
|
1350
|
+
writeFileSync(tabsFile, JSON.stringify(saved))
|
|
1351
|
+
} catch {
|
|
1352
|
+
/* a browser that cannot write its note still works */
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* @param {string} session - a chat id.
|
|
1358
|
+
* @returns {string} the address that chat last had, or '' when it never had one.
|
|
1359
|
+
*/
|
|
1360
|
+
rememberedTab(session) {
|
|
1361
|
+
const id = String(session ?? '')
|
|
1362
|
+
if (!/^session-/.test(id)) return ''
|
|
1363
|
+
try {
|
|
1364
|
+
const saved = JSON.parse(readFileSync(tabsFile, 'utf8'))
|
|
1365
|
+
return String(saved?.[id] ?? '')
|
|
1366
|
+
} catch {
|
|
1367
|
+
return ''
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Give a chat its own tab, on the same browser: logins are shared, addresses
|
|
1373
|
+
* are not.
|
|
1374
|
+
* @param {string} session - chat id.
|
|
1375
|
+
* @param {string} [url] - address to open in it.
|
|
1376
|
+
* @returns {Promise<SidecarPage>} its tab.
|
|
1377
|
+
*/
|
|
1378
|
+
async tabFor(session, url) {
|
|
1379
|
+
const id = String(session ?? '')
|
|
1380
|
+
const had = this.pageFor(id)
|
|
1381
|
+
if (had !== null) {
|
|
1382
|
+
if (url !== undefined && url !== '') await had.navigate(url)
|
|
1383
|
+
return had
|
|
1384
|
+
}
|
|
1385
|
+
/* Two calls that start together are one chat, and a chat has one tab: the
|
|
1386
|
+
second waits for the first tab instead of opening a twin nobody holds. */
|
|
1387
|
+
const underway = this.opening?.get(id)
|
|
1388
|
+
if (underway !== undefined) {
|
|
1389
|
+
const page = await underway
|
|
1390
|
+
const want = String(url ?? '')
|
|
1391
|
+
if (want !== '' && String(page.url ?? '') !== want) await page.navigate(want)
|
|
1392
|
+
return page
|
|
1393
|
+
}
|
|
1394
|
+
const work = this.openTab(id, url).finally(() => {
|
|
1395
|
+
this.opening?.delete(id)
|
|
1396
|
+
})
|
|
1397
|
+
this.opening?.set(id, work)
|
|
1398
|
+
return work
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* @param {string} id - chat id.
|
|
1403
|
+
* @param {string} [url] - address to open in it.
|
|
1404
|
+
* @returns {Promise<SidecarPage>} a new tab for that chat.
|
|
1405
|
+
*/
|
|
1406
|
+
async openTab(id, url) {
|
|
1407
|
+
const had = this.pageFor(id)
|
|
1408
|
+
if (had !== null) {
|
|
1409
|
+
if (url !== undefined && url !== '') await had.navigate(url)
|
|
1410
|
+
return had
|
|
1411
|
+
}
|
|
1412
|
+
if (this.page === null) await this.ensure()
|
|
1413
|
+
const port = this.page?.port ?? 0 // ui-copy-ignore
|
|
1414
|
+
if (port === 0) throw new Error('sidecar: no browser to open a tab in') // ui-copy-ignore
|
|
1415
|
+
/* A chat that was given a tab without an address keeps looking at what the
|
|
1416
|
+
browser was already showing, rather than at a blank page nobody asked for. */
|
|
1417
|
+
const remembered = this.rememberedTab(id)
|
|
1418
|
+
const address = url === undefined || url === '' ? String(remembered !== '' ? remembered : this.page?.lastUrl ?? this.lastUrl ?? 'about:blank') : String(url)
|
|
1419
|
+
const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
|
|
1420
|
+
if (!created.ok) throw new Error('sidecar: could not open a tab')
|
|
1421
|
+
const target = await created.json()
|
|
1422
|
+
if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: new tab has no socket')
|
|
1423
|
+
const cdp = new Cdp(target.webSocketDebuggerUrl)
|
|
1424
|
+
await cdp.ready
|
|
1425
|
+
const page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
|
|
1426
|
+
this.pages.set(id, page)
|
|
1427
|
+
this.touch()
|
|
1428
|
+
this.rememberTab(id, address)
|
|
1429
|
+
return page
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* @param {{profile?: string, headless?: boolean, url?: string, session?: string}} [options] - how to start.
|
|
975
1434
|
* @returns {Promise<SidecarPage>} the page being driven.
|
|
976
1435
|
*/
|
|
977
1436
|
async ensure(options = {}) {
|
|
1437
|
+
const page = await this.ensurePage(options)
|
|
1438
|
+
const id = String(options.session ?? '')
|
|
1439
|
+
if (id === '') return page
|
|
1440
|
+
const mine = this.pageFor(id)
|
|
1441
|
+
if (mine !== null) {
|
|
1442
|
+
/* A chat's tab can die on its own: a crash, a browser another call
|
|
1443
|
+
restarted, a tab closed by hand. Handing that corpse back leaves the
|
|
1444
|
+
mirror on a blank picture forever, so it has to answer before it is
|
|
1445
|
+
trusted again. */
|
|
1446
|
+
const alive = await mine.ping()
|
|
1447
|
+
if (alive.ok) {
|
|
1448
|
+
/* A blank tab after a restart is not where this chat was: the address it
|
|
1449
|
+
last had is on disk, and that is the one worth returning to. */
|
|
1450
|
+
if (String(mine.url ?? '') === 'about:blank') {
|
|
1451
|
+
const back = this.rememberedTab(id)
|
|
1452
|
+
if (back !== '') await mine.navigate(back)
|
|
1453
|
+
}
|
|
1454
|
+
const wanted = String(options.url ?? '')
|
|
1455
|
+
if (wanted !== '' && String(mine.url) !== wanted) await mine.navigate(wanted)
|
|
1456
|
+
this.rememberTab(id, String(mine.url ?? wanted))
|
|
1457
|
+
return mine
|
|
1458
|
+
}
|
|
1459
|
+
this.pages.delete(id)
|
|
1460
|
+
return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
|
|
1461
|
+
}
|
|
1462
|
+
/* A new tab for a chat that had one before opens where that chat was: a
|
|
1463
|
+
blank mirror is not a place, and the first call should not land on one. */
|
|
1464
|
+
return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async ensurePage(options = {}) {
|
|
978
1468
|
if (options.profile !== undefined) this.profile = options.profile
|
|
979
1469
|
const wanted = options.headless === undefined ? this.headless : options.headless !== false
|
|
980
1470
|
const modeChanged = wanted !== this.headless
|
|
@@ -1011,6 +1501,12 @@ export class Sidecar {
|
|
|
1011
1501
|
}
|
|
1012
1502
|
}
|
|
1013
1503
|
this.touch()
|
|
1504
|
+
const id = String(options.session ?? '')
|
|
1505
|
+
if (id !== '') {
|
|
1506
|
+
/* Another chat may be looking at another tab: this one gets its own. */
|
|
1507
|
+
const mine = await this.tabFor(id, options.url)
|
|
1508
|
+
return mine
|
|
1509
|
+
}
|
|
1014
1510
|
if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
|
|
1015
1511
|
return this.page
|
|
1016
1512
|
}
|
|
@@ -1038,18 +1534,28 @@ export class Sidecar {
|
|
|
1038
1534
|
/** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
|
|
1039
1535
|
async launch(url) {
|
|
1040
1536
|
const dir = profileDir(this.profile)
|
|
1537
|
+
/* A profile whose cookie store went missing would look signed out at the
|
|
1538
|
+
next step even though the person never signed out, so the working copy
|
|
1539
|
+
is put back before the browser is asked to read it. */
|
|
1540
|
+
restoreLogin(dir)
|
|
1041
1541
|
let port = await this.runningPort(dir) // ui-copy-ignore
|
|
1042
1542
|
if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
|
|
1043
1543
|
const ws = await this.pageSocket(port) // ui-copy-ignore
|
|
1044
1544
|
const cdp = new Cdp(ws)
|
|
1045
1545
|
await cdp.ready
|
|
1046
1546
|
this.cdp = cdp
|
|
1047
|
-
this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
|
|
1547
|
+
this.page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
|
|
1048
1548
|
if (url !== undefined && url !== '') this.lastUrl = String(url)
|
|
1049
1549
|
await this.restoreSessionCookies()
|
|
1050
1550
|
await this.page.cast()
|
|
1051
1551
|
if (url !== undefined && url !== '') await this.page.navigate(url)
|
|
1052
1552
|
this.touch()
|
|
1553
|
+
/* Sign-ins outlive this browser: what works now is worth a copy, taken
|
|
1554
|
+
while the cookies are known good rather than after they are lost. */
|
|
1555
|
+
snapshotLogin(dir)
|
|
1556
|
+
this.backup?.()
|
|
1557
|
+
this.backup = setInterval(() => snapshotLogin(dir), 10 * 60_000)
|
|
1558
|
+
this.backup.unref?.()
|
|
1053
1559
|
this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
|
|
1054
1560
|
return this.page
|
|
1055
1561
|
}
|
|
@@ -1113,11 +1619,25 @@ export class Sidecar {
|
|
|
1113
1619
|
'--password-store=basic',
|
|
1114
1620
|
'--lang=zh-CN',
|
|
1115
1621
|
`--window-size=${VIEW.width},${VIEW.height}`,
|
|
1622
|
+
/* A windowless build says "HeadlessChrome" in every request, and a site
|
|
1623
|
+
that checks for it answers the whole browser with a parked page. The
|
|
1624
|
+
browser is windowless, not a robot: say what a person's Chrome says. */
|
|
1625
|
+
`--user-agent=${DESKTOP_UA}`,
|
|
1626
|
+
/* A site that refuses a headless build still gets a window, but never on
|
|
1627
|
+
the desktop: the mirror in the column is the only thing to look at. */
|
|
1628
|
+
'--window-position=-3000,-3000',
|
|
1116
1629
|
'about:blank',
|
|
1117
1630
|
]
|
|
1118
1631
|
if (this.headless) args.unshift('--headless=new')
|
|
1632
|
+
/* A browser started again after a stop is a new one: its death is news
|
|
1633
|
+
again, and the flag left behind by the stop must not silence it. */
|
|
1634
|
+
this.stopping = false
|
|
1119
1635
|
const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
|
|
1120
|
-
|
|
1636
|
+
const born = Date.now()
|
|
1637
|
+
child.on('exit', (code, signal) => {
|
|
1638
|
+
/* A browser that ends without being asked is the one case nobody can see:
|
|
1639
|
+
Chrome's own stderr goes nowhere, so leave a line behind. */
|
|
1640
|
+
noteDeath({ pid: child.pid, code, signal, profile: this.profile, asked: this.stopping === true || Date.now() - born < 3000 })
|
|
1121
1641
|
if (this.child === child) {
|
|
1122
1642
|
this.child = null
|
|
1123
1643
|
this.cdp?.close()
|
|
@@ -1149,8 +1669,9 @@ export class Sidecar {
|
|
|
1149
1669
|
* The browser's pages, in the order the browser lists them.
|
|
1150
1670
|
* @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
|
|
1151
1671
|
*/
|
|
1152
|
-
async tabs() {
|
|
1153
|
-
|
|
1672
|
+
async tabs(session = '') {
|
|
1673
|
+
const page = this.pageFor(session) ?? this.page
|
|
1674
|
+
return page === null ? [] : page.tabs()
|
|
1154
1675
|
}
|
|
1155
1676
|
|
|
1156
1677
|
/**
|
|
@@ -1158,15 +1679,47 @@ export class Sidecar {
|
|
|
1158
1679
|
* @param {number} index - a row from `tabs()`.
|
|
1159
1680
|
* @returns {Promise<SidecarPage>} the page now being driven.
|
|
1160
1681
|
*/
|
|
1161
|
-
|
|
1162
|
-
|
|
1682
|
+
/**
|
|
1683
|
+
* Tells a page which targets belong to other chats, so a popup lookup cannot
|
|
1684
|
+
* pick somebody else's tab.
|
|
1685
|
+
* @param {SidecarPage} page - a page this browser drives.
|
|
1686
|
+
* @returns {SidecarPage} the same page.
|
|
1687
|
+
*/
|
|
1688
|
+
bind(page) {
|
|
1689
|
+
page.siblings = () =>
|
|
1690
|
+
[...this.pages.values(), this.page]
|
|
1691
|
+
.filter((other) => other !== null && other !== undefined && other !== page)
|
|
1692
|
+
.map((other) => String(other.targetId ?? ''))
|
|
1693
|
+
.filter((id) => id !== '')
|
|
1694
|
+
return page
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async useTab(index, session = '') {
|
|
1698
|
+
const list = await this.tabs(session)
|
|
1163
1699
|
const tab = list[Number(index)]
|
|
1164
1700
|
if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
|
|
1701
|
+
const owned = String(session ?? '') !== ''
|
|
1702
|
+
const mine = owned ? this.pageFor(session) : null
|
|
1703
|
+
if (tab.current && mine !== null) return mine
|
|
1704
|
+
if (tab.current && !owned) return this.page
|
|
1165
1705
|
if (tab.current) return this.page
|
|
1166
|
-
const was = this.page
|
|
1706
|
+
const was = mine ?? this.page
|
|
1707
|
+
/* A chat that owns a tab keeps owning one: switching moves the chat, not
|
|
1708
|
+
the browser's first tab that a different chat may be driving. */
|
|
1709
|
+
if (mine !== null) {
|
|
1710
|
+
const cdp = new Cdp(tab.webSocketUrl)
|
|
1711
|
+
await cdp.ready
|
|
1712
|
+
const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port).start()) // ui-copy-ignore
|
|
1713
|
+
this.pages.set(String(session), page)
|
|
1714
|
+
mine.cdp.send('Page.stopScreencast').catch(() => {})
|
|
1715
|
+
mine.cdp.close()
|
|
1716
|
+
await page.cast()
|
|
1717
|
+
this.touch()
|
|
1718
|
+
return page
|
|
1719
|
+
}
|
|
1167
1720
|
const cdp = new Cdp(tab.webSocketUrl)
|
|
1168
1721
|
await cdp.ready
|
|
1169
|
-
const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
|
|
1722
|
+
const page = await this.bind(await new SidecarPage(cdp, this.profile, was.port).start()) // ui-copy-ignore
|
|
1170
1723
|
this.cdp = cdp
|
|
1171
1724
|
this.page = page
|
|
1172
1725
|
this.lastUrl = tab.url
|
|
@@ -1181,18 +1734,25 @@ export class Sidecar {
|
|
|
1181
1734
|
* Close a tab; the mirror moves to a neighbour when the closed one was shown.
|
|
1182
1735
|
* @param {number} index - a row from `tabs()`.
|
|
1183
1736
|
*/
|
|
1184
|
-
async closeTab(index) {
|
|
1185
|
-
const list = await this.tabs()
|
|
1737
|
+
async closeTab(index, session = '') {
|
|
1738
|
+
const list = await this.tabs(session)
|
|
1186
1739
|
const tab = list[Number(index)]
|
|
1187
1740
|
if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
|
|
1188
|
-
|
|
1741
|
+
const mine = String(session ?? '') === '' ? null : this.pageFor(session)
|
|
1742
|
+
let port = (mine ?? this.page)?.port ?? 0 // ui-copy-ignore
|
|
1189
1743
|
let alone = true
|
|
1190
1744
|
if (tab.current) {
|
|
1191
1745
|
const other = list.findIndex((item) => !item.current)
|
|
1192
1746
|
alone = other < 0
|
|
1193
|
-
if (!alone) await this.useTab(other)
|
|
1747
|
+
if (!alone) await this.useTab(other, session)
|
|
1194
1748
|
}
|
|
1195
1749
|
if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
|
|
1750
|
+
if (alone && mine !== null) {
|
|
1751
|
+
/* The chat still needs somewhere to go: a fresh tab in the same browser. */
|
|
1752
|
+
this.pages.delete(String(session))
|
|
1753
|
+
await this.tabFor(String(session), 'about:blank')
|
|
1754
|
+
return
|
|
1755
|
+
}
|
|
1196
1756
|
if (alone) {
|
|
1197
1757
|
await this.shutdown()
|
|
1198
1758
|
await this.ensure()
|
|
@@ -1210,7 +1770,7 @@ export class Sidecar {
|
|
|
1210
1770
|
const cdp = new Cdp(target.webSocketDebuggerUrl)
|
|
1211
1771
|
await cdp.ready
|
|
1212
1772
|
this.cdp = cdp
|
|
1213
|
-
this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
|
|
1773
|
+
this.page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
|
|
1214
1774
|
await this.page.cast()
|
|
1215
1775
|
this.touch()
|
|
1216
1776
|
return this.page
|
|
@@ -1241,8 +1801,8 @@ export class Sidecar {
|
|
|
1241
1801
|
* @param {(frame: any) => void} sink - called per frame.
|
|
1242
1802
|
* @returns {Promise<() => void>} detach.
|
|
1243
1803
|
*/
|
|
1244
|
-
async watch(sink) {
|
|
1245
|
-
const page = await this.ensure()
|
|
1804
|
+
async watch(sink, session = '') {
|
|
1805
|
+
const page = await this.ensure({ session })
|
|
1246
1806
|
page.frames.add(sink)
|
|
1247
1807
|
page.pointerSinks.add(sink)
|
|
1248
1808
|
await page.cast()
|
|
@@ -1259,7 +1819,9 @@ export class Sidecar {
|
|
|
1259
1819
|
this.idle = null
|
|
1260
1820
|
/* Someone is watching the mirror: the page stays up, and the clock starts
|
|
1261
1821
|
again so the browser is closed only once it is truly unused. */
|
|
1262
|
-
|
|
1822
|
+
let watched = this.page?.frames.size ?? 0
|
|
1823
|
+
for (const page of this.pages.values()) watched += page.frames?.size ?? 0
|
|
1824
|
+
if (watched > 0) {
|
|
1263
1825
|
this.touch()
|
|
1264
1826
|
return
|
|
1265
1827
|
}
|
|
@@ -1269,20 +1831,104 @@ export class Sidecar {
|
|
|
1269
1831
|
}
|
|
1270
1832
|
|
|
1271
1833
|
/** @returns {Promise<void>} resolves once Chrome is gone. */
|
|
1834
|
+
/**
|
|
1835
|
+
* Leaves the browser running for whoever loads this plugin next, and puts the
|
|
1836
|
+
* sign-in on disk first. A plugin reload used to close Chrome, so the column
|
|
1837
|
+
* lost the page and every sign-in paid for a fresh launch; the browser is
|
|
1838
|
+
* meant to outlive the driver. A detached watcher closes the browser once
|
|
1839
|
+
* this app is really gone, so nothing is left behind.
|
|
1840
|
+
* @returns {Promise<void>} done, with the browser still up.
|
|
1841
|
+
*/
|
|
1842
|
+
async handoff() {
|
|
1843
|
+
this.stopping = true
|
|
1844
|
+
if (this.page !== null) await this.saveSessionCookies()
|
|
1845
|
+
if (this.backup !== undefined) clearInterval(this.backup)
|
|
1846
|
+
this.backup = undefined
|
|
1847
|
+
try {
|
|
1848
|
+
snapshotLogin(profileDir(this.profile))
|
|
1849
|
+
} catch {
|
|
1850
|
+
/* a profile that cannot be copied is not a reason to drop the browser */
|
|
1851
|
+
}
|
|
1852
|
+
const chrome = Number(this.child?.pid ?? 0)
|
|
1853
|
+
if (chrome === 0) return
|
|
1854
|
+
/* One watcher per application run, watching the application rather than
|
|
1855
|
+
this instance: a plugin reload is not the app closing. */
|
|
1856
|
+
/* Read at call time, so a test can point the note somewhere harmless. */
|
|
1857
|
+
const dir = process.env.DSH_INNER_BROWSER_STATE_DIR || tabsDir
|
|
1858
|
+
const app = appPid()
|
|
1859
|
+
/* A watcher may only watch the application: one keyed to the node instance
|
|
1860
|
+
a reload replaces closes the browser at that reload. When the walk cannot
|
|
1861
|
+
name the app, no watcher is armed at all rather than a wrong one. */
|
|
1862
|
+
if (app === 0 || looksLikeApp(commandOf(app)) === false) return
|
|
1863
|
+
try {
|
|
1864
|
+
const said = JSON.parse(readFileSync(watcherFile(dir), 'utf8'))
|
|
1865
|
+
const watching = Number(said?.appPid ?? 0)
|
|
1866
|
+
const alive = (() => {
|
|
1867
|
+
try {
|
|
1868
|
+
process.kill(Number(said?.pid ?? 0), 0)
|
|
1869
|
+
return true
|
|
1870
|
+
} catch {
|
|
1871
|
+
return false
|
|
1872
|
+
}
|
|
1873
|
+
})()
|
|
1874
|
+
/* Same run and same browser: the watcher already covers it. A browser
|
|
1875
|
+
that was restarted needs the note pointed at the new pid, or the app
|
|
1876
|
+
going away would leave the new one running. */
|
|
1877
|
+
if (alive && Number(said?.chrome ?? 0) === chrome && watching === Number(said?.appPid) && (app === 0 || watching === app)) return
|
|
1878
|
+
if (alive) {
|
|
1879
|
+
try {
|
|
1880
|
+
process.kill(Number(said?.pid ?? 0))
|
|
1881
|
+
} catch {
|
|
1882
|
+
/* a watcher that already left needs no goodbye */
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
} catch {
|
|
1886
|
+
/* no watcher yet, or one whose note is unreadable */
|
|
1887
|
+
}
|
|
1888
|
+
/* Detached and unref'd: the app must not wait for this, and it must outlive
|
|
1889
|
+
the plugin instance it was started for. Windows has no `/bin/sh`, so there
|
|
1890
|
+
the lifetime rides on the browser itself: `taskkill /T` already ends the
|
|
1891
|
+
whole tree when this process is gone, and spawning a POSIX watcher here
|
|
1892
|
+
would only throw ENOENT and be swallowed. */
|
|
1893
|
+
if (process.platform === 'win32') return
|
|
1894
|
+
try {
|
|
1895
|
+
const guard = spawn('/bin/sh', ['-c', `while kill -0 ${app} 2>/dev/null; do sleep 5; done; kill ${chrome} 2>/dev/null`], {
|
|
1896
|
+
detached: true,
|
|
1897
|
+
stdio: 'ignore',
|
|
1898
|
+
})
|
|
1899
|
+
guard.unref()
|
|
1900
|
+
mkdirSync(dir, { recursive: true })
|
|
1901
|
+
writeFileSync(watcherFile(dir), JSON.stringify({ appPid: app, pid: guard.pid ?? 0, chrome, at: Date.now() }))
|
|
1902
|
+
} catch {
|
|
1903
|
+
/* a watcher that cannot start only means the browser outlives the app */
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1272
1907
|
async shutdown() {
|
|
1273
1908
|
/* Ask Chrome to close first: a browser that is killed outright may lose the
|
|
1274
1909
|
last minute of cookies, and keeping the person signed in is the point. */
|
|
1275
1910
|
if (this.page !== null) await this.saveSessionCookies()
|
|
1911
|
+
if (this.backup !== undefined) clearInterval(this.backup)
|
|
1912
|
+
this.backup = undefined
|
|
1913
|
+
snapshotLogin(profileDir(this.profile))
|
|
1276
1914
|
if (this.cdp !== null) {
|
|
1277
1915
|
await this.cdp.send('Browser.close').catch(() => {})
|
|
1278
1916
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
1279
1917
|
}
|
|
1280
1918
|
this.page = null
|
|
1919
|
+
this.pages.clear()
|
|
1281
1920
|
this.cdp?.close()
|
|
1282
1921
|
this.cdp = null
|
|
1283
1922
|
if (this.child !== null) {
|
|
1284
1923
|
try {
|
|
1285
|
-
|
|
1924
|
+
/* A browser on Windows is a process tree: terminating the launcher alone
|
|
1925
|
+
leaves renderers and the profile lock behind. Browser.close above is
|
|
1926
|
+
the graceful path; this is the one for a browser that ignored it. */
|
|
1927
|
+
if (process.platform === 'win32' && Number.isInteger(this.child.pid)) {
|
|
1928
|
+
spawnSync('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
1929
|
+
} else {
|
|
1930
|
+
this.child.kill('SIGTERM')
|
|
1931
|
+
}
|
|
1286
1932
|
} catch {
|
|
1287
1933
|
/* already gone */
|
|
1288
1934
|
}
|
|
@@ -1319,9 +1965,20 @@ export class Sidecar {
|
|
|
1319
1965
|
return
|
|
1320
1966
|
}
|
|
1321
1967
|
if (!Array.isArray(jar)) return
|
|
1968
|
+
/* A cookie the browser already holds is newer than the note: the site may
|
|
1969
|
+
have rotated its session id since the note was written, and putting the
|
|
1970
|
+
old value back is exactly how a working sign-in turns into a logout. */
|
|
1971
|
+
let live = []
|
|
1972
|
+
try {
|
|
1973
|
+
live = await this.page.cookies()
|
|
1974
|
+
} catch {
|
|
1975
|
+
live = []
|
|
1976
|
+
}
|
|
1977
|
+
const held = new Set(live.map((c) => `${String(c.domain ?? '').replace(/^\./, '')}|${String(c.path ?? '/')}|${String(c.name ?? '')}`))
|
|
1322
1978
|
for (const cookie of jar.slice(0, 200)) {
|
|
1323
1979
|
const domain = String(cookie?.domain ?? '').replace(/^\./, '')
|
|
1324
1980
|
if (domain === '') continue
|
|
1981
|
+
if (held.has(`${domain}|${String(cookie?.path ?? '/')}|${String(cookie.name)}`)) continue
|
|
1325
1982
|
try {
|
|
1326
1983
|
await this.page.setCookie({
|
|
1327
1984
|
name: String(cookie.name),
|
|
@@ -1339,6 +1996,22 @@ export class Sidecar {
|
|
|
1339
1996
|
}
|
|
1340
1997
|
|
|
1341
1998
|
/** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
|
|
1999
|
+
/**
|
|
2000
|
+
* What the browser holds for one chat, when the caller names it.
|
|
2001
|
+
* @param {string} [session] - chat id.
|
|
2002
|
+
* @returns {{url: string, title: string, viewers: number, alive: boolean, remembered: string}} its page.
|
|
2003
|
+
*/
|
|
2004
|
+
async chatState(session = '') {
|
|
2005
|
+
const id = String(session ?? '')
|
|
2006
|
+
/* A chat whose tab is gone still knows where it was: that address is what
|
|
2007
|
+
the pane offers to bring back, so a lost picture is one click, not a
|
|
2008
|
+
question about which site it used to be. */
|
|
2009
|
+
const remembered = this.rememberedTab(id)
|
|
2010
|
+
const page = id === '' ? null : this.pageFor(id)
|
|
2011
|
+
if (page === null || page === undefined) return { url: '', title: '', viewers: 0, alive: false, remembered } // ui-copy-ignore
|
|
2012
|
+
return { url: String(page.url ?? ''), title: String(page.title ?? ''), viewers: page.frames?.size ?? 0, alive: true, remembered } // ui-copy-ignore
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1342
2015
|
async state() {
|
|
1343
2016
|
const profiles = (() => {
|
|
1344
2017
|
try {
|
|
@@ -1352,8 +2025,12 @@ export class Sidecar {
|
|
|
1352
2025
|
profile: this.profile, // ui-copy-ignore
|
|
1353
2026
|
headless: this.headless, // ui-copy-ignore
|
|
1354
2027
|
url: this.page?.url ?? '', // ui-copy-ignore
|
|
2028
|
+
pages: this.pages.size,
|
|
1355
2029
|
title: this.page?.title ?? '',
|
|
1356
2030
|
viewers: this.page?.frames.size ?? 0,
|
|
2031
|
+
/* Everyone watching any chat's picture: the pane in the right column is
|
|
2032
|
+
one of these, so 0 means no column is attached right now. */
|
|
2033
|
+
watchers: [...this.pages.values()].reduce((sum, page) => sum + (page.frames?.size ?? 0), 0),
|
|
1357
2034
|
frames: this.page?.seq ?? 0,
|
|
1358
2035
|
crashed: this.page?.crashed === true,
|
|
1359
2036
|
pointer: this.page?.pointer ?? null,
|