dsh-inner-browser-use 0.11.44 → 0.18.0
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 +112 -75
- package/README.zh.md +90 -45
- package/client.js +473 -3276
- package/docs/ARCHITECTURE.md +196 -150
- package/docs/BENCH.md +234 -0
- package/docs/COMPATIBILITY.md +72 -102
- package/docs/ROADMAP.md +176 -0
- package/docs/VERIFY.md +131 -415
- package/index.js +230 -1117
- package/locale/en.json +11 -22
- package/locale/zh.json +11 -22
- package/package.json +12 -5
- package/read.js +718 -0
- package/scripts/doctor.mjs +89 -0
- package/sidecar.js +1364 -0
- package/tools.js +1652 -0
- package/docs/upstream/cookie-persistence.md +0 -52
package/sidecar.js
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidecar browser: a real Chrome that the Host owns. // ui-copy-ignore
|
|
3
|
+
*
|
|
4
|
+
* A separate process with its own on-disk profile: a login survives a restart,
|
|
5
|
+
* and a page keeps working while the person looks somewhere else. The column
|
|
6
|
+
* shows it as a screencast, so the tab is a viewer, not the browser.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Chrome builds this machine may have, most preferred first. */
|
|
14
|
+
const CHROME_BINARIES = [
|
|
15
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
16
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
17
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
18
|
+
'/usr/bin/google-chrome',
|
|
19
|
+
'/usr/bin/chromium',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** Viewport the screencast and the pointer share. Nothing here is a user-agent detail. */
|
|
23
|
+
export const VIEW = { width: 1440, height: 900 }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pixels the mirror actually sends. The viewport is never downscaled: a
|
|
27
|
+
* half-size JPEG turns 12px body text into mush in a column this narrow, and
|
|
28
|
+
* the sharpness is what makes the mirror feel like the page. Quality stops
|
|
29
|
+
* short of lossless so a moving picture still arrives on the next frame.
|
|
30
|
+
*/
|
|
31
|
+
const MIRROR = { width: VIEW.width, height: VIEW.height }
|
|
32
|
+
|
|
33
|
+
/** Frames are dropped rather than queued: a lagging viewer must not slow the page. */
|
|
34
|
+
const MAX_INFLIGHT_FRAMES = 2
|
|
35
|
+
|
|
36
|
+
/** Idle lifetime, so a forgotten sidecar does not sit on a login forever. */
|
|
37
|
+
const IDLE_MS = 15 * 60_000
|
|
38
|
+
|
|
39
|
+
/** @param {number} ms - how long to wait. @returns {Promise<void>} resolves after it. */
|
|
40
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
41
|
+
|
|
42
|
+
/** @param {string} path - candidate. @returns {string | undefined} the one that exists. */
|
|
43
|
+
function firstBinary() {
|
|
44
|
+
return CHROME_BINARIES.find((path) => existsSync(path))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Root of everything this plugin keeps for the sidecar. */
|
|
48
|
+
function rootDir() {
|
|
49
|
+
const dir = join(homedir(), '.dsh', 'inner-browser-use', 'chrome')
|
|
50
|
+
mkdirSync(dir, { recursive: true })
|
|
51
|
+
return dir
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string} name - profile name. @returns {string} its directory. */
|
|
55
|
+
function profileDir(name) {
|
|
56
|
+
const dir = join(rootDir(), String(name || 'default').replace(/[^\w.-]/g, '_'))
|
|
57
|
+
mkdirSync(dir, { recursive: true })
|
|
58
|
+
return dir
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A minimal CDP session over one target's WebSocket.
|
|
63
|
+
*
|
|
64
|
+
* `flatten` sessions are not needed: the sidecar owns exactly one page, and a
|
|
65
|
+
* page-level socket addresses it without ids to get wrong.
|
|
66
|
+
*/
|
|
67
|
+
class Cdp {
|
|
68
|
+
/** @param {string} url - the target's `webSocketDebuggerUrl`. */
|
|
69
|
+
constructor(url) {
|
|
70
|
+
this.url = url
|
|
71
|
+
this.next = 1
|
|
72
|
+
this.pending = new Map()
|
|
73
|
+
this.listeners = new Set()
|
|
74
|
+
this.closed = false
|
|
75
|
+
this.socket = new WebSocket(url)
|
|
76
|
+
this.ready = new Promise((resolve, reject) => {
|
|
77
|
+
this.socket.addEventListener('open', () => resolve(this))
|
|
78
|
+
this.socket.addEventListener('error', () => reject(new Error('sidecar: CDP socket failed')))
|
|
79
|
+
})
|
|
80
|
+
this.socket.addEventListener('message', (event) => {
|
|
81
|
+
let message
|
|
82
|
+
try {
|
|
83
|
+
message = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data))
|
|
84
|
+
} catch {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (message.id !== undefined) {
|
|
88
|
+
const entry = this.pending.get(message.id)
|
|
89
|
+
if (entry === undefined) return
|
|
90
|
+
this.pending.delete(message.id)
|
|
91
|
+
if (message.error) entry.reject(new Error(`${entry.method}: ${message.error.message}`))
|
|
92
|
+
else entry.resolve(message.result ?? {})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
for (const listener of this.listeners) listener(message)
|
|
96
|
+
})
|
|
97
|
+
this.socket.addEventListener('close', () => {
|
|
98
|
+
this.closed = true
|
|
99
|
+
for (const entry of this.pending.values()) entry.reject(new Error(`${entry.method}: sidecar closed`))
|
|
100
|
+
this.pending.clear()
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
close() {
|
|
105
|
+
this.socket.close()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} method - CDP method.
|
|
110
|
+
* @param {Record<string, unknown>} params - its parameters.
|
|
111
|
+
* @param {string} [sessionId] - a child target to run it in, for a popup or a frame Chrome runs elsewhere.
|
|
112
|
+
* @returns {Promise<any>} its result.
|
|
113
|
+
*/
|
|
114
|
+
send(method, params = {}, sessionId) {
|
|
115
|
+
if (this.closed) return Promise.reject(new Error('sidecar: CDP session is closed'))
|
|
116
|
+
const id = this.next++
|
|
117
|
+
const message = { id, method, params }
|
|
118
|
+
if (sessionId !== undefined) message.sessionId = sessionId
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
this.pending.set(id, { resolve, reject, method })
|
|
121
|
+
this.socket.send(JSON.stringify(message))
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {(message: any) => void} listener - called for every event. @returns {() => void} unsubscribe. */
|
|
126
|
+
on(listener) {
|
|
127
|
+
this.listeners.add(listener)
|
|
128
|
+
return () => this.listeners.delete(listener)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
close() {
|
|
132
|
+
try {
|
|
133
|
+
this.socket.close()
|
|
134
|
+
} catch {
|
|
135
|
+
/* a socket that is already gone needs no closing */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One page of the sidecar, with its screencast and its input.
|
|
142
|
+
*
|
|
143
|
+
* Input is real CDP input: the page sees the same events a person's trackpad
|
|
144
|
+
* produces, which is what makes sites that reject scripted clicks work.
|
|
145
|
+
*/
|
|
146
|
+
export class SidecarPage {
|
|
147
|
+
/** @param {Cdp} cdp - the page session. @param {string} profile - profile name. */
|
|
148
|
+
constructor(cdp, profile, port) { // ui-copy-ignore
|
|
149
|
+
this.cdp = cdp
|
|
150
|
+
this.profile = profile
|
|
151
|
+
this.port = port // ui-copy-ignore
|
|
152
|
+
this.epoch = 0
|
|
153
|
+
this.frames = new Set()
|
|
154
|
+
/* Pointer moves are their own, tiny message: the arrow follows the agent at
|
|
155
|
+
once instead of waiting for the next picture to be painted. */
|
|
156
|
+
this.pointerSinks = new Set()
|
|
157
|
+
this.latest = ''
|
|
158
|
+
this.seq = 0
|
|
159
|
+
/* Total JPEG bytes seen, for the bench and for noticing when a page got
|
|
160
|
+
expensive to watch. */
|
|
161
|
+
this.bytes = 0
|
|
162
|
+
this.pointer = { x: VIEW.width / 2, y: VIEW.height / 2 }
|
|
163
|
+
this.url = ''
|
|
164
|
+
this.lastUrl = ''
|
|
165
|
+
this.crashed = false
|
|
166
|
+
this.title = ''
|
|
167
|
+
this.casting = false
|
|
168
|
+
this.inflight = 0
|
|
169
|
+
this.touched = Date.now()
|
|
170
|
+
/* A dialog blocks the page until someone answers it, so it is state, not an
|
|
171
|
+
event: the driver can look at it before deciding. */
|
|
172
|
+
this.dialog = null
|
|
173
|
+
this.console = []
|
|
174
|
+
this.failed = []
|
|
175
|
+
/* What the page asked for, so a page that fills itself from addresses can
|
|
176
|
+
be understood without a screenshot or a retry. */
|
|
177
|
+
this.network = []
|
|
178
|
+
this.requests = new Map()
|
|
179
|
+
/* frame id -> the world its page script runs in. A control inside an iframe
|
|
180
|
+
is only reachable through that world; the frame list is read when the
|
|
181
|
+
read model needs it. */
|
|
182
|
+
this.contexts = new Map()
|
|
183
|
+
this.frameOrder = []
|
|
184
|
+
/* A click can open a tab. Chrome tells us the new target; the mirror then
|
|
185
|
+
follows it, because a person looking at the column expects that too. */
|
|
186
|
+
this.children = new Map()
|
|
187
|
+
this.opened = null
|
|
188
|
+
this.port = Number(port ?? 0) // ui-copy-ignore
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Record the page events that matter to a driver: dialogs, console lines and
|
|
193
|
+
* failed requests. Buffers are capped so a chatty page cannot grow memory.
|
|
194
|
+
* @param {any} message - one CDP message.
|
|
195
|
+
*/
|
|
196
|
+
watch(message) {
|
|
197
|
+
if (message.method === 'Runtime.executionContextCreated') {
|
|
198
|
+
const context = message.params?.context ?? {}
|
|
199
|
+
const aux = context.auxData ?? {}
|
|
200
|
+
if (aux.isDefault === true && typeof aux.frameId === 'string') this.contexts.set(aux.frameId, Number(context.id))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (message.method === 'Runtime.executionContextDestroyed') {
|
|
204
|
+
const gone = Number(message.params?.executionContextId)
|
|
205
|
+
for (const [frameId, contextId] of this.contexts) if (contextId === gone) this.contexts.delete(frameId)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (message.method === 'Runtime.executionContextsCleared') {
|
|
209
|
+
this.contexts.clear()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (message.method === 'Target.attachedToTarget') {
|
|
213
|
+
const params = message.params ?? {}
|
|
214
|
+
const sessionId = String(params.sessionId ?? '')
|
|
215
|
+
const info = params.targetInfo ?? {}
|
|
216
|
+
if (sessionId === '') return
|
|
217
|
+
this.children.set(sessionId, { targetId: String(info.targetId ?? ''), type: String(info.type ?? ''), url: String(info.url ?? '') })
|
|
218
|
+
/* A target that waits for the debugger must be let go at once, or the
|
|
219
|
+
tab a person just asked for never loads. */
|
|
220
|
+
void this.cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {})
|
|
221
|
+
if (String(info.type ?? '') === 'page') {
|
|
222
|
+
this.opened = { targetId: String(info.targetId ?? ''), url: String(info.url ?? '') }
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (message.method === 'Target.detachedFromTarget') {
|
|
227
|
+
this.children.delete(String(message.params?.sessionId ?? ''))
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (message.method === 'Target.targetInfoChanged') {
|
|
231
|
+
const info = message.params?.targetInfo ?? {}
|
|
232
|
+
const targetId = String(info.targetId ?? '')
|
|
233
|
+
for (const child of this.children.values()) if (child.targetId === targetId) child.url = String(info.url ?? '')
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (message.method === 'Page.javascriptDialogOpening') {
|
|
237
|
+
this.dialog = message.params ?? {}
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (message.method === 'Page.javascriptDialogClosed') {
|
|
241
|
+
this.dialog = null
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (message.method === 'Runtime.exceptionThrown') {
|
|
245
|
+
const detail = message.params?.exceptionDetails ?? {}
|
|
246
|
+
this.console.push({ type: 'error', text: String(detail.exception?.description ?? detail.text ?? '').slice(0, 300) })
|
|
247
|
+
if (this.console.length > 200) this.console.splice(0, 50)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (message.method === 'Runtime.consoleAPICalled') {
|
|
251
|
+
const params = message.params ?? {}
|
|
252
|
+
const text = (params.args ?? []).map((arg) => String(arg.value ?? arg.description ?? '')).join(' ').slice(0, 300)
|
|
253
|
+
this.console.push({ type: String(params.type ?? 'log'), text })
|
|
254
|
+
if (this.console.length > 200) this.console.splice(0, 50)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (message.method === 'Network.requestWillBeSent') {
|
|
258
|
+
const params = message.params ?? {}
|
|
259
|
+
const request = params.request ?? {}
|
|
260
|
+
const id = String(params.requestId ?? '')
|
|
261
|
+
if (id === '') return
|
|
262
|
+
this.requests.set(id, {
|
|
263
|
+
method: String(request.method ?? 'GET'),
|
|
264
|
+
url: String(request.url ?? '').slice(0, 300), // ui-copy-ignore
|
|
265
|
+
type: String(params.type ?? ''),
|
|
266
|
+
status: 0,
|
|
267
|
+
})
|
|
268
|
+
if (this.requests.size > 200) this.requests.delete(this.requests.keys().next().value)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (message.method === 'Network.responseReceived') {
|
|
272
|
+
const params = message.params ?? {}
|
|
273
|
+
const known = this.requests.get(String(params.requestId ?? ''))
|
|
274
|
+
if (known === undefined) return
|
|
275
|
+
known.status = Number(params.response?.status ?? 0)
|
|
276
|
+
this.network.push(known)
|
|
277
|
+
if (this.network.length > 120) this.network.splice(0, 40)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (message.method === 'Network.loadingFailed') {
|
|
281
|
+
const params = message.params ?? {}
|
|
282
|
+
if (params.canceled === true) return
|
|
283
|
+
this.failed.push({ error: String(params.errorText ?? ''), type: String(params.type ?? '') })
|
|
284
|
+
if (this.failed.length > 100) this.failed.splice(0, 25)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Follow a tab the page just opened: the mirror shows the new page and the
|
|
290
|
+
* popup is closed, so there is still one page to drive.
|
|
291
|
+
* @returns {Promise<string>} what happened, for the tool result.
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* The session Chrome gave a frame it runs in its own process, so a read can be
|
|
295
|
+
* sent there. Chrome names an iframe target after its frame.
|
|
296
|
+
* @param {string} frameId - a frame from the frame tree.
|
|
297
|
+
* @returns {string|undefined} the session, when one exists.
|
|
298
|
+
*/
|
|
299
|
+
childSession(frameId) {
|
|
300
|
+
for (const [sessionId, child] of this.children) if (child.targetId === frameId) return sessionId
|
|
301
|
+
return undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A tab this page opened, found through the DevTools address list. Chrome
|
|
306
|
+
* gives a page no handle on its own popups, but the list is enough to know
|
|
307
|
+
* one exists and where it went.
|
|
308
|
+
* @returns {Promise<{targetId: string, url: string}|null>} the popup.
|
|
309
|
+
*/
|
|
310
|
+
async findPopup() {
|
|
311
|
+
if (this.port <= 0) return null // ui-copy-ignore
|
|
312
|
+
const main = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
|
|
313
|
+
const list = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
|
|
314
|
+
.then((response) => (response.ok ? response.json() : []))
|
|
315
|
+
.catch(() => [])
|
|
316
|
+
const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main)
|
|
317
|
+
const popup = pages.find((item) => /^https?:|^file:/.test(String(item.url ?? '')))
|
|
318
|
+
if (popup !== undefined) return { targetId: String(popup.id), url: String(popup.url) }
|
|
319
|
+
const blank = pages[0]
|
|
320
|
+
return blank === undefined ? null : { targetId: String(blank.id), url: String(blank.url ?? '') }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The browser's pages, in the order the browser lists them. More than one
|
|
325
|
+
* exists when a site opens a window the driver did not follow.
|
|
326
|
+
* @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
|
|
327
|
+
*/
|
|
328
|
+
async tabs() {
|
|
329
|
+
const port = this.port // ui-copy-ignore
|
|
330
|
+
if (port <= 0) return [] // ui-copy-ignore
|
|
331
|
+
const list = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
|
|
332
|
+
.then((response) => (response.ok ? response.json() : []))
|
|
333
|
+
.catch(() => [])
|
|
334
|
+
const current = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
|
|
335
|
+
return (Array.isArray(list) ? list : [])
|
|
336
|
+
.filter((item) => item.type === 'page' && item.webSocketDebuggerUrl !== undefined)
|
|
337
|
+
.map((item) => ({ targetId: String(item.id), title: String(item.title ?? ''), url: String(item.url ?? ''), webSocketUrl: String(item.webSocketDebuggerUrl), current: String(item.id) === current }))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Follow a tab the page just opened: the mirror shows where the person would
|
|
342
|
+
* have ended up, and the popup is closed so there is still one page to drive.
|
|
343
|
+
* @returns {Promise<string>} what happened, for the tool result.
|
|
344
|
+
*/
|
|
345
|
+
async adoptOpened() {
|
|
346
|
+
let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
|
|
347
|
+
this.opened = null
|
|
348
|
+
if (target === null) {
|
|
349
|
+
/* The target list is written before the popup finished loading, so give a
|
|
350
|
+
click that opens one a moment to say where it is going. */
|
|
351
|
+
for (let attempt = 0; attempt < 3 && target === null; attempt += 1) {
|
|
352
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150))
|
|
353
|
+
const found = await this.findPopup()
|
|
354
|
+
if (found !== null && found.url !== '') target = found
|
|
355
|
+
else if (found !== null && attempt === 2) target = found
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (target === null) return ''
|
|
359
|
+
const url = String(target.url ?? '')
|
|
360
|
+
await this.cdp.send('Target.closeTarget', { targetId: target.targetId }).catch(() => {})
|
|
361
|
+
if (this.port > 0) await fetch(`http://127.0.0.1:${this.port}/json/close/${target.targetId}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
|
|
362
|
+
if (!/^https?:|^file:/.test(url)) return ''
|
|
363
|
+
await this.cdp.send('Page.navigate', { url }).catch(() => {})
|
|
364
|
+
return `opened ${url}`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The frames of the page, main frame first, each with the context its scripts
|
|
369
|
+
* run in. Read on demand: a page with no iframes pays one call.
|
|
370
|
+
* @returns {Promise<Array<{frameId: string, url: string, contextId?: number}>>} the frames.
|
|
371
|
+
*/
|
|
372
|
+
async frameList() {
|
|
373
|
+
const tree = await this.cdp.send('Page.getFrameTree').catch(() => null)
|
|
374
|
+
const out = []
|
|
375
|
+
const walk = (node) => {
|
|
376
|
+
if (node === null || node === undefined || typeof node !== 'object') return
|
|
377
|
+
const frameId = String(node.frame?.id ?? '')
|
|
378
|
+
if (frameId !== '') {
|
|
379
|
+
const contextId = this.contexts.get(frameId)
|
|
380
|
+
out.push({ frameId, url: String(node.frame?.url ?? ''), contextId: contextId === undefined ? undefined : Number(contextId) })
|
|
381
|
+
}
|
|
382
|
+
for (const child of node.childFrames ?? []) walk(child)
|
|
383
|
+
}
|
|
384
|
+
walk(tree?.frameTree)
|
|
385
|
+
/* A frame Chrome runs in another process is not in this tree, but it is
|
|
386
|
+
attached as its own target; list it after the tree so it gets a slot. */
|
|
387
|
+
const known = new Set(out.map((item) => item.frameId))
|
|
388
|
+
for (const [sessionId, child] of this.children) {
|
|
389
|
+
if (child.type !== 'iframe' || known.has(child.targetId)) continue
|
|
390
|
+
if (sessionId === '') continue
|
|
391
|
+
out.push({ frameId: child.targetId, url: String(child.url ?? ''), contextId: undefined })
|
|
392
|
+
known.add(child.targetId)
|
|
393
|
+
}
|
|
394
|
+
this.frameOrder = out.map((item) => item.frameId)
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Responses this page received, newest first.
|
|
400
|
+
* @returns {{method: string, url: string, type: string, status: number}[]} what it asked for.
|
|
401
|
+
*/
|
|
402
|
+
networkList() {
|
|
403
|
+
return this.network.slice(-120).reverse()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Files this browser has downloaded, newest first.
|
|
408
|
+
* @returns {{name: string, bytes: number, at: string}[]} what is on disk.
|
|
409
|
+
*/
|
|
410
|
+
downloads() {
|
|
411
|
+
const folder = this.downloadsFolder ?? downloadsDir(profileDir(this.profile))
|
|
412
|
+
let names = []
|
|
413
|
+
try {
|
|
414
|
+
names = readdirSync(folder)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return []
|
|
417
|
+
}
|
|
418
|
+
const found = []
|
|
419
|
+
for (const name of names) {
|
|
420
|
+
if (name.endsWith('.crdownload')) continue
|
|
421
|
+
const path = join(folder, name)
|
|
422
|
+
try {
|
|
423
|
+
const info = statSync(path)
|
|
424
|
+
if (!info.isFile()) continue
|
|
425
|
+
found.push({ name, bytes: info.size, at: new Date(info.mtimeMs).toISOString() })
|
|
426
|
+
} catch (error) {
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
found.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
|
431
|
+
return found.slice(0, 20)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Where the owner element of a frame sits on the page, for a frame whose own
|
|
436
|
+
* process cannot answer. The page's DOM knows the iframe element.
|
|
437
|
+
* @param {string} frameId - a frame from the frame list.
|
|
438
|
+
* @returns {Promise<{x: number, y: number}|null>} the corner, when known.
|
|
439
|
+
*/
|
|
440
|
+
async frameBox(frameId) {
|
|
441
|
+
const owner = await this.cdp.send('DOM.getFrameOwner', { frameId }).catch(() => null)
|
|
442
|
+
const backendNodeId = owner?.backendNodeId
|
|
443
|
+
if (backendNodeId === undefined) return null
|
|
444
|
+
const node = await this.cdp.send('DOM.resolveNode', { backendNodeId }).catch(() => null)
|
|
445
|
+
const objectId = node?.object?.objectId
|
|
446
|
+
if (objectId === undefined) return null
|
|
447
|
+
const said = await this.cdp.send('Runtime.callFunctionOn', {
|
|
448
|
+
objectId,
|
|
449
|
+
functionDeclaration: 'function () { const b = this.getBoundingClientRect(); return { x: Math.round(b.left), y: Math.round(b.top) } }',
|
|
450
|
+
returnByValue: true,
|
|
451
|
+
}).catch(() => null)
|
|
452
|
+
const spot = said?.result?.value
|
|
453
|
+
if (spot === null || spot === undefined || !Number.isFinite(spot.x)) return null
|
|
454
|
+
return { x: Number(spot.x), y: Number(spot.y) }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Keep one listener that fans frames out to every viewer. */
|
|
458
|
+
async start() {
|
|
459
|
+
await this.cdp.send('Network.enable').catch(() => {})
|
|
460
|
+
/* A click that downloads should leave a file, not nothing: point downloads
|
|
461
|
+
at the profile so the driver can report where they went. */
|
|
462
|
+
const folder = downloadsDir(profileDir(this.profile))
|
|
463
|
+
mkdirSync(folder, { recursive: true })
|
|
464
|
+
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
|
+
this.cdp.on((message) => {
|
|
470
|
+
this.watch(message)
|
|
471
|
+
if (message.method !== 'Page.screencastFrame') return
|
|
472
|
+
const { data, sessionId, metadata } = message.params ?? {}
|
|
473
|
+
void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
|
|
474
|
+
if (typeof data !== 'string' || data === '') return
|
|
475
|
+
this.latest = data
|
|
476
|
+
this.seq += 1
|
|
477
|
+
this.bytes += data.length
|
|
478
|
+
if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
|
|
479
|
+
if (this.inflight >= MAX_INFLIGHT_FRAMES) return
|
|
480
|
+
this.inflight += 1
|
|
481
|
+
const frame = {
|
|
482
|
+
seq: this.seq, // ui-copy-ignore
|
|
483
|
+
w: VIEW.width, // ui-copy-ignore
|
|
484
|
+
h: VIEW.height, // ui-copy-ignore
|
|
485
|
+
jpeg: data, // ui-copy-ignore
|
|
486
|
+
url: this.url, // ui-copy-ignore
|
|
487
|
+
title: this.title, // ui-copy-ignore
|
|
488
|
+
pointer: this.pointer, // ui-copy-ignore
|
|
489
|
+
}
|
|
490
|
+
for (const sink of this.frames) {
|
|
491
|
+
try {
|
|
492
|
+
sink(frame)
|
|
493
|
+
} catch {
|
|
494
|
+
/* a closed viewer is dropped by its own stream handler */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
this.inflight -= 1
|
|
498
|
+
})
|
|
499
|
+
this.cdp.on((message) => {
|
|
500
|
+
if (message.method === 'Page.frameNavigated' && message.params?.frame?.parentId === undefined) {
|
|
501
|
+
this.url = String(message.params.frame.url ?? '')
|
|
502
|
+
void this.readTitle()
|
|
503
|
+
}
|
|
504
|
+
if (message.method === 'Page.loadEventFired') void this.readTitle()
|
|
505
|
+
})
|
|
506
|
+
await this.cdp.send('Page.enable')
|
|
507
|
+
/* An instance left behind by a stopped app can still be sitting on a
|
|
508
|
+
dialog; a paused renderer answers nothing, so clear it first. */
|
|
509
|
+
await Promise.race([
|
|
510
|
+
this.cdp.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}),
|
|
511
|
+
new Promise((resolve) => setTimeout(resolve, 800)),
|
|
512
|
+
])
|
|
513
|
+
await this.cdp.send('Runtime.enable')
|
|
514
|
+
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
515
|
+
width: VIEW.width, // ui-copy-ignore
|
|
516
|
+
height: VIEW.height, // ui-copy-ignore
|
|
517
|
+
deviceScaleFactor: 1,
|
|
518
|
+
mobile: false, // ui-copy-ignore
|
|
519
|
+
})
|
|
520
|
+
/* A headless build announces itself in the UA, and some sites refuse that
|
|
521
|
+
string outright. The page is a normal Chrome here, so it says so. */
|
|
522
|
+
const { product } = await this.cdp.send('Browser.getVersion').catch(() => ({ product: '' }))
|
|
523
|
+
if (String(product).includes('HeadlessChrome')) {
|
|
524
|
+
const version = String(product).replace(/.*HeadlessChrome\//, '')
|
|
525
|
+
await this.cdp.send('Emulation.setUserAgentOverride', {
|
|
526
|
+
userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version.split('.')[0]}.0.0.0 Safari/537.36`,
|
|
527
|
+
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
528
|
+
platform: 'MacIntel',
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
await this.readTitle()
|
|
532
|
+
return this
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async readTitle() {
|
|
536
|
+
try {
|
|
537
|
+
const call = this.cdp.send('Runtime.evaluate', {
|
|
538
|
+
expression: 'JSON.stringify({url: location.href, title: document.title})',
|
|
539
|
+
returnByValue: true,
|
|
540
|
+
})
|
|
541
|
+
const answer = await Promise.race([call, new Promise((resolve) => setTimeout(resolve, 1500))])
|
|
542
|
+
const result = answer?.result
|
|
543
|
+
const value = JSON.parse(String(result?.value ?? '{}'))
|
|
544
|
+
this.url = String(value.url ?? this.url)
|
|
545
|
+
this.title = String(value.title ?? this.title)
|
|
546
|
+
} catch {
|
|
547
|
+
/* a page mid-navigation has no title yet */
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** @returns {Promise<void>} resolves once the page is casting. */
|
|
552
|
+
async cast() {
|
|
553
|
+
if (this.casting) return
|
|
554
|
+
this.casting = true
|
|
555
|
+
/* Full-resolution mirror at a quality that keeps small text readable;
|
|
556
|
+
`optimizeForSpeed` keeps the encoder from adding to the round trip. */
|
|
557
|
+
await this.cdp.send('Page.startScreencast', {
|
|
558
|
+
format: 'jpeg', // ui-copy-ignore
|
|
559
|
+
quality: 72, // ui-copy-ignore
|
|
560
|
+
maxWidth: MIRROR.width, // ui-copy-ignore
|
|
561
|
+
maxHeight: MIRROR.height,
|
|
562
|
+
optimizeForSpeed: true,
|
|
563
|
+
everyNthFrame: 1,
|
|
564
|
+
}).catch(() => {
|
|
565
|
+
this.casting = false
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async stopCast() {
|
|
570
|
+
if (!this.casting) return
|
|
571
|
+
this.casting = false
|
|
572
|
+
await this.cdp.send('Page.stopScreencast').catch(() => {})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @param {string} url - address to open.
|
|
577
|
+
* @returns {Promise<{url: string, title: string}>} where it landed.
|
|
578
|
+
*/
|
|
579
|
+
async navigate(url) {
|
|
580
|
+
this.touched = Date.now()
|
|
581
|
+
this.lastUrl = String(url)
|
|
582
|
+
await this.cdp.send('Page.navigate', { url })
|
|
583
|
+
for (let i = 0; i < 200; i += 1) {
|
|
584
|
+
await wait(100)
|
|
585
|
+
await this.readTitle()
|
|
586
|
+
if (this.url !== '' && this.url !== 'about:blank') break
|
|
587
|
+
}
|
|
588
|
+
return { url: this.url, title: this.title }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* A renderer that crashed stops answering. Asking for one number is the
|
|
593
|
+
* cheapest way to tell a busy page from a dead one.
|
|
594
|
+
* @returns {Promise<boolean>} whether the page answered.
|
|
595
|
+
*/
|
|
596
|
+
async ping() {
|
|
597
|
+
try {
|
|
598
|
+
const answer = await Promise.race([
|
|
599
|
+
this.cdp.send('Runtime.evaluate', { expression: '1', returnByValue: true }),
|
|
600
|
+
wait(600).then(() => { throw new Error('no answer') }),
|
|
601
|
+
])
|
|
602
|
+
return { ok: answer !== undefined && answer !== null, closed: false }
|
|
603
|
+
} catch {
|
|
604
|
+
/* A closed connection answers at once; a busy page only times out. The
|
|
605
|
+
difference matters: one is replaced now, the other gets one more try. */
|
|
606
|
+
return { ok: false, closed: this.cdp?.closed === true }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async reload() {
|
|
611
|
+
this.touched = Date.now()
|
|
612
|
+
await this.cdp.send('Page.reload', {})
|
|
613
|
+
await wait(400)
|
|
614
|
+
await this.readTitle()
|
|
615
|
+
return { url: this.url, title: this.title }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @param {number} delta - negative goes back. @returns {Promise<{url: string}>} where it landed. */
|
|
619
|
+
async history(delta) {
|
|
620
|
+
const { currentIndex, entries } = await this.cdp.send('Page.getNavigationHistory')
|
|
621
|
+
const entry = entries?.[currentIndex + delta]
|
|
622
|
+
if (entry === undefined) return { url: this.url, moved: false }
|
|
623
|
+
await this.cdp.send('Page.navigateToHistoryEntry', { entryId: entry.id })
|
|
624
|
+
await wait(500)
|
|
625
|
+
await this.readTitle()
|
|
626
|
+
return { url: this.url, moved: true }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** @param {{x: number, y: number}} point - where the pointer goes. */
|
|
630
|
+
movePointer(point) {
|
|
631
|
+
this.pointer = { x: Math.round(point.x), y: Math.round(point.y) }
|
|
632
|
+
this.touched = Date.now()
|
|
633
|
+
const message = { type: 'pointer', w: VIEW.width, h: VIEW.height, pointer: this.pointer }
|
|
634
|
+
for (const sink of this.pointerSinks) {
|
|
635
|
+
try {
|
|
636
|
+
sink(message)
|
|
637
|
+
} catch {
|
|
638
|
+
/* a closed viewer is dropped by its own stream handler */
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The cursor shape the page would show at a point. This is a read: it never
|
|
645
|
+
* changes the page.
|
|
646
|
+
*
|
|
647
|
+
* @param {{x: number, y: number}} point - where to look, in viewport pixels.
|
|
648
|
+
* @returns {Promise<string>} a CSS cursor keyword the Client can apply.
|
|
649
|
+
*/
|
|
650
|
+
async cursorAt(point) {
|
|
651
|
+
const expression = `(() => {
|
|
652
|
+
const el = document.elementFromPoint(${Math.round(point.x)}, ${Math.round(point.y)})
|
|
653
|
+
if (el === null) return 'default'
|
|
654
|
+
return getComputedStyle(el).cursor || 'default'
|
|
655
|
+
})()`
|
|
656
|
+
try {
|
|
657
|
+
const { result } = await this.cdp.send('Runtime.evaluate', { expression, returnByValue: true })
|
|
658
|
+
return String(result?.value ?? 'default')
|
|
659
|
+
} catch {
|
|
660
|
+
return 'default'
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Move the page's own hover state without moving the agent's pointer: the
|
|
666
|
+
* person's hand is a real cursor here, and the black arrow marks the agent.
|
|
667
|
+
*
|
|
668
|
+
* @param {{x: number, y: number}} point - where the hand is, in viewport pixels.
|
|
669
|
+
* @returns {Promise<string>} the cursor shape the page shows under it.
|
|
670
|
+
*/
|
|
671
|
+
/**
|
|
672
|
+
* Send one input event and do not wait on a renderer that a dialog or a long
|
|
673
|
+
* task has paused. The event still lands; only the acknowledgement is dropped.
|
|
674
|
+
* @param {string} method - Input.* method.
|
|
675
|
+
* @param {object} params - its parameters.
|
|
676
|
+
* @returns {Promise<void>} done when sent or when the wait gives up.
|
|
677
|
+
*/
|
|
678
|
+
async input(method, params) {
|
|
679
|
+
await Promise.race([
|
|
680
|
+
this.cdp.send(method, params).catch(() => {}),
|
|
681
|
+
new Promise((resolve) => setTimeout(resolve, 1200)),
|
|
682
|
+
])
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async hover(point) {
|
|
686
|
+
await this.input('Input.dispatchMouseEvent', {
|
|
687
|
+
type: 'mouseMoved', // ui-copy-ignore
|
|
688
|
+
x: Math.round(point.x),
|
|
689
|
+
y: Math.round(point.y),
|
|
690
|
+
button: 'none', // ui-copy-ignore
|
|
691
|
+
buttons: 0, // ui-copy-ignore
|
|
692
|
+
clickCount: 0,
|
|
693
|
+
pointerType: 'mouse',
|
|
694
|
+
})
|
|
695
|
+
return this.cursorAt(point)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* @param {string} type - `mousePressed`, `mouseReleased` or `mouseMoved`.
|
|
700
|
+
* @param {{x: number, y: number, button?: string, clickCount?: number}} point - CSS pixels.
|
|
701
|
+
* @returns {Promise<void>} resolves when Chrome took the event.
|
|
702
|
+
*/
|
|
703
|
+
async mouse(type, point) {
|
|
704
|
+
this.movePointer(point)
|
|
705
|
+
await this.input('Input.dispatchMouseEvent', {
|
|
706
|
+
type,
|
|
707
|
+
x: point.x,
|
|
708
|
+
y: point.y,
|
|
709
|
+
button: point.button ?? 'left',
|
|
710
|
+
buttons: type === 'mouseReleased' ? 0 : 1,
|
|
711
|
+
clickCount: point.clickCount ?? (type === 'mouseMoved' ? 0 : 1),
|
|
712
|
+
pointerType: 'mouse',
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* @param {{x: number, y: number, dx?: number, dy?: number}} args - wheel over a point.
|
|
718
|
+
* @returns {Promise<void>} resolves when Chrome took it.
|
|
719
|
+
*/
|
|
720
|
+
async wheel(args) {
|
|
721
|
+
this.movePointer(args)
|
|
722
|
+
await this.input('Input.dispatchMouseEvent', {
|
|
723
|
+
type: 'mouseWheel', // ui-copy-ignore
|
|
724
|
+
x: args.x, // ui-copy-ignore
|
|
725
|
+
y: args.y, // ui-copy-ignore
|
|
726
|
+
deltaX: args.dx ?? 0, // ui-copy-ignore
|
|
727
|
+
deltaY: args.dy ?? 0, // ui-copy-ignore
|
|
728
|
+
pointerType: 'mouse',
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* @param {string} type - `keyDown` or `keyUp`.
|
|
734
|
+
* @param {{key: string, code?: string, keyCode?: number, text?: string, modifiers?: number}} key - one key.
|
|
735
|
+
* @returns {Promise<void>} resolves when Chrome took it.
|
|
736
|
+
*/
|
|
737
|
+
async key(type, key) {
|
|
738
|
+
this.touched = Date.now()
|
|
739
|
+
const virtual = key.keyCode ?? key.windowsVirtualKeyCode ?? 0
|
|
740
|
+
await this.input('Input.dispatchKeyEvent', {
|
|
741
|
+
type: key.text === undefined || key.text === '' ? type : (type === 'keyDown' ? 'keyDown' : type),
|
|
742
|
+
key: key.key, // ui-copy-ignore
|
|
743
|
+
code: key.code ?? '', // ui-copy-ignore
|
|
744
|
+
windowsVirtualKeyCode: virtual,
|
|
745
|
+
nativeVirtualKeyCode: key.nativeVirtualKeyCode ?? virtual,
|
|
746
|
+
text: key.text, // ui-copy-ignore
|
|
747
|
+
unmodifiedText: key.text,
|
|
748
|
+
modifiers: key.modifiers ?? 0,
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
|
|
753
|
+
async insertText(text) {
|
|
754
|
+
this.touched = Date.now()
|
|
755
|
+
await this.input('Input.insertText', { text })
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** @returns {Promise<string>} a full-size screenshot as base64 PNG. */
|
|
759
|
+
async screenshot() {
|
|
760
|
+
this.touched = Date.now()
|
|
761
|
+
const { data } = await this.cdp.send('Page.captureScreenshot', { format: 'png' })
|
|
762
|
+
return String(data ?? '')
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* @param {{full?: boolean, rect?: {x: number, y: number, width: number, height: number}, scale?: number}} [options] - what to capture.
|
|
767
|
+
* @returns {Promise<string>} base64 PNG; `full` ignores the viewport.
|
|
768
|
+
*/
|
|
769
|
+
async shot(options = {}) {
|
|
770
|
+
this.touched = Date.now()
|
|
771
|
+
/** @type {any} */
|
|
772
|
+
const args = { format: 'png' }
|
|
773
|
+
if (options.rect) {
|
|
774
|
+
args.clip = { ...options.rect, scale: options.scale ?? 1 }
|
|
775
|
+
args.captureBeyondViewport = options.full === true
|
|
776
|
+
} else if (options.full === true) {
|
|
777
|
+
const { result } = await this.cdp.send('Runtime.evaluate', {
|
|
778
|
+
expression: 'JSON.stringify({w: Math.max(document.documentElement.scrollWidth, innerWidth), h: Math.max(document.documentElement.scrollHeight, innerHeight)})',
|
|
779
|
+
returnByValue: true,
|
|
780
|
+
})
|
|
781
|
+
const size = JSON.parse(String(result?.value ?? '{"w":1440,"h":900}'))
|
|
782
|
+
args.clip = { x: 0, y: 0, width: Math.min(size.w, 8000), height: Math.min(size.h, 20000), scale: options.scale ?? 1 }
|
|
783
|
+
args.captureBeyondViewport = true
|
|
784
|
+
}
|
|
785
|
+
const { data } = await this.cdp.send('Page.captureScreenshot', args)
|
|
786
|
+
return String(data ?? '')
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** @returns {Promise<string>} base64 PDF of the whole document. */
|
|
790
|
+
async pdf() {
|
|
791
|
+
this.touched = Date.now()
|
|
792
|
+
const { data } = await this.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true })
|
|
793
|
+
return String(data ?? '')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Allow the page to use the clipboard; without this the API rejects. */
|
|
797
|
+
async allowClipboard() {
|
|
798
|
+
let origin = 'http://localhost'
|
|
799
|
+
try {
|
|
800
|
+
origin = new URL(this.url).origin
|
|
801
|
+
} catch {
|
|
802
|
+
/* a blank page has no origin yet */
|
|
803
|
+
}
|
|
804
|
+
await this.cdp.send('Browser.grantPermissions', {
|
|
805
|
+
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
|
806
|
+
origin,
|
|
807
|
+
}).catch(() => {})
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** @returns {Promise<string>} clipboard text, or an empty string when the page cannot read it. */
|
|
811
|
+
async clipboardRead() {
|
|
812
|
+
await this.allowClipboard()
|
|
813
|
+
const { result } = await this.cdp.send('Runtime.evaluate', {
|
|
814
|
+
expression: 'navigator.clipboard.readText().then((text) => JSON.stringify({ text }))',
|
|
815
|
+
awaitPromise: true,
|
|
816
|
+
returnByValue: true,
|
|
817
|
+
userGesture: true,
|
|
818
|
+
})
|
|
819
|
+
try {
|
|
820
|
+
return String(JSON.parse(String(result?.value ?? '{"text":""}')).text ?? '')
|
|
821
|
+
} catch {
|
|
822
|
+
return ''
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** @param {string} text - what to put on the clipboard. @returns {Promise<void>} resolves when written. */
|
|
827
|
+
async clipboardWrite(text) {
|
|
828
|
+
await this.allowClipboard()
|
|
829
|
+
await this.cdp.send('Runtime.evaluate', {
|
|
830
|
+
expression: `navigator.clipboard.writeText(${JSON.stringify(text)}).then(() => true)`,
|
|
831
|
+
awaitPromise: true,
|
|
832
|
+
returnByValue: true,
|
|
833
|
+
userGesture: true,
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** @param {number} sessionId - the screencast frame being acknowledged. */
|
|
838
|
+
/**
|
|
839
|
+
* The cookies this profile holds, newest state, straight from the browser.
|
|
840
|
+
* @param {string[]} [urls] - only cookies the given addresses would send.
|
|
841
|
+
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, secure: boolean, httpOnly: boolean}>>} cookies.
|
|
842
|
+
*/
|
|
843
|
+
async cookies(urls) {
|
|
844
|
+
if (Array.isArray(urls) && urls.length > 0) {
|
|
845
|
+
const { cookies: found } = await this.cdp.send('Network.getCookies', { urls })
|
|
846
|
+
return found ?? []
|
|
847
|
+
}
|
|
848
|
+
const { cookies: all } = await this.cdp.send('Network.getAllCookies')
|
|
849
|
+
return all ?? []
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* @param {{name: string, value: string, domain?: string, path?: string, secure?: boolean, httpOnly?: boolean, sameSite?: string}} cookie - what to keep.
|
|
854
|
+
* @returns {Promise<{ok: boolean, reason?: string}>} whether the browser took it.
|
|
855
|
+
*/
|
|
856
|
+
async setCookie(cookie) {
|
|
857
|
+
const params = {
|
|
858
|
+
name: cookie.name, // ui-copy-ignore
|
|
859
|
+
value: cookie.value, // ui-copy-ignore
|
|
860
|
+
path: cookie.path ?? '/', // ui-copy-ignore
|
|
861
|
+
secure: cookie.secure === true,
|
|
862
|
+
httpOnly: cookie.httpOnly === true,
|
|
863
|
+
}
|
|
864
|
+
if (cookie.domain !== undefined) params.domain = cookie.domain
|
|
865
|
+
else params.url = this.url
|
|
866
|
+
if (cookie.sameSite !== undefined) params.sameSite = cookie.sameSite
|
|
867
|
+
const answer = await this.cdp.send('Network.setCookie', params)
|
|
868
|
+
return { ok: answer?.success === true, reason: String(answer?.error ?? '') }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** @returns {Promise<void>} drops every cookie in this profile. */
|
|
872
|
+
async clearCookies() {
|
|
873
|
+
await this.cdp.send('Network.clearBrowserCookies')
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async ack(sessionId) {
|
|
877
|
+
await this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The Host's own Chrome. // ui-copy-ignore
|
|
883
|
+
*
|
|
884
|
+
* One instance per plugin, reattachable: a plugin reload must not log the
|
|
885
|
+
* person out, so an already-running sidecar is adopted through its port file // ui-copy-ignore
|
|
886
|
+
* instead of being spawned again.
|
|
887
|
+
*/
|
|
888
|
+
/** Where a profile keeps the cookies a browser would otherwise drop on exit. */
|
|
889
|
+
function sessionJar(dir) {
|
|
890
|
+
return join(dir, 'session-cookies.json') // ui-copy-ignore
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Where a profile keeps what the page downloads. */
|
|
894
|
+
function downloadsDir(dir) {
|
|
895
|
+
return join(dir, 'downloads')
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export class Sidecar {
|
|
899
|
+
/** @param {{logger?: any}} options - logging. */
|
|
900
|
+
constructor(options = {}) {
|
|
901
|
+
this.logger = options.logger
|
|
902
|
+
this.child = null
|
|
903
|
+
this.cdp = null
|
|
904
|
+
this.page = null
|
|
905
|
+
/* A measurement or a test may want a profile of its own; the person's
|
|
906
|
+
browser keeps the default one. */
|
|
907
|
+
this.profile = String(options.profile ?? 'default')
|
|
908
|
+
this.headless = true
|
|
909
|
+
this.starting = null
|
|
910
|
+
this.idle = null
|
|
911
|
+
this.lastError = ''
|
|
912
|
+
this.lastUrl = ''
|
|
913
|
+
/* What the person did in the mirror, and when. It starts empty rather than
|
|
914
|
+
missing: an adopted browser has no record of a hand on it yet, and asking
|
|
915
|
+
about one used to throw instead of answering. */
|
|
916
|
+
this.person = { at: 0, kind: '', x: 0, y: 0, seen: true } // ui-copy-ignore
|
|
917
|
+
/* How long after the person's last real input the agent holds off. Short
|
|
918
|
+
enough that a stray click does not stall a run, long enough that the agent
|
|
919
|
+
does not type into a field the person is in the middle of filling. */
|
|
920
|
+
this.holdMs = 6000
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The person touched the mirror by hand; every action waits for them now.
|
|
925
|
+
* Called from the input route, which only sees the person's own input.
|
|
926
|
+
*/
|
|
927
|
+
humanUse({ kind = '', x = 0, y = 0 } = {}) {
|
|
928
|
+
this.person = { at: Date.now(), kind, x, y, seen: false }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** @returns {number} ms the agent should still hold off; 0 when free to act. */
|
|
932
|
+
holding() {
|
|
933
|
+
if (this.person === undefined || this.person.at === 0) return 0
|
|
934
|
+
const left = this.holdMs - (Date.now() - this.person.at)
|
|
935
|
+
return left > 0 ? left : 0
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Take the page back now, without waiting out the hold. */
|
|
939
|
+
release() {
|
|
940
|
+
if (this.person === undefined) this.person = { at: 0, kind: '', x: 0, y: 0, seen: true } // ui-copy-ignore
|
|
941
|
+
this.person.at = 0
|
|
942
|
+
this.person.seen = true
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Files downloaded so far. Asking a stopped browser is not an error.
|
|
947
|
+
* @returns {{name: string, bytes: number, at: string}[]} what is on disk.
|
|
948
|
+
*/
|
|
949
|
+
downloads() {
|
|
950
|
+
if (this.page === null) return []
|
|
951
|
+
return this.page.downloads()
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Responses so far. Asking a stopped browser is an empty list, not an error.
|
|
956
|
+
* @returns {{method: string, url: string, type: string, status: number}[]} the requests.
|
|
957
|
+
*/
|
|
958
|
+
network() {
|
|
959
|
+
if (this.page === null) return []
|
|
960
|
+
return this.page.networkList()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
get alive() {
|
|
964
|
+
return this.cdp !== null && !this.cdp.closed && this.page !== null
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** @param {string} text - why the sidecar is not usable. */
|
|
968
|
+
note(text) {
|
|
969
|
+
this.lastError = text
|
|
970
|
+
this.logger?.warn?.('dsh-inner-browser-use: sidecar %s', text)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* @param {{profile?: string, headless?: boolean, url?: string}} [options] - how to start.
|
|
975
|
+
* @returns {Promise<SidecarPage>} the page being driven.
|
|
976
|
+
*/
|
|
977
|
+
async ensure(options = {}) {
|
|
978
|
+
if (options.profile !== undefined) this.profile = options.profile
|
|
979
|
+
const wanted = options.headless === undefined ? this.headless : options.headless !== false
|
|
980
|
+
const modeChanged = wanted !== this.headless
|
|
981
|
+
this.headless = wanted
|
|
982
|
+
/* A windowless browser and a windowed one are different processes, so asking
|
|
983
|
+
for the other one has to restart it. Setting the flag alone left the request
|
|
984
|
+
with no effect at all: a site that refuses a windowless browser stayed out
|
|
985
|
+
of reach until the browser happened to close for an unrelated reason. */
|
|
986
|
+
if (this.alive && modeChanged) {
|
|
987
|
+
const wasAt = options.url ?? this.page?.lastUrl ?? this.lastUrl
|
|
988
|
+
await this.shutdown()
|
|
989
|
+
return this.ensure({ ...options, url: wasAt })
|
|
990
|
+
}
|
|
991
|
+
if (this.alive) {
|
|
992
|
+
const page = this.page
|
|
993
|
+
/* A page that stopped answering is a page nobody can drive. Start a new
|
|
994
|
+
one on the address it was showing, rather than failing every call. */
|
|
995
|
+
let probe = page.dialog !== null && page.dialog !== undefined ? { ok: true } : await page.ping()
|
|
996
|
+
if (!probe.ok && !probe.closed) {
|
|
997
|
+
await wait(150)
|
|
998
|
+
probe = await page.ping()
|
|
999
|
+
}
|
|
1000
|
+
if (!probe.ok) {
|
|
1001
|
+
page.crashed = true
|
|
1002
|
+
this.note('renderer stopped answering; opening a fresh tab')
|
|
1003
|
+
const wasAt = options.url ?? page.lastUrl ?? this.lastUrl
|
|
1004
|
+
/* The browser itself is usually fine: only the renderer died. Opening a
|
|
1005
|
+
fresh tab in it is much faster than starting Chrome again. */
|
|
1006
|
+
try {
|
|
1007
|
+
return await this.recover(wasAt)
|
|
1008
|
+
} catch {
|
|
1009
|
+
await this.shutdown()
|
|
1010
|
+
return this.ensure({ ...options, url: wasAt })
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
this.touch()
|
|
1014
|
+
if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
|
|
1015
|
+
return this.page
|
|
1016
|
+
}
|
|
1017
|
+
if (this.starting !== null) return this.starting
|
|
1018
|
+
/* A page whose connection closed on its own (a renderer crash closes the
|
|
1019
|
+
tab's session) can be replaced in the browser that is still running. */
|
|
1020
|
+
if (this.page !== null && this.page.cdp?.closed === true) {
|
|
1021
|
+
const wasAt = options.url ?? this.page.lastUrl ?? this.lastUrl
|
|
1022
|
+
this.page.crashed = true
|
|
1023
|
+
this.note('renderer stopped answering; opening a fresh tab')
|
|
1024
|
+
this.starting = this.recover(wasAt).catch((error) => {
|
|
1025
|
+
this.note(String(error?.message ?? error))
|
|
1026
|
+
return this.launch(wasAt)
|
|
1027
|
+
}).finally(() => {
|
|
1028
|
+
this.starting = null
|
|
1029
|
+
})
|
|
1030
|
+
return this.starting
|
|
1031
|
+
}
|
|
1032
|
+
this.starting = this.launch(options.url).finally(() => {
|
|
1033
|
+
this.starting = null
|
|
1034
|
+
})
|
|
1035
|
+
return this.starting
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
|
|
1039
|
+
async launch(url) {
|
|
1040
|
+
const dir = profileDir(this.profile)
|
|
1041
|
+
let port = await this.runningPort(dir) // ui-copy-ignore
|
|
1042
|
+
if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
|
|
1043
|
+
const ws = await this.pageSocket(port) // ui-copy-ignore
|
|
1044
|
+
const cdp = new Cdp(ws)
|
|
1045
|
+
await cdp.ready
|
|
1046
|
+
this.cdp = cdp
|
|
1047
|
+
this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
|
|
1048
|
+
if (url !== undefined && url !== '') this.lastUrl = String(url)
|
|
1049
|
+
await this.restoreSessionCookies()
|
|
1050
|
+
await this.page.cast()
|
|
1051
|
+
if (url !== undefined && url !== '') await this.page.navigate(url)
|
|
1052
|
+
this.touch()
|
|
1053
|
+
this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
|
|
1054
|
+
return this.page
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* @param {string} dir - profile directory.
|
|
1059
|
+
* @returns {Promise<number>} the port of a sidecar already using it, or 0. // ui-copy-ignore
|
|
1060
|
+
*/
|
|
1061
|
+
async runningPort(dir) {
|
|
1062
|
+
const file = join(dir, 'DevToolsActivePort')
|
|
1063
|
+
if (!existsSync(file)) return 0
|
|
1064
|
+
const claimed = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0)
|
|
1065
|
+
if (!Number.isFinite(claimed) || claimed <= 0) return 0
|
|
1066
|
+
try {
|
|
1067
|
+
const response = await fetch(`http://127.0.0.1:${claimed}/json/version`, { signal: AbortSignal.timeout(1200) })
|
|
1068
|
+
if (!response.ok) return 0
|
|
1069
|
+
return claimed
|
|
1070
|
+
} catch {
|
|
1071
|
+
try {
|
|
1072
|
+
rmSync(file, { force: true })
|
|
1073
|
+
} catch {
|
|
1074
|
+
/* a stale port file is harmless */ // ui-copy-ignore
|
|
1075
|
+
}
|
|
1076
|
+
return 0
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* @param {string} dir - profile directory.
|
|
1082
|
+
* @returns {Promise<number>} the port Chrome opened. // ui-copy-ignore
|
|
1083
|
+
*/
|
|
1084
|
+
async spawnChrome(dir) {
|
|
1085
|
+
const binary = firstBinary()
|
|
1086
|
+
if (binary === undefined) throw new Error('sidecar: no Chrome found on this machine')
|
|
1087
|
+
const args = [
|
|
1088
|
+
`--user-data-dir=${dir}`,
|
|
1089
|
+
'--remote-debugging-port=0', // ui-copy-ignore
|
|
1090
|
+
'--remote-allow-origins=*',
|
|
1091
|
+
'--no-first-run',
|
|
1092
|
+
'--no-default-browser-check',
|
|
1093
|
+
'--use-mock-keychain',
|
|
1094
|
+
'--disable-blink-features=AutomationControlled',
|
|
1095
|
+
'--disable-features=Translate,MediaRouter,OptimizationHints',
|
|
1096
|
+
'--disable-sync',
|
|
1097
|
+
/* The driver clicks for a person, and a page that opens a tab on a click
|
|
1098
|
+
is doing what the page means to do. Blocking it would strand the
|
|
1099
|
+
mirror on the old page. */
|
|
1100
|
+
'--disable-popup-blocking',
|
|
1101
|
+
/* A crash-restore bubble would sit on the person's screen and is not how
|
|
1102
|
+
this browser is meant to come back; the profile itself is the state. */
|
|
1103
|
+
'--hide-crash-restore-bubble',
|
|
1104
|
+
'--disable-session-crashed-bubble',
|
|
1105
|
+
/* A background page is throttled to about one frame a second, and the
|
|
1106
|
+
mirror would show a slideshow: nothing here is background work. */
|
|
1107
|
+
'--disable-background-timer-throttling',
|
|
1108
|
+
'--disable-backgrounding-occluded-windows',
|
|
1109
|
+
'--disable-renderer-backgrounding',
|
|
1110
|
+
'--disable-ipc-flooding-protection',
|
|
1111
|
+
'--metrics-recording-only',
|
|
1112
|
+
'--no-service-autorun',
|
|
1113
|
+
'--password-store=basic',
|
|
1114
|
+
'--lang=zh-CN',
|
|
1115
|
+
`--window-size=${VIEW.width},${VIEW.height}`,
|
|
1116
|
+
'about:blank',
|
|
1117
|
+
]
|
|
1118
|
+
if (this.headless) args.unshift('--headless=new')
|
|
1119
|
+
const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
|
|
1120
|
+
child.on('exit', () => {
|
|
1121
|
+
if (this.child === child) {
|
|
1122
|
+
this.child = null
|
|
1123
|
+
this.cdp?.close()
|
|
1124
|
+
this.cdp = null
|
|
1125
|
+
this.page = null
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
this.child = child
|
|
1129
|
+
const file = join(dir, 'DevToolsActivePort')
|
|
1130
|
+
for (let i = 0; i < 150; i += 1) {
|
|
1131
|
+
await wait(100)
|
|
1132
|
+
if (!existsSync(file)) continue
|
|
1133
|
+
const port = Number(String(readFileSync(file, 'utf8')).split('\n')[0] ?? 0) // ui-copy-ignore
|
|
1134
|
+
if (Number.isFinite(port) && port > 0) return port // ui-copy-ignore
|
|
1135
|
+
}
|
|
1136
|
+
throw new Error('sidecar: Chrome did not open a debugging port') // ui-copy-ignore
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* @param {number} port - debugging port. // ui-copy-ignore
|
|
1141
|
+
* @returns {Promise<string>} the page target's socket.
|
|
1142
|
+
*/
|
|
1143
|
+
/**
|
|
1144
|
+
* Replace a dead page with a fresh tab in the same browser, on the same address.
|
|
1145
|
+
* @param {string} url - where the person was.
|
|
1146
|
+
* @returns {Promise<SidecarPage>} the new page.
|
|
1147
|
+
*/
|
|
1148
|
+
/**
|
|
1149
|
+
* The browser's pages, in the order the browser lists them.
|
|
1150
|
+
* @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
|
|
1151
|
+
*/
|
|
1152
|
+
async tabs() {
|
|
1153
|
+
return this.page === null ? [] : this.page.tabs()
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Move the mirror to another tab, leaving the old one open where it was.
|
|
1158
|
+
* @param {number} index - a row from `tabs()`.
|
|
1159
|
+
* @returns {Promise<SidecarPage>} the page now being driven.
|
|
1160
|
+
*/
|
|
1161
|
+
async useTab(index) {
|
|
1162
|
+
const list = await this.tabs()
|
|
1163
|
+
const tab = list[Number(index)]
|
|
1164
|
+
if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
|
|
1165
|
+
if (tab.current) return this.page
|
|
1166
|
+
const was = this.page
|
|
1167
|
+
const cdp = new Cdp(tab.webSocketUrl)
|
|
1168
|
+
await cdp.ready
|
|
1169
|
+
const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
|
|
1170
|
+
this.cdp = cdp
|
|
1171
|
+
this.page = page
|
|
1172
|
+
this.lastUrl = tab.url
|
|
1173
|
+
await page.cast()
|
|
1174
|
+
was.cdp.send('Page.stopScreencast').catch(() => {})
|
|
1175
|
+
was.cdp.close()
|
|
1176
|
+
this.touch()
|
|
1177
|
+
return page
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Close a tab; the mirror moves to a neighbour when the closed one was shown.
|
|
1182
|
+
* @param {number} index - a row from `tabs()`.
|
|
1183
|
+
*/
|
|
1184
|
+
async closeTab(index) {
|
|
1185
|
+
const list = await this.tabs()
|
|
1186
|
+
const tab = list[Number(index)]
|
|
1187
|
+
if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
|
|
1188
|
+
let port = this.page?.port ?? 0 // ui-copy-ignore
|
|
1189
|
+
let alone = true
|
|
1190
|
+
if (tab.current) {
|
|
1191
|
+
const other = list.findIndex((item) => !item.current)
|
|
1192
|
+
alone = other < 0
|
|
1193
|
+
if (!alone) await this.useTab(other)
|
|
1194
|
+
}
|
|
1195
|
+
if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
|
|
1196
|
+
if (alone) {
|
|
1197
|
+
await this.shutdown()
|
|
1198
|
+
await this.ensure()
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async recover(url) {
|
|
1203
|
+
const port = this.page?.port ?? 0 // ui-copy-ignore
|
|
1204
|
+
if (port === 0) throw new Error('sidecar: no browser to recover into') // ui-copy-ignore
|
|
1205
|
+
const address = url === '' || url === undefined ? 'about:blank' : String(url)
|
|
1206
|
+
const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
|
|
1207
|
+
if (!created.ok) throw new Error('sidecar: could not open a tab to recover into')
|
|
1208
|
+
const target = await created.json()
|
|
1209
|
+
if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: recovered tab has no socket')
|
|
1210
|
+
const cdp = new Cdp(target.webSocketDebuggerUrl)
|
|
1211
|
+
await cdp.ready
|
|
1212
|
+
this.cdp = cdp
|
|
1213
|
+
this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
|
|
1214
|
+
await this.page.cast()
|
|
1215
|
+
this.touch()
|
|
1216
|
+
return this.page
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async pageSocket(port) { // ui-copy-ignore
|
|
1220
|
+
for (let i = 0; i < 60; i += 1) {
|
|
1221
|
+
try {
|
|
1222
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
|
|
1223
|
+
const targets = await response.json()
|
|
1224
|
+
const page = targets.find((target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string')
|
|
1225
|
+
if (page !== undefined) return page.webSocketDebuggerUrl
|
|
1226
|
+
const created = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT', signal: AbortSignal.timeout(1500) }) // ui-copy-ignore
|
|
1227
|
+
if (created.ok) {
|
|
1228
|
+
const target = await created.json()
|
|
1229
|
+
if (typeof target.webSocketDebuggerUrl === 'string') return target.webSocketDebuggerUrl
|
|
1230
|
+
}
|
|
1231
|
+
} catch {
|
|
1232
|
+
/* Chrome is still coming up */
|
|
1233
|
+
}
|
|
1234
|
+
await wait(200)
|
|
1235
|
+
}
|
|
1236
|
+
throw new Error('sidecar: Chrome has no page target')
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Attach one viewer to the frame stream.
|
|
1241
|
+
* @param {(frame: any) => void} sink - called per frame.
|
|
1242
|
+
* @returns {Promise<() => void>} detach.
|
|
1243
|
+
*/
|
|
1244
|
+
async watch(sink) {
|
|
1245
|
+
const page = await this.ensure()
|
|
1246
|
+
page.frames.add(sink)
|
|
1247
|
+
page.pointerSinks.add(sink)
|
|
1248
|
+
await page.cast()
|
|
1249
|
+
return () => {
|
|
1250
|
+
page.frames.delete(sink)
|
|
1251
|
+
page.pointerSinks.delete(sink)
|
|
1252
|
+
if (page.frames.size === 0) void page.stopCast()
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
touch() {
|
|
1257
|
+
if (this.idle !== null) clearTimeout(this.idle)
|
|
1258
|
+
this.idle = setTimeout(() => {
|
|
1259
|
+
this.idle = null
|
|
1260
|
+
/* Someone is watching the mirror: the page stays up, and the clock starts
|
|
1261
|
+
again so the browser is closed only once it is truly unused. */
|
|
1262
|
+
if ((this.page?.frames.size ?? 0) > 0) {
|
|
1263
|
+
this.touch()
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
void this.shutdown()
|
|
1267
|
+
}, IDLE_MS)
|
|
1268
|
+
this.idle.unref?.()
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** @returns {Promise<void>} resolves once Chrome is gone. */
|
|
1272
|
+
async shutdown() {
|
|
1273
|
+
/* Ask Chrome to close first: a browser that is killed outright may lose the
|
|
1274
|
+
last minute of cookies, and keeping the person signed in is the point. */
|
|
1275
|
+
if (this.page !== null) await this.saveSessionCookies()
|
|
1276
|
+
if (this.cdp !== null) {
|
|
1277
|
+
await this.cdp.send('Browser.close').catch(() => {})
|
|
1278
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
1279
|
+
}
|
|
1280
|
+
this.page = null
|
|
1281
|
+
this.cdp?.close()
|
|
1282
|
+
this.cdp = null
|
|
1283
|
+
if (this.child !== null) {
|
|
1284
|
+
try {
|
|
1285
|
+
this.child.kill('SIGTERM')
|
|
1286
|
+
} catch {
|
|
1287
|
+
/* already gone */
|
|
1288
|
+
}
|
|
1289
|
+
this.child = null
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Chrome drops session cookies when it closes, and most sign-ins are session
|
|
1295
|
+
* cookies. Keeping them here is what lets a person stay signed in across a
|
|
1296
|
+
* reload of the app, which is the whole point of the profile.
|
|
1297
|
+
* @returns {Promise<void>} done.
|
|
1298
|
+
*/
|
|
1299
|
+
async saveSessionCookies() {
|
|
1300
|
+
try {
|
|
1301
|
+
const jar = (await this.page.cookies()).filter((c) => c.session === true || Number(c.expires) < 0)
|
|
1302
|
+
const file = sessionJar(profileDir(this.profile))
|
|
1303
|
+
if (jar.length === 0) {
|
|
1304
|
+
rmSync(file, { force: true })
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1307
|
+
writeFileSync(file, JSON.stringify(jar), { mode: 0o600 })
|
|
1308
|
+
} catch {
|
|
1309
|
+
/* a browser that is already gone has nothing to save */
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** @returns {Promise<void>} puts back what saveSessionCookies kept. */
|
|
1314
|
+
async restoreSessionCookies() {
|
|
1315
|
+
let jar = []
|
|
1316
|
+
try {
|
|
1317
|
+
jar = JSON.parse(String(readFileSync(sessionJar(profileDir(this.profile)), 'utf8')))
|
|
1318
|
+
} catch {
|
|
1319
|
+
return
|
|
1320
|
+
}
|
|
1321
|
+
if (!Array.isArray(jar)) return
|
|
1322
|
+
for (const cookie of jar.slice(0, 200)) {
|
|
1323
|
+
const domain = String(cookie?.domain ?? '').replace(/^\./, '')
|
|
1324
|
+
if (domain === '') continue
|
|
1325
|
+
try {
|
|
1326
|
+
await this.page.setCookie({
|
|
1327
|
+
name: String(cookie.name),
|
|
1328
|
+
value: String(cookie.value),
|
|
1329
|
+
domain,
|
|
1330
|
+
path: String(cookie.path ?? '/'),
|
|
1331
|
+
secure: cookie.secure === true,
|
|
1332
|
+
httpOnly: cookie.httpOnly === true,
|
|
1333
|
+
sameSite: cookie.sameSite,
|
|
1334
|
+
})
|
|
1335
|
+
} catch {
|
|
1336
|
+
/* one stale cookie must not stop the others */
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
|
|
1342
|
+
async state() {
|
|
1343
|
+
const profiles = (() => {
|
|
1344
|
+
try {
|
|
1345
|
+
return readdirSync(rootDir()).filter((name) => statSync(join(rootDir(), name)).isDirectory())
|
|
1346
|
+
} catch {
|
|
1347
|
+
return []
|
|
1348
|
+
}
|
|
1349
|
+
})()
|
|
1350
|
+
return {
|
|
1351
|
+
alive: this.alive, // ui-copy-ignore
|
|
1352
|
+
profile: this.profile, // ui-copy-ignore
|
|
1353
|
+
headless: this.headless, // ui-copy-ignore
|
|
1354
|
+
url: this.page?.url ?? '', // ui-copy-ignore
|
|
1355
|
+
title: this.page?.title ?? '',
|
|
1356
|
+
viewers: this.page?.frames.size ?? 0,
|
|
1357
|
+
frames: this.page?.seq ?? 0,
|
|
1358
|
+
crashed: this.page?.crashed === true,
|
|
1359
|
+
pointer: this.page?.pointer ?? null,
|
|
1360
|
+
profiles,
|
|
1361
|
+
error: this.alive ? '' : this.lastError,
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|