dsh-builtin-browser 0.4.8 → 0.4.10
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/CHANGELOG.md +219 -0
- package/desktop-bridge/install.mjs +77 -19
- package/desktop-bridge/plugin-browser-bridge.js +49 -3
- package/docs/limitations.md +8 -0
- package/lib/browser/types.d.ts +8 -2
- package/lib/browser-electron/desktop-bridge-host.d.ts +49 -0
- package/lib/browser-electron/desktop-bridge-host.js +116 -2
- package/lib/browser-electron/entry.js +13 -0
- package/lib/browser-electron/provider.d.ts +41 -0
- package/lib/browser-electron/provider.js +36 -3
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +329 -23
- package/package.json +1 -1
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/desktop-bridge-host.ts +116 -2
- package/src/browser-electron/entry.ts +15 -0
- package/src/browser-electron/provider.ts +79 -3
- package/src/browser-electron/system-browser.ts +321 -16
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
ExportedCookie,
|
|
52
52
|
} from '../browser/types.js'
|
|
53
53
|
import { BrowserError } from '../browser/types.js'
|
|
54
|
+
import type { CarrierPage } from '../browser/types.js'
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
@@ -156,6 +157,35 @@ export interface ElectronBrowserViewHost {
|
|
|
156
157
|
* @param label - human-readable session/task label, as in {@link showView}.
|
|
157
158
|
*/
|
|
158
159
|
presentView?(handle: ElectronViewHandle, label?: string): Promise<void>
|
|
160
|
+
/**
|
|
161
|
+
* Optional: pages this carrier holds that the provider never created.
|
|
162
|
+
*
|
|
163
|
+
* A sidebar panel and an installed browser both own their own page creation. Clicking a link
|
|
164
|
+
* that opens a new page — or any `window.open` the page performs — produces a page the provider
|
|
165
|
+
* did not ask for and therefore never recorded. Those pages are real: they are what the human
|
|
166
|
+
* sees, they hold memory, and they persist until something closes them. Without this the tab
|
|
167
|
+
* model reports only what the provider itself made, so the extras are invisible to
|
|
168
|
+
* `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`, accumulating for the
|
|
169
|
+
* life of the browser.
|
|
170
|
+
*
|
|
171
|
+
* Views the host itself opened are NOT included. That exclusion belongs to the host, which is the
|
|
172
|
+
* only side that can make it: a provider tab is identified by the provider's own id and a carrier
|
|
173
|
+
* page by the carrier's, so a caller comparing the two matches nothing and reports every page
|
|
174
|
+
* twice. Measured on the sidebar carrier: three clicks produced three webview guests while
|
|
175
|
+
* `dsh_browser_list_tabs` reported one tab; on the installed-browser carrier the window held four
|
|
176
|
+
* pages and the tool reported one.
|
|
177
|
+
* @returns every page the carrier holds that this host did not open.
|
|
178
|
+
*/
|
|
179
|
+
listPages?(): Promise<readonly CarrierPage[]>
|
|
180
|
+
/**
|
|
181
|
+
* Optional: close one page reported by {@link listPages}.
|
|
182
|
+
*
|
|
183
|
+
* The pair exists because listing a page the caller cannot close is worse than not listing it:
|
|
184
|
+
* `dsh_browser_list_tabs` would report pages that `dsh_browser_close_tab` answers "not open in
|
|
185
|
+
* this session" about, which reads as a contradiction rather than a boundary.
|
|
186
|
+
* @param id - the carrier's own page id, as {@link listPages} reported it.
|
|
187
|
+
*/
|
|
188
|
+
closePage?(id: string): Promise<void>
|
|
159
189
|
/**
|
|
160
190
|
* Optional cheap usability probe (no network): whether the host can back
|
|
161
191
|
* views at all right now. The self-hosted host checks for a usable Electron
|
|
@@ -413,6 +443,15 @@ export interface CdpNavigateParams {
|
|
|
413
443
|
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
414
444
|
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
415
445
|
* release halves; a move carries only the position.
|
|
446
|
+
*
|
|
447
|
+
* `buttons` is a BITMASK of what is held down at the moment of the event, and it is not the same
|
|
448
|
+
* field as `button` — `button` says which one this event is about, `buttons` says which ones are
|
|
449
|
+
* down. Chromium needs both to recognise a synthesized press as a real button press: with
|
|
450
|
+
* `buttons` omitted (0), the event still reaches the page and a `click` listener still fires, but
|
|
451
|
+
* the element's DEFAULT ACTION does not run. Measured on the sidebar carrier: a click on an
|
|
452
|
+
* anchor sent `mousedown`/`mouseup`/`click` to the page with `isTrusted: true` and
|
|
453
|
+
* `defaultPrevented: false`, and the browser did not follow the link — including for an anchor
|
|
454
|
+
* injected by the test itself, so the page was not responsible.
|
|
416
455
|
*/
|
|
417
456
|
export interface CdpMouseParams {
|
|
418
457
|
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
|
|
@@ -420,6 +459,8 @@ export interface CdpMouseParams {
|
|
|
420
459
|
readonly y: number
|
|
421
460
|
readonly button?: 'left'
|
|
422
461
|
readonly clickCount?: number
|
|
462
|
+
/** Buttons held during the event: 1 = primary down. A move carries 0. */
|
|
463
|
+
readonly buttons?: number
|
|
423
464
|
}
|
|
424
465
|
|
|
425
466
|
/** CDP method/params for `Input.insertText`. */
|
|
@@ -771,6 +812,27 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
771
812
|
active: i === s.activeIndex,
|
|
772
813
|
})
|
|
773
814
|
}
|
|
815
|
+
// Pages the CARRIER holds that this provider never created.
|
|
816
|
+
//
|
|
817
|
+
// A sidebar panel and an installed browser both open pages on their own: a click on a link
|
|
818
|
+
// that targets a new page, or any `window.open`, produces a page nothing tells us about.
|
|
819
|
+
// Reporting only our own tabs made those invisible to this tool and unclosable through
|
|
820
|
+
// `dsh_browser_close_tab`, so they accumulated for the life of the browser. Measured on the
|
|
821
|
+
// sidebar carrier: three clicks produced three webview guests while this listed one tab.
|
|
822
|
+
//
|
|
823
|
+
// `listPages` already excludes the pages it opened the view for — that filtering cannot happen
|
|
824
|
+
// here, because a tab is known by this provider's own id and a carrier page by the carrier's,
|
|
825
|
+
// and the two never match. A first version compared them anyway and listed every page twice.
|
|
826
|
+
//
|
|
827
|
+
// They are appended under a `page:` prefix so a caller can tell the two kinds apart — the
|
|
828
|
+
// prefix is also what `closeTab` uses to decide whether to close a view or a carrier page.
|
|
829
|
+
const pages = await this.host.listPages?.().catch(() => undefined) ?? []
|
|
830
|
+
for (const page of pages) {
|
|
831
|
+
// A page with no URL is a blank one the carrier opened for itself. Listing it would invite a
|
|
832
|
+
// caller to act on nothing, and the blank normalisation already closes those.
|
|
833
|
+
if (page.url === '' || page.url === 'about:blank') continue
|
|
834
|
+
result.push({ id: `page:${page.id}`, url: page.url, active: false })
|
|
835
|
+
}
|
|
774
836
|
return result
|
|
775
837
|
}
|
|
776
838
|
|
|
@@ -784,6 +846,19 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
784
846
|
|
|
785
847
|
/** Close one tab; closing the active tab activates the next. */
|
|
786
848
|
closeTab(session: BrowserSessionId, tabId: string): Promise<void> {
|
|
849
|
+
// A page the carrier owns rather than a view we created. It was reported by `listTabs` under
|
|
850
|
+
// this prefix, so closing it here is what keeps the two tools consistent — a page that can be
|
|
851
|
+
// listed must be closable, or the listing reads as a contradiction.
|
|
852
|
+
if (tabId.startsWith('page:')) {
|
|
853
|
+
const id = tabId.slice('page:'.length)
|
|
854
|
+
if (this.host.closePage === undefined) {
|
|
855
|
+
return Promise.reject(new BrowserError(
|
|
856
|
+
'browser: this carrier does not expose the pages it opens on its own, so that page cannot be closed',
|
|
857
|
+
'BROWSER_TAB_NOT_FOUND',
|
|
858
|
+
))
|
|
859
|
+
}
|
|
860
|
+
return this.host.closePage(id)
|
|
861
|
+
}
|
|
787
862
|
const found = this.locateTab(session, tabId)
|
|
788
863
|
const s = found.s
|
|
789
864
|
const index = found.index
|
|
@@ -1747,7 +1822,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1747
1822
|
// landed because the first had quietly established the target. Move the
|
|
1748
1823
|
// pointer first so a click is self-contained.
|
|
1749
1824
|
try {
|
|
1750
|
-
await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
|
|
1825
|
+
await send({ type: 'mouseMoved', x, y, buttons: 0 } satisfies CdpMouseParams, 'move')
|
|
1751
1826
|
} catch (error) {
|
|
1752
1827
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1753
1828
|
}
|
|
@@ -1767,12 +1842,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1767
1842
|
x,
|
|
1768
1843
|
y,
|
|
1769
1844
|
button: 'left',
|
|
1845
|
+
buttons: 0,
|
|
1770
1846
|
clickCount: 1,
|
|
1771
1847
|
} satisfies CdpMouseParams)
|
|
1772
1848
|
.catch(() => {})
|
|
1773
1849
|
}
|
|
1774
1850
|
try {
|
|
1775
|
-
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
|
|
1851
|
+
await send({ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, 'press')
|
|
1776
1852
|
} catch (error) {
|
|
1777
1853
|
// The press may still land late; release best-effort so the button is
|
|
1778
1854
|
// never left in a stuck pressed state.
|
|
@@ -1780,7 +1856,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1780
1856
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1781
1857
|
}
|
|
1782
1858
|
try {
|
|
1783
|
-
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
|
|
1859
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, 'release')
|
|
1784
1860
|
} catch (error) {
|
|
1785
1861
|
// The press already landed; retry the release so the button is not
|
|
1786
1862
|
// left pressed before surfacing the failure.
|
|
@@ -38,6 +38,15 @@ import { join } from 'node:path'
|
|
|
38
38
|
* exited, and that exit was reported as a bad browser path.
|
|
39
39
|
*/
|
|
40
40
|
const EXIT_GRACE_MS = 3_000
|
|
41
|
+
/**
|
|
42
|
+
* How long a browser is given to close itself after being asked, before it is killed.
|
|
43
|
+
*
|
|
44
|
+
* Long enough for Chrome to flush its profile, which is what keeps the next launch from reading
|
|
45
|
+
* the profile as crashed and offering a session restore. Deliberately not the same knob as
|
|
46
|
+
* {@link EXIT_GRACE_MS}: that one waits for a browser to die because a NEW one cannot start until
|
|
47
|
+
* it does, this one merely prefers a graceful exit to a kill.
|
|
48
|
+
*/
|
|
49
|
+
const CLOSE_GRACE_MS = 6_000
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Ceiling on one attempt to ask a discovered CDP port who it is.
|
|
@@ -73,6 +82,7 @@ const defaultLauncher: BrowserLauncher = (path, args) =>
|
|
|
73
82
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
74
83
|
import { randomUUID } from 'node:crypto'
|
|
75
84
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
85
|
+
import type { CarrierPage } from '../browser/types.js'
|
|
76
86
|
|
|
77
87
|
/** Which browser the user asked for. */
|
|
78
88
|
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar'
|
|
@@ -178,6 +188,34 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
178
188
|
* @param env - the environment to resolve against.
|
|
179
189
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
180
190
|
*/
|
|
191
|
+
/**
|
|
192
|
+
* Whether a browser's self-reported product name is one this carrier could have launched.
|
|
193
|
+
*
|
|
194
|
+
* `edg`, not `edge`: Edge reports itself as `Edg/155.0.4283.45`, both over HTTP and over the
|
|
195
|
+
* protocol. Testing for the full word rejected a live Edge that had just been started, so the
|
|
196
|
+
* call failed with the hand-off error this check was added to prevent — while the browser was
|
|
197
|
+
* running the whole time, answering on its own port.
|
|
198
|
+
* @param product - the product string a browser reports about itself.
|
|
199
|
+
*/
|
|
200
|
+
function isBrowserProduct(product: unknown): boolean {
|
|
201
|
+
return typeof product === 'string' && /chrome|chromium|edg/i.test(product)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
206
|
+
*
|
|
207
|
+
* Deliberately narrow: `about:blank` and Chrome's own new-tab pages, and nothing else. A wider
|
|
208
|
+
* test ("has no path", "starts with chrome://") would sooner or later match a page the user is
|
|
209
|
+
* reading, and the caller closes what this returns.
|
|
210
|
+
* @param url - the target's reported URL.
|
|
211
|
+
*/
|
|
212
|
+
function isBlankUrl(url: unknown): boolean {
|
|
213
|
+
if (typeof url !== 'string') return false
|
|
214
|
+
return url === 'about:blank'
|
|
215
|
+
|| url.startsWith('chrome://newtab')
|
|
216
|
+
|| url.startsWith('chrome://new-tab-page')
|
|
217
|
+
}
|
|
218
|
+
|
|
181
219
|
function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
|
|
182
220
|
return location.replace(/%([^%]+)%/g, (whole, name: string) =>
|
|
183
221
|
env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
|
|
@@ -415,6 +453,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
415
453
|
*/
|
|
416
454
|
private clientSuspect = false
|
|
417
455
|
private child: ChildProcess | undefined
|
|
456
|
+
/**
|
|
457
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
458
|
+
*
|
|
459
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
460
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
461
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
462
|
+
*/
|
|
463
|
+
private adoptedRunning = false
|
|
464
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
465
|
+
private blanksNormalized = false
|
|
418
466
|
private starting: Promise<CdpClient> | undefined
|
|
419
467
|
|
|
420
468
|
/**
|
|
@@ -503,6 +551,10 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
503
551
|
private forgetBrowserState(): void {
|
|
504
552
|
this.views.clear()
|
|
505
553
|
this.sessions.clear()
|
|
554
|
+
// A new browser comes with its own surplus pages, so the tidy-up runs again for it. Without
|
|
555
|
+
// this, a restart would inherit the previous connection's "already handled" and leave the new
|
|
556
|
+
// browser's extra blank page in place for the rest of the session.
|
|
557
|
+
this.blanksNormalized = false
|
|
506
558
|
}
|
|
507
559
|
|
|
508
560
|
private async ensureClient(): Promise<CdpClient> {
|
|
@@ -535,7 +587,70 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
535
587
|
}
|
|
536
588
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
537
589
|
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
538
|
-
|
|
590
|
+
const client = await this.starting
|
|
591
|
+
// Once per connection. A browser opens more than one page when it starts, and the surplus is
|
|
592
|
+
// invisible to the plugin's tab model — see normalizeBlankPages for why that matters.
|
|
593
|
+
if (!this.blanksNormalized) {
|
|
594
|
+
this.blanksNormalized = true
|
|
595
|
+
await this.normalizeBlankPages(client)
|
|
596
|
+
}
|
|
597
|
+
return client
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
602
|
+
*
|
|
603
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
604
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
605
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
606
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
607
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
608
|
+
* exactly what was reported.
|
|
609
|
+
*
|
|
610
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
611
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
612
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
613
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
614
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
615
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
616
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
617
|
+
*/
|
|
618
|
+
private async adoptRunningBrowser(why: string): Promise<CdpClient | undefined> {
|
|
619
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
620
|
+
let port: number
|
|
621
|
+
try {
|
|
622
|
+
if (!existsSync(portFile)) return undefined
|
|
623
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
|
|
624
|
+
if (!Number.isInteger(value) || value <= 0) return undefined
|
|
625
|
+
port = value
|
|
626
|
+
} catch {
|
|
627
|
+
return undefined
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
631
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
632
|
+
})).json() as { webSocketDebuggerUrl?: string; Browser?: string }
|
|
633
|
+
if (typeof version.webSocketDebuggerUrl !== 'string') return undefined
|
|
634
|
+
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
635
|
+
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
636
|
+
if (!isBrowserProduct(version.Browser)) return undefined
|
|
637
|
+
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
638
|
+
await client.whenReady()
|
|
639
|
+
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
640
|
+
// mistaken for a browser that answers.
|
|
641
|
+
const info = await client.send('Browser.getVersion', {}) as { product?: string }
|
|
642
|
+
if (!isBrowserProduct(info?.product)) {
|
|
643
|
+
client.close()
|
|
644
|
+
return undefined
|
|
645
|
+
}
|
|
646
|
+
if (this.disposed) { client.close(); return undefined }
|
|
647
|
+
this.client = client
|
|
648
|
+
this.adoptedRunning = true
|
|
649
|
+
return client
|
|
650
|
+
} catch {
|
|
651
|
+
// No endpoint, nothing listening, or not a browser: all of them mean "launch one".
|
|
652
|
+
return undefined
|
|
653
|
+
}
|
|
539
654
|
}
|
|
540
655
|
|
|
541
656
|
/**
|
|
@@ -547,6 +662,11 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
547
662
|
* @returns the connected client.
|
|
548
663
|
*/
|
|
549
664
|
private async start(): Promise<CdpClient> {
|
|
665
|
+
// Before spawning anything: is a browser for THIS profile already running? A DSH restart
|
|
666
|
+
// leaves one behind, and launching into it is the failure reported as "exited immediately
|
|
667
|
+
// after launch (exit code 0)" that required killing seven processes by hand.
|
|
668
|
+
const adopted = await this.adoptRunningBrowser('before launch')
|
|
669
|
+
if (adopted !== undefined) return adopted
|
|
550
670
|
const args = [
|
|
551
671
|
'--remote-debugging-port=0',
|
|
552
672
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -598,12 +718,14 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
598
718
|
})
|
|
599
719
|
})
|
|
600
720
|
}
|
|
601
|
-
// The port file
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
721
|
+
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
722
|
+
//
|
|
723
|
+
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
724
|
+
// the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
|
|
725
|
+
// deletion never protected it — while it DID destroy the only record of a browser that may
|
|
726
|
+
// still be running for this profile. That record is what `adoptRunningBrowser` needs when the
|
|
727
|
+
// launch comes back as a hand-off: the browser was alive the whole time, and without the file
|
|
728
|
+
// there is no way to find it again.
|
|
607
729
|
const spawnedAt = Date.now()
|
|
608
730
|
let child: ReturnType<typeof spawn>
|
|
609
731
|
try {
|
|
@@ -723,6 +845,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
723
845
|
const code = child.exitCode
|
|
724
846
|
const signal = child.signalCode
|
|
725
847
|
const clean = code === 0 && signal === null
|
|
848
|
+
// Exit code 0 IS the hand-off: the request went to an instance already holding this profile,
|
|
849
|
+
// so that instance is our browser and it is running right now. Adopting it turns the
|
|
850
|
+
// reported "kill the leftovers by hand, on every restart" into a call that just works.
|
|
851
|
+
if (clean) {
|
|
852
|
+
const recovered = await this.adoptRunningBrowser('after a hand-off')
|
|
853
|
+
if (recovered !== undefined) return recovered
|
|
854
|
+
}
|
|
726
855
|
throw new Error(
|
|
727
856
|
clean
|
|
728
857
|
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
@@ -752,6 +881,69 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
752
881
|
return !this.disposed
|
|
753
882
|
}
|
|
754
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Every page the browser currently holds, including ones this plugin did not create.
|
|
886
|
+
*
|
|
887
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
888
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
889
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
890
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
891
|
+
*
|
|
892
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
893
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
894
|
+
* @returns the browser's pages, or an empty list when it cannot answer.
|
|
895
|
+
*/
|
|
896
|
+
/**
|
|
897
|
+
* Every page the browser holds that THIS HOST did not open.
|
|
898
|
+
*
|
|
899
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
900
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
901
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
902
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
903
|
+
*
|
|
904
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
905
|
+
* caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
|
|
906
|
+
* CDP target id, and the two never match. The first version returned everything and relied on the
|
|
907
|
+
* caller to filter, which reported the same page twice.
|
|
908
|
+
*
|
|
909
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
910
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
911
|
+
* @returns the browser's pages that are not ours, or an empty list when it cannot answer.
|
|
912
|
+
*/
|
|
913
|
+
async listPages(): Promise<readonly CarrierPage[]> {
|
|
914
|
+
try {
|
|
915
|
+
const client = await this.ensureClient()
|
|
916
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
917
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string; title?: string }>
|
|
918
|
+
}
|
|
919
|
+
const ours = new Set(this.sessions.values())
|
|
920
|
+
return (listed?.targetInfos ?? [])
|
|
921
|
+
.filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
|
|
922
|
+
.filter(entry => !ours.has(entry.targetId as string))
|
|
923
|
+
.map(entry => ({
|
|
924
|
+
id: entry.targetId as string,
|
|
925
|
+
url: typeof entry.url === 'string' ? entry.url : '',
|
|
926
|
+
...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
|
|
927
|
+
}))
|
|
928
|
+
} catch {
|
|
929
|
+
// A listing must not fail harder than driving does; the next command reports a real problem.
|
|
930
|
+
return []
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Close one page by the target id {@link listPages} reported.
|
|
936
|
+
*
|
|
937
|
+
* `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
|
|
938
|
+
* created. The page may already be gone if the human closed it in the meantime — the caller
|
|
939
|
+
* asked for it to be closed and it is, so that is not an error.
|
|
940
|
+
* @param id - the CDP target id.
|
|
941
|
+
*/
|
|
942
|
+
async closePage(id: string): Promise<void> {
|
|
943
|
+
const client = await this.ensureClient()
|
|
944
|
+
await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined)
|
|
945
|
+
}
|
|
946
|
+
|
|
755
947
|
createView(): ElectronViewHandle {
|
|
756
948
|
const viewId = randomUUID()
|
|
757
949
|
return {
|
|
@@ -892,13 +1084,34 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
892
1084
|
dispose(): void {
|
|
893
1085
|
if (this.disposed) return
|
|
894
1086
|
this.disposed = true
|
|
1087
|
+
const client = this.client
|
|
895
1088
|
this.forgetBrowserState()
|
|
896
|
-
this.client
|
|
897
|
-
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
1089
|
+
this.client = undefined
|
|
1090
|
+
// Ask before killing. `kill()` on Windows is TerminateProcess, which gives the browser no
|
|
1091
|
+
// chance to flush its profile — and Chrome records how it ended in that profile, so a
|
|
1092
|
+
// hard kill leaves it marked as a crash and the NEXT launch offers to restore a session.
|
|
1093
|
+
// That is a visible symptom (an extra window) traced all the way back to this line.
|
|
1094
|
+
if (client !== undefined) {
|
|
1095
|
+
try { void client.send('Browser.close', {}).catch(() => { /* it is closing, not talking */ }) } catch { /* already gone */ }
|
|
1096
|
+
}
|
|
1097
|
+
// Both the browser we launched and anything that ignored an earlier kill. They hold this
|
|
1098
|
+
// profile, so leaving them running means the next launch contends with a browser nobody owns.
|
|
1099
|
+
const doomed = [this.child, ...this.stubborn.splice(0)].filter(
|
|
1100
|
+
(entry): entry is ChildProcess => entry !== undefined,
|
|
1101
|
+
)
|
|
1102
|
+
this.child = undefined
|
|
1103
|
+
for (const dying of doomed) {
|
|
1104
|
+
/**
|
|
1105
|
+
* A bounded reprieve, then the kill we used to do immediately.
|
|
1106
|
+
*
|
|
1107
|
+
* The timer is unref'd so it never holds the process open: a DSH shutdown that is itself
|
|
1108
|
+
* ending does not wait for this, and the browser — having been asked to close — generally
|
|
1109
|
+
* gets there on its own. If it does not, the kill still lands while the process lives.
|
|
1110
|
+
*/
|
|
1111
|
+
const force = (): void => { try { dying.kill() } catch { /* already gone */ } }
|
|
1112
|
+
const timer = setTimeout(force, CLOSE_GRACE_MS)
|
|
1113
|
+
timer.unref?.()
|
|
1114
|
+
dying.once('exit', () => { clearTimeout(timer) })
|
|
902
1115
|
}
|
|
903
1116
|
const ephemeral = this.ephemeralDir
|
|
904
1117
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
@@ -945,9 +1158,15 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
945
1158
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
946
1159
|
private async buildSession(viewId: string): Promise<string> {
|
|
947
1160
|
const client = await this.ensureClient()
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1161
|
+
// Adopt a blank page the browser opened on its own before creating another one.
|
|
1162
|
+
//
|
|
1163
|
+
// Chrome starts with more than one page when handed a URL on top of its own start page, so
|
|
1164
|
+
// creating a target here discarded the original and left it behind as a tab that is not in
|
|
1165
|
+
// the plugin's tab model — invisible to `dsh_browser_list_tabs`, and therefore unclosable
|
|
1166
|
+
// through the tools. Taking the blank instead makes the page the browser already opened the
|
|
1167
|
+
// one the caller gets.
|
|
1168
|
+
const adopted = await this.adoptBlankTarget(client)
|
|
1169
|
+
const targetId = adopted ?? await this.createTarget(client)
|
|
951
1170
|
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
952
1171
|
const sessionId = attached?.sessionId
|
|
953
1172
|
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
@@ -957,6 +1176,92 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
957
1176
|
return sessionId
|
|
958
1177
|
}
|
|
959
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
1181
|
+
*
|
|
1182
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
1183
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
1184
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
1185
|
+
* @param client - the connected browser.
|
|
1186
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
1187
|
+
*/
|
|
1188
|
+
private async adoptBlankTarget(client: CdpClient): Promise<string | undefined> {
|
|
1189
|
+
try {
|
|
1190
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1191
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1192
|
+
}
|
|
1193
|
+
const claimed = new Set(this.sessions.values())
|
|
1194
|
+
for (const entry of listed?.targetInfos ?? []) {
|
|
1195
|
+
if (entry.type !== 'page' || typeof entry.targetId !== 'string') continue
|
|
1196
|
+
if (!isBlankUrl(entry.url)) continue
|
|
1197
|
+
if (claimed.has(entry.targetId)) continue
|
|
1198
|
+
return entry.targetId
|
|
1199
|
+
}
|
|
1200
|
+
return undefined
|
|
1201
|
+
} catch {
|
|
1202
|
+
// Discovery is best-effort; creating a page is the behaviour this replaced.
|
|
1203
|
+
return undefined
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Create one page, refusing to go on without an id. */
|
|
1208
|
+
private async createTarget(client: CdpClient): Promise<string> {
|
|
1209
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
1210
|
+
const targetId = created?.targetId
|
|
1211
|
+
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
1212
|
+
return targetId
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
1217
|
+
*
|
|
1218
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
1219
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
1220
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
1221
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
1222
|
+
*
|
|
1223
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
1224
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
1225
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
1226
|
+
*
|
|
1227
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
1228
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
1229
|
+
* reason the first attempt at this fix failed silently.
|
|
1230
|
+
* @param client - the connected browser.
|
|
1231
|
+
* @returns how many surplus blank pages were closed.
|
|
1232
|
+
*/
|
|
1233
|
+
private async normalizeBlankPages(client: CdpClient): Promise<number> {
|
|
1234
|
+
try {
|
|
1235
|
+
await client.send('Target.setDiscoverTargets', { discover: true })
|
|
1236
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1237
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1238
|
+
}
|
|
1239
|
+
const blanks = (listed?.targetInfos ?? []).filter(
|
|
1240
|
+
entry => entry.type === 'page' && typeof entry.targetId === 'string' && isBlankUrl(entry.url),
|
|
1241
|
+
)
|
|
1242
|
+
const claimed = new Set(this.sessions.values())
|
|
1243
|
+
let closed = 0
|
|
1244
|
+
// Keep the first unclaimed blank (that is the one buildSession will adopt) and close the rest.
|
|
1245
|
+
let kept = false
|
|
1246
|
+
for (const entry of blanks) {
|
|
1247
|
+
const targetId = entry.targetId as string
|
|
1248
|
+
if (claimed.has(targetId)) continue
|
|
1249
|
+
if (!kept) { kept = true; continue }
|
|
1250
|
+
try {
|
|
1251
|
+
await client.send('Target.closeTarget', { targetId })
|
|
1252
|
+
closed += 1
|
|
1253
|
+
} catch {
|
|
1254
|
+
// It may already be gone; one failure must not stop the rest.
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return closed
|
|
1258
|
+
} catch {
|
|
1259
|
+
// A browser that will not answer Target commands has a real problem, and the next command
|
|
1260
|
+
// will report it. Turning a tidy-up into a functional error here would hide that.
|
|
1261
|
+
return 0
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
960
1265
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
961
1266
|
private readonly sessionBuilding = new Map<string, Promise<string>>()
|
|
962
1267
|
|