dsh-builtin-browser 0.4.9 → 0.4.11
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 +173 -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 +40 -0
- package/lib/browser-electron/system-browser.js +119 -14
- 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 +123 -14
|
@@ -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.
|
|
@@ -82,6 +82,7 @@ const defaultLauncher: BrowserLauncher = (path, args) =>
|
|
|
82
82
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
83
83
|
import { randomUUID } from 'node:crypto'
|
|
84
84
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
85
|
+
import type { CarrierPage } from '../browser/types.js'
|
|
85
86
|
|
|
86
87
|
/** Which browser the user asked for. */
|
|
87
88
|
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar'
|
|
@@ -187,6 +188,19 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
187
188
|
* @param env - the environment to resolve against.
|
|
188
189
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
189
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
|
+
|
|
190
204
|
/**
|
|
191
205
|
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
192
206
|
*
|
|
@@ -619,13 +633,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
619
633
|
if (typeof version.webSocketDebuggerUrl !== 'string') return undefined
|
|
620
634
|
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
621
635
|
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
622
|
-
if (
|
|
636
|
+
if (!isBrowserProduct(version.Browser)) return undefined
|
|
623
637
|
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
624
638
|
await client.whenReady()
|
|
625
639
|
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
626
640
|
// mistaken for a browser that answers.
|
|
627
641
|
const info = await client.send('Browser.getVersion', {}) as { product?: string }
|
|
628
|
-
if (
|
|
642
|
+
if (!isBrowserProduct(info?.product)) {
|
|
629
643
|
client.close()
|
|
630
644
|
return undefined
|
|
631
645
|
}
|
|
@@ -707,11 +721,37 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
707
721
|
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
708
722
|
//
|
|
709
723
|
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
710
|
-
// the loop
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
724
|
+
// the loop judges freshness against a snapshot taken below, so the deletion never protected it
|
|
725
|
+
// — while it DID destroy the only record of a browser that may still be running for this
|
|
726
|
+
// profile. That record is what `adoptRunningBrowser` needs when the launch comes back as a
|
|
727
|
+
// hand-off: the browser was alive the whole time, and without the file there is no way to find
|
|
728
|
+
// it again.
|
|
729
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
730
|
+
/**
|
|
731
|
+
* What the port file was like BEFORE this launch, so "fresh" is judged against a snapshot
|
|
732
|
+
* rather than against a clock.
|
|
733
|
+
*
|
|
734
|
+
* The test used to be `mtimeMs < spawnedAt` — a filesystem timestamp compared to `Date.now()`.
|
|
735
|
+
* Those are different clocks with different resolutions, and CI is where it showed: a file the
|
|
736
|
+
* fake browser had just written was rejected as stale, so the loop waited out its full 30s
|
|
737
|
+
* against a browser that was already listening, because the file's mtime had rounded down
|
|
738
|
+
* below the spawn time. That is not a test-only problem: on any filesystem whose mtime
|
|
739
|
+
* resolution is coarser than a millisecond, the plugin would fail to see the browser it had
|
|
740
|
+
* just launched, and report a CDP timeout for a browser that was up the whole time.
|
|
741
|
+
*
|
|
742
|
+
* `undefined` means there was no file, so any file that appears afterwards is fresh. When one
|
|
743
|
+
* existed, a later mtime OR different contents makes the new one fresh. Byte-identical contents
|
|
744
|
+
* with an unchanged mtime is the PREVIOUS browser's port, and adopting it is what this whole
|
|
745
|
+
* check exists to prevent: it may name a port that now belongs to something else entirely.
|
|
746
|
+
*/
|
|
747
|
+
const portFileBefore = ((): { mtimeMs: number; contents: string } | undefined => {
|
|
748
|
+
try {
|
|
749
|
+
return { mtimeMs: statSync(portFile).mtimeMs, contents: readFileSync(portFile, 'utf8') }
|
|
750
|
+
} catch {
|
|
751
|
+
return undefined
|
|
752
|
+
}
|
|
753
|
+
})()
|
|
754
|
+
// Kept for the failure message and for the exit listener's diagnostics; no longer a judgement.
|
|
715
755
|
const spawnedAt = Date.now()
|
|
716
756
|
let child: ReturnType<typeof spawn>
|
|
717
757
|
try {
|
|
@@ -745,20 +785,26 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
745
785
|
// Clearing these is what makes the restart usable rather than merely alive.
|
|
746
786
|
this.forgetBrowserState()
|
|
747
787
|
})
|
|
748
|
-
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
749
788
|
/**
|
|
750
789
|
* The port this launch wrote, or undefined while it has not written one yet.
|
|
751
790
|
*
|
|
752
|
-
* A file left by the PREVIOUS browser is not evidence about this one. Its contents
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
791
|
+
* A file left by the PREVIOUS browser is not evidence about this one. Its contents name a port
|
|
792
|
+
* that may now belong to something else entirely — another Chromium, a `node --inspect` — and
|
|
793
|
+
* connecting there publishes a client this host does not own and dispose() can never kill.
|
|
794
|
+
* Only a file that CHANGED across this launch counts, which is what `portFileBefore` records.
|
|
756
795
|
*/
|
|
757
796
|
const freshPort = (): number | undefined => {
|
|
758
797
|
try {
|
|
759
798
|
if (!existsSync(portFile)) return undefined
|
|
760
|
-
|
|
761
|
-
const
|
|
799
|
+
const after = statSync(portFile)
|
|
800
|
+
const contents = readFileSync(portFile, 'utf8')
|
|
801
|
+
// Unchanged since before the launch: this is the previous browser's file, whatever its
|
|
802
|
+
// mtime says. Comparing against the snapshot rather than the spawn time is what keeps this
|
|
803
|
+
// correct on a filesystem whose timestamps are coarser than the clock behind spawnedAt.
|
|
804
|
+
if (portFileBefore !== undefined
|
|
805
|
+
&& after.mtimeMs <= portFileBefore.mtimeMs
|
|
806
|
+
&& contents === portFileBefore.contents) return undefined
|
|
807
|
+
const value = Number(contents.split('\n')[0]?.trim() ?? '')
|
|
762
808
|
return Number.isInteger(value) && value > 0 ? value : undefined
|
|
763
809
|
} catch {
|
|
764
810
|
return undefined
|
|
@@ -867,6 +913,69 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
867
913
|
return !this.disposed
|
|
868
914
|
}
|
|
869
915
|
|
|
916
|
+
/**
|
|
917
|
+
* Every page the browser currently holds, including ones this plugin did not create.
|
|
918
|
+
*
|
|
919
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
920
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
921
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
922
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
923
|
+
*
|
|
924
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
925
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
926
|
+
* @returns the browser's pages, or an empty list when it cannot answer.
|
|
927
|
+
*/
|
|
928
|
+
/**
|
|
929
|
+
* Every page the browser holds that THIS HOST did not open.
|
|
930
|
+
*
|
|
931
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
932
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
933
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
934
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
935
|
+
*
|
|
936
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
937
|
+
* caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
|
|
938
|
+
* CDP target id, and the two never match. The first version returned everything and relied on the
|
|
939
|
+
* caller to filter, which reported the same page twice.
|
|
940
|
+
*
|
|
941
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
942
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
943
|
+
* @returns the browser's pages that are not ours, or an empty list when it cannot answer.
|
|
944
|
+
*/
|
|
945
|
+
async listPages(): Promise<readonly CarrierPage[]> {
|
|
946
|
+
try {
|
|
947
|
+
const client = await this.ensureClient()
|
|
948
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
949
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string; title?: string }>
|
|
950
|
+
}
|
|
951
|
+
const ours = new Set(this.sessions.values())
|
|
952
|
+
return (listed?.targetInfos ?? [])
|
|
953
|
+
.filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
|
|
954
|
+
.filter(entry => !ours.has(entry.targetId as string))
|
|
955
|
+
.map(entry => ({
|
|
956
|
+
id: entry.targetId as string,
|
|
957
|
+
url: typeof entry.url === 'string' ? entry.url : '',
|
|
958
|
+
...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
|
|
959
|
+
}))
|
|
960
|
+
} catch {
|
|
961
|
+
// A listing must not fail harder than driving does; the next command reports a real problem.
|
|
962
|
+
return []
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Close one page by the target id {@link listPages} reported.
|
|
968
|
+
*
|
|
969
|
+
* `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
|
|
970
|
+
* created. The page may already be gone if the human closed it in the meantime — the caller
|
|
971
|
+
* asked for it to be closed and it is, so that is not an error.
|
|
972
|
+
* @param id - the CDP target id.
|
|
973
|
+
*/
|
|
974
|
+
async closePage(id: string): Promise<void> {
|
|
975
|
+
const client = await this.ensureClient()
|
|
976
|
+
await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined)
|
|
977
|
+
}
|
|
978
|
+
|
|
870
979
|
createView(): ElectronViewHandle {
|
|
871
980
|
const viewId = randomUUID()
|
|
872
981
|
return {
|