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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type VisitedPage } from './history-store.js';
|
|
10
10
|
import type { BrowserSettings } from './settings-store.js';
|
|
11
11
|
import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
|
|
12
|
+
import type { CarrierPage } from '../browser/types.js';
|
|
12
13
|
/** Stable provider id registered with `ctx.browser`. */
|
|
13
14
|
export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
|
|
14
15
|
/**
|
|
@@ -58,6 +59,35 @@ export interface ElectronBrowserViewHost {
|
|
|
58
59
|
* @param label - human-readable session/task label, as in {@link showView}.
|
|
59
60
|
*/
|
|
60
61
|
presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Optional: pages this carrier holds that the provider never created.
|
|
64
|
+
*
|
|
65
|
+
* A sidebar panel and an installed browser both own their own page creation. Clicking a link
|
|
66
|
+
* that opens a new page — or any `window.open` the page performs — produces a page the provider
|
|
67
|
+
* did not ask for and therefore never recorded. Those pages are real: they are what the human
|
|
68
|
+
* sees, they hold memory, and they persist until something closes them. Without this the tab
|
|
69
|
+
* model reports only what the provider itself made, so the extras are invisible to
|
|
70
|
+
* `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`, accumulating for the
|
|
71
|
+
* life of the browser.
|
|
72
|
+
*
|
|
73
|
+
* Views the host itself opened are NOT included. That exclusion belongs to the host, which is the
|
|
74
|
+
* only side that can make it: a provider tab is identified by the provider's own id and a carrier
|
|
75
|
+
* page by the carrier's, so a caller comparing the two matches nothing and reports every page
|
|
76
|
+
* twice. Measured on the sidebar carrier: three clicks produced three webview guests while
|
|
77
|
+
* `dsh_browser_list_tabs` reported one tab; on the installed-browser carrier the window held four
|
|
78
|
+
* pages and the tool reported one.
|
|
79
|
+
* @returns every page the carrier holds that this host did not open.
|
|
80
|
+
*/
|
|
81
|
+
listPages?(): Promise<readonly CarrierPage[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Optional: close one page reported by {@link listPages}.
|
|
84
|
+
*
|
|
85
|
+
* The pair exists because listing a page the caller cannot close is worse than not listing it:
|
|
86
|
+
* `dsh_browser_list_tabs` would report pages that `dsh_browser_close_tab` answers "not open in
|
|
87
|
+
* this session" about, which reads as a contradiction rather than a boundary.
|
|
88
|
+
* @param id - the carrier's own page id, as {@link listPages} reported it.
|
|
89
|
+
*/
|
|
90
|
+
closePage?(id: string): Promise<void>;
|
|
61
91
|
/**
|
|
62
92
|
* Optional cheap usability probe (no network): whether the host can back
|
|
63
93
|
* views at all right now. The self-hosted host checks for a usable Electron
|
|
@@ -303,6 +333,15 @@ export interface CdpNavigateParams {
|
|
|
303
333
|
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
304
334
|
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
305
335
|
* release halves; a move carries only the position.
|
|
336
|
+
*
|
|
337
|
+
* `buttons` is a BITMASK of what is held down at the moment of the event, and it is not the same
|
|
338
|
+
* field as `button` — `button` says which one this event is about, `buttons` says which ones are
|
|
339
|
+
* down. Chromium needs both to recognise a synthesized press as a real button press: with
|
|
340
|
+
* `buttons` omitted (0), the event still reaches the page and a `click` listener still fires, but
|
|
341
|
+
* the element's DEFAULT ACTION does not run. Measured on the sidebar carrier: a click on an
|
|
342
|
+
* anchor sent `mousedown`/`mouseup`/`click` to the page with `isTrusted: true` and
|
|
343
|
+
* `defaultPrevented: false`, and the browser did not follow the link — including for an anchor
|
|
344
|
+
* injected by the test itself, so the page was not responsible.
|
|
306
345
|
*/
|
|
307
346
|
export interface CdpMouseParams {
|
|
308
347
|
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
|
|
@@ -310,6 +349,8 @@ export interface CdpMouseParams {
|
|
|
310
349
|
readonly y: number;
|
|
311
350
|
readonly button?: 'left';
|
|
312
351
|
readonly clickCount?: number;
|
|
352
|
+
/** Buttons held during the event: 1 = primary down. A move carries 0. */
|
|
353
|
+
readonly buttons?: number;
|
|
313
354
|
}
|
|
314
355
|
/** CDP method/params for `Input.insertText`. */
|
|
315
356
|
export interface CdpInsertTextParams {
|
|
@@ -384,6 +384,28 @@ export class ElectronBrowserProvider {
|
|
|
384
384
|
active: i === s.activeIndex,
|
|
385
385
|
});
|
|
386
386
|
}
|
|
387
|
+
// Pages the CARRIER holds that this provider never created.
|
|
388
|
+
//
|
|
389
|
+
// A sidebar panel and an installed browser both open pages on their own: a click on a link
|
|
390
|
+
// that targets a new page, or any `window.open`, produces a page nothing tells us about.
|
|
391
|
+
// Reporting only our own tabs made those invisible to this tool and unclosable through
|
|
392
|
+
// `dsh_browser_close_tab`, so they accumulated for the life of the browser. Measured on the
|
|
393
|
+
// sidebar carrier: three clicks produced three webview guests while this listed one tab.
|
|
394
|
+
//
|
|
395
|
+
// `listPages` already excludes the pages it opened the view for — that filtering cannot happen
|
|
396
|
+
// here, because a tab is known by this provider's own id and a carrier page by the carrier's,
|
|
397
|
+
// and the two never match. A first version compared them anyway and listed every page twice.
|
|
398
|
+
//
|
|
399
|
+
// They are appended under a `page:` prefix so a caller can tell the two kinds apart — the
|
|
400
|
+
// prefix is also what `closeTab` uses to decide whether to close a view or a carrier page.
|
|
401
|
+
const pages = await this.host.listPages?.().catch(() => undefined) ?? [];
|
|
402
|
+
for (const page of pages) {
|
|
403
|
+
// A page with no URL is a blank one the carrier opened for itself. Listing it would invite a
|
|
404
|
+
// caller to act on nothing, and the blank normalisation already closes those.
|
|
405
|
+
if (page.url === '' || page.url === 'about:blank')
|
|
406
|
+
continue;
|
|
407
|
+
result.push({ id: `page:${page.id}`, url: page.url, active: false });
|
|
408
|
+
}
|
|
387
409
|
return result;
|
|
388
410
|
}
|
|
389
411
|
/** Switch to a tab by id, making its view visible. */
|
|
@@ -395,6 +417,16 @@ export class ElectronBrowserProvider {
|
|
|
395
417
|
}
|
|
396
418
|
/** Close one tab; closing the active tab activates the next. */
|
|
397
419
|
closeTab(session, tabId) {
|
|
420
|
+
// A page the carrier owns rather than a view we created. It was reported by `listTabs` under
|
|
421
|
+
// this prefix, so closing it here is what keeps the two tools consistent — a page that can be
|
|
422
|
+
// listed must be closable, or the listing reads as a contradiction.
|
|
423
|
+
if (tabId.startsWith('page:')) {
|
|
424
|
+
const id = tabId.slice('page:'.length);
|
|
425
|
+
if (this.host.closePage === undefined) {
|
|
426
|
+
return Promise.reject(new BrowserError('browser: this carrier does not expose the pages it opens on its own, so that page cannot be closed', 'BROWSER_TAB_NOT_FOUND'));
|
|
427
|
+
}
|
|
428
|
+
return this.host.closePage(id);
|
|
429
|
+
}
|
|
398
430
|
const found = this.locateTab(session, tabId);
|
|
399
431
|
const s = found.s;
|
|
400
432
|
const index = found.index;
|
|
@@ -1290,7 +1322,7 @@ export class ElectronBrowserProvider {
|
|
|
1290
1322
|
// landed because the first had quietly established the target. Move the
|
|
1291
1323
|
// pointer first so a click is self-contained.
|
|
1292
1324
|
try {
|
|
1293
|
-
await send({ type: 'mouseMoved', x, y }, 'move');
|
|
1325
|
+
await send({ type: 'mouseMoved', x, y, buttons: 0 }, 'move');
|
|
1294
1326
|
}
|
|
1295
1327
|
catch (error) {
|
|
1296
1328
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
@@ -1311,12 +1343,13 @@ export class ElectronBrowserProvider {
|
|
|
1311
1343
|
x,
|
|
1312
1344
|
y,
|
|
1313
1345
|
button: 'left',
|
|
1346
|
+
buttons: 0,
|
|
1314
1347
|
clickCount: 1,
|
|
1315
1348
|
})
|
|
1316
1349
|
.catch(() => { });
|
|
1317
1350
|
};
|
|
1318
1351
|
try {
|
|
1319
|
-
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
|
|
1352
|
+
await send({ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, 'press');
|
|
1320
1353
|
}
|
|
1321
1354
|
catch (error) {
|
|
1322
1355
|
// The press may still land late; release best-effort so the button is
|
|
@@ -1325,7 +1358,7 @@ export class ElectronBrowserProvider {
|
|
|
1325
1358
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
1326
1359
|
}
|
|
1327
1360
|
try {
|
|
1328
|
-
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
|
|
1361
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, 'release');
|
|
1329
1362
|
}
|
|
1330
1363
|
catch (error) {
|
|
1331
1364
|
// The press already landed; retry the release so the button is not
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>;
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
38
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
39
|
+
import type { CarrierPage } from '../browser/types.js';
|
|
39
40
|
/** Which browser the user asked for. */
|
|
40
41
|
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
|
|
41
42
|
/** A resolved browser installation. */
|
|
@@ -213,6 +214,45 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
213
214
|
* make the plugin fall back for no reason.
|
|
214
215
|
*/
|
|
215
216
|
available(): boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Every page the browser currently holds, including ones this plugin did not create.
|
|
219
|
+
*
|
|
220
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
221
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
222
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
223
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
224
|
+
*
|
|
225
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
226
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
227
|
+
* @returns the browser's pages, or an empty list when it cannot answer.
|
|
228
|
+
*/
|
|
229
|
+
/**
|
|
230
|
+
* Every page the browser holds that THIS HOST did not open.
|
|
231
|
+
*
|
|
232
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
233
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
234
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
235
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
236
|
+
*
|
|
237
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
238
|
+
* caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
|
|
239
|
+
* CDP target id, and the two never match. The first version returned everything and relied on the
|
|
240
|
+
* caller to filter, which reported the same page twice.
|
|
241
|
+
*
|
|
242
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
243
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
244
|
+
* @returns the browser's pages that are not ours, or an empty list when it cannot answer.
|
|
245
|
+
*/
|
|
246
|
+
listPages(): Promise<readonly CarrierPage[]>;
|
|
247
|
+
/**
|
|
248
|
+
* Close one page by the target id {@link listPages} reported.
|
|
249
|
+
*
|
|
250
|
+
* `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
|
|
251
|
+
* created. The page may already be gone if the human closed it in the meantime — the caller
|
|
252
|
+
* asked for it to be closed and it is, so that is not an error.
|
|
253
|
+
* @param id - the CDP target id.
|
|
254
|
+
*/
|
|
255
|
+
closePage(id: string): Promise<void>;
|
|
216
256
|
createView(): ElectronViewHandle;
|
|
217
257
|
destroyView(handle: ElectronViewHandle): void;
|
|
218
258
|
/** Nothing to show: the browser owns its own windows. */
|
|
@@ -156,6 +156,18 @@ const INSTALL_LOCATIONS = {
|
|
|
156
156
|
* @param env - the environment to resolve against.
|
|
157
157
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
158
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* Whether a browser's self-reported product name is one this carrier could have launched.
|
|
161
|
+
*
|
|
162
|
+
* `edg`, not `edge`: Edge reports itself as `Edg/155.0.4283.45`, both over HTTP and over the
|
|
163
|
+
* protocol. Testing for the full word rejected a live Edge that had just been started, so the
|
|
164
|
+
* call failed with the hand-off error this check was added to prevent — while the browser was
|
|
165
|
+
* running the whole time, answering on its own port.
|
|
166
|
+
* @param product - the product string a browser reports about itself.
|
|
167
|
+
*/
|
|
168
|
+
function isBrowserProduct(product) {
|
|
169
|
+
return typeof product === 'string' && /chrome|chromium|edg/i.test(product);
|
|
170
|
+
}
|
|
159
171
|
/**
|
|
160
172
|
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
161
173
|
*
|
|
@@ -588,14 +600,14 @@ export class SystemBrowserViewHost {
|
|
|
588
600
|
return undefined;
|
|
589
601
|
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
590
602
|
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
591
|
-
if (
|
|
603
|
+
if (!isBrowserProduct(version.Browser))
|
|
592
604
|
return undefined;
|
|
593
605
|
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
594
606
|
await client.whenReady();
|
|
595
607
|
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
596
608
|
// mistaken for a browser that answers.
|
|
597
609
|
const info = await client.send('Browser.getVersion', {});
|
|
598
|
-
if (
|
|
610
|
+
if (!isBrowserProduct(info?.product)) {
|
|
599
611
|
client.close();
|
|
600
612
|
return undefined;
|
|
601
613
|
}
|
|
@@ -687,11 +699,38 @@ export class SystemBrowserViewHost {
|
|
|
687
699
|
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
688
700
|
//
|
|
689
701
|
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
690
|
-
// the loop
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
702
|
+
// the loop judges freshness against a snapshot taken below, so the deletion never protected it
|
|
703
|
+
// — while it DID destroy the only record of a browser that may still be running for this
|
|
704
|
+
// profile. That record is what `adoptRunningBrowser` needs when the launch comes back as a
|
|
705
|
+
// hand-off: the browser was alive the whole time, and without the file there is no way to find
|
|
706
|
+
// it again.
|
|
707
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
708
|
+
/**
|
|
709
|
+
* What the port file was like BEFORE this launch, so "fresh" is judged against a snapshot
|
|
710
|
+
* rather than against a clock.
|
|
711
|
+
*
|
|
712
|
+
* The test used to be `mtimeMs < spawnedAt` — a filesystem timestamp compared to `Date.now()`.
|
|
713
|
+
* Those are different clocks with different resolutions, and CI is where it showed: a file the
|
|
714
|
+
* fake browser had just written was rejected as stale, so the loop waited out its full 30s
|
|
715
|
+
* against a browser that was already listening, because the file's mtime had rounded down
|
|
716
|
+
* below the spawn time. That is not a test-only problem: on any filesystem whose mtime
|
|
717
|
+
* resolution is coarser than a millisecond, the plugin would fail to see the browser it had
|
|
718
|
+
* just launched, and report a CDP timeout for a browser that was up the whole time.
|
|
719
|
+
*
|
|
720
|
+
* `undefined` means there was no file, so any file that appears afterwards is fresh. When one
|
|
721
|
+
* existed, a later mtime OR different contents makes the new one fresh. Byte-identical contents
|
|
722
|
+
* with an unchanged mtime is the PREVIOUS browser's port, and adopting it is what this whole
|
|
723
|
+
* check exists to prevent: it may name a port that now belongs to something else entirely.
|
|
724
|
+
*/
|
|
725
|
+
const portFileBefore = (() => {
|
|
726
|
+
try {
|
|
727
|
+
return { mtimeMs: statSync(portFile).mtimeMs, contents: readFileSync(portFile, 'utf8') };
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
})();
|
|
733
|
+
// Kept for the failure message and for the exit listener's diagnostics; no longer a judgement.
|
|
695
734
|
const spawnedAt = Date.now();
|
|
696
735
|
let child;
|
|
697
736
|
try {
|
|
@@ -727,22 +766,28 @@ export class SystemBrowserViewHost {
|
|
|
727
766
|
// Clearing these is what makes the restart usable rather than merely alive.
|
|
728
767
|
this.forgetBrowserState();
|
|
729
768
|
});
|
|
730
|
-
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
731
769
|
/**
|
|
732
770
|
* The port this launch wrote, or undefined while it has not written one yet.
|
|
733
771
|
*
|
|
734
|
-
* A file left by the PREVIOUS browser is not evidence about this one. Its contents
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
772
|
+
* A file left by the PREVIOUS browser is not evidence about this one. Its contents name a port
|
|
773
|
+
* that may now belong to something else entirely — another Chromium, a `node --inspect` — and
|
|
774
|
+
* connecting there publishes a client this host does not own and dispose() can never kill.
|
|
775
|
+
* Only a file that CHANGED across this launch counts, which is what `portFileBefore` records.
|
|
738
776
|
*/
|
|
739
777
|
const freshPort = () => {
|
|
740
778
|
try {
|
|
741
779
|
if (!existsSync(portFile))
|
|
742
780
|
return undefined;
|
|
743
|
-
|
|
781
|
+
const after = statSync(portFile);
|
|
782
|
+
const contents = readFileSync(portFile, 'utf8');
|
|
783
|
+
// Unchanged since before the launch: this is the previous browser's file, whatever its
|
|
784
|
+
// mtime says. Comparing against the snapshot rather than the spawn time is what keeps this
|
|
785
|
+
// correct on a filesystem whose timestamps are coarser than the clock behind spawnedAt.
|
|
786
|
+
if (portFileBefore !== undefined
|
|
787
|
+
&& after.mtimeMs <= portFileBefore.mtimeMs
|
|
788
|
+
&& contents === portFileBefore.contents)
|
|
744
789
|
return undefined;
|
|
745
|
-
const value = Number(
|
|
790
|
+
const value = Number(contents.split('\n')[0]?.trim() ?? '');
|
|
746
791
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
747
792
|
}
|
|
748
793
|
catch {
|
|
@@ -875,6 +920,66 @@ export class SystemBrowserViewHost {
|
|
|
875
920
|
available() {
|
|
876
921
|
return !this.disposed;
|
|
877
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* Every page the browser currently holds, including ones this plugin did not create.
|
|
925
|
+
*
|
|
926
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
927
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
928
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
929
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
930
|
+
*
|
|
931
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
932
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
933
|
+
* @returns the browser's pages, or an empty list when it cannot answer.
|
|
934
|
+
*/
|
|
935
|
+
/**
|
|
936
|
+
* Every page the browser holds that THIS HOST did not open.
|
|
937
|
+
*
|
|
938
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
939
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
940
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
941
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
942
|
+
*
|
|
943
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
944
|
+
* caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
|
|
945
|
+
* CDP target id, and the two never match. The first version returned everything and relied on the
|
|
946
|
+
* caller to filter, which reported the same page twice.
|
|
947
|
+
*
|
|
948
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
949
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
950
|
+
* @returns the browser's pages that are not ours, or an empty list when it cannot answer.
|
|
951
|
+
*/
|
|
952
|
+
async listPages() {
|
|
953
|
+
try {
|
|
954
|
+
const client = await this.ensureClient();
|
|
955
|
+
const listed = await client.send('Target.getTargets', {});
|
|
956
|
+
const ours = new Set(this.sessions.values());
|
|
957
|
+
return (listed?.targetInfos ?? [])
|
|
958
|
+
.filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
|
|
959
|
+
.filter(entry => !ours.has(entry.targetId))
|
|
960
|
+
.map(entry => ({
|
|
961
|
+
id: entry.targetId,
|
|
962
|
+
url: typeof entry.url === 'string' ? entry.url : '',
|
|
963
|
+
...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
|
|
964
|
+
}));
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
// A listing must not fail harder than driving does; the next command reports a real problem.
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Close one page by the target id {@link listPages} reported.
|
|
973
|
+
*
|
|
974
|
+
* `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
|
|
975
|
+
* created. The page may already be gone if the human closed it in the meantime — the caller
|
|
976
|
+
* asked for it to be closed and it is, so that is not an error.
|
|
977
|
+
* @param id - the CDP target id.
|
|
978
|
+
*/
|
|
979
|
+
async closePage(id) {
|
|
980
|
+
const client = await this.ensureClient();
|
|
981
|
+
await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined);
|
|
982
|
+
}
|
|
878
983
|
createView() {
|
|
879
984
|
const viewId = randomUUID();
|
|
880
985
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/browser/types.ts
CHANGED
|
@@ -503,8 +503,15 @@ export interface BrowserTab {
|
|
|
503
503
|
readonly active: boolean
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
/**
|
|
507
|
-
export interface
|
|
506
|
+
/** One page a carrier holds, whether or not this plugin created it. */
|
|
507
|
+
export interface CarrierPage {
|
|
508
|
+
/** Stable within one carrier session; used to address the page. */
|
|
509
|
+
readonly id: string
|
|
510
|
+
readonly url: string
|
|
511
|
+
readonly title?: string
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Request to open a URL: into the active tab or a new tab. */export interface BrowserOpenRequest {
|
|
508
515
|
/** The URL to open. */
|
|
509
516
|
readonly url: string
|
|
510
517
|
/** Open in a new tab (parallel). Default false (reuse the active tab). */
|
|
@@ -30,6 +30,7 @@ import { join } from 'node:path'
|
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
32
32
|
import type { BrowserUserAction } from './provider.js'
|
|
33
|
+
import type { CarrierPage } from '../browser/types.js'
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
/** Absolute path of the endpoint file the shell writes. */
|
|
@@ -171,7 +172,26 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
171
172
|
const probe = new BridgeConnection(endpoint, bridgeEndpointPath())
|
|
172
173
|
try {
|
|
173
174
|
const answer = await probe.call({ op: 'list' }, 5_000)
|
|
174
|
-
|
|
175
|
+
if (answer.ok !== true) return undefined
|
|
176
|
+
// Refuse a panel this plugin cannot drive, before adopting it.
|
|
177
|
+
//
|
|
178
|
+
// The sidebar browser renders as a <webview> only where the shell exposes
|
|
179
|
+
// `dshDesktop.browser` and enables `webviewTag`. Where it renders as an <iframe> there is
|
|
180
|
+
// no webContents, no `getWebContentsId()` and no CDP endpoint — and every tool here is
|
|
181
|
+
// built on CDP, so adopting it means failing every call, with no path back to a carrier
|
|
182
|
+
// that works. Reported on DSH Desktop 2.0.17 with both conditions measured.
|
|
183
|
+
//
|
|
184
|
+
// Read here WITHOUT an owner on purpose, because the question is not about a conversation:
|
|
185
|
+
// whether the panel is a webview is decided by the shell's preload and window options, so
|
|
186
|
+
// it is the same answer for every conversation and needs no session context.
|
|
187
|
+
//
|
|
188
|
+
// `none` and `unknown` are NOT refusals: a panel that does not exist yet is created by the
|
|
189
|
+
// first open, and a probe that could not read the attribute has learned nothing.
|
|
190
|
+
if (String(answer.frame ?? 'unknown') === 'iframe') {
|
|
191
|
+
DesktopBridgeViewHost.refuseIframe = true
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
return new DesktopBridgeViewHost(endpoint, sessionId)
|
|
175
195
|
} finally {
|
|
176
196
|
probe.close()
|
|
177
197
|
}
|
|
@@ -181,11 +201,96 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
181
201
|
}
|
|
182
202
|
}
|
|
183
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Whether the last discovery refused a bridge because its sidebar panel is an iframe.
|
|
206
|
+
*
|
|
207
|
+
* Recorded so the entry point can say WHY the plugin is self-hosting on a shell that does have a
|
|
208
|
+
* bridge. Otherwise the user sees a second browser window and no explanation, on a machine where
|
|
209
|
+
* they just followed instructions to install the bridge.
|
|
210
|
+
*/
|
|
211
|
+
private static refuseIframe = false
|
|
212
|
+
|
|
213
|
+
/** Whether discovery refused a bridge for an undrivable sidebar panel (see {@link discover}). */
|
|
214
|
+
static refusedIframePanel(): boolean {
|
|
215
|
+
return this.refuseIframe
|
|
216
|
+
}
|
|
217
|
+
|
|
184
218
|
/** Whether this host can back views: the bridge already answered `list`. */
|
|
185
219
|
available(): boolean {
|
|
186
220
|
return true
|
|
187
221
|
}
|
|
188
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Every page this conversation's sidebar panel currently holds.
|
|
225
|
+
*
|
|
226
|
+
* The shell owns page creation inside the panel: a link the user or the agent clicks may open a
|
|
227
|
+
* new page, and nothing tells the provider. Those pages are real — they are what the human sees
|
|
228
|
+
* — but without this the tab model reports only the ones the provider created, so the rest are
|
|
229
|
+
* invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
|
|
230
|
+
*
|
|
231
|
+
* The bridge's `list` already answers with the sidebar guests, which is the same information, so
|
|
232
|
+
* this asks for it rather than reaching into the shell again.
|
|
233
|
+
* @returns the panel's pages, or an empty list when the bridge cannot answer.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Every sidebar page this conversation holds that THIS HOST did not open.
|
|
237
|
+
*
|
|
238
|
+
* The shell owns page creation inside the panel: a link the user or the agent clicks may open a
|
|
239
|
+
* new page, and nothing tells the provider. Those pages are real — they are what the human sees
|
|
240
|
+
* — but without this the tab model reports only the ones the provider created, so the rest are
|
|
241
|
+
* invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
|
|
242
|
+
*
|
|
243
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
244
|
+
* caller cannot do it: the provider knows a tab by its own id and the carrier knows a page by the
|
|
245
|
+
* guest id, and the two never match. The first version returned everything and relied on the
|
|
246
|
+
* caller to filter, which reported the same page twice — the mapping that makes the match
|
|
247
|
+
* possible exists only here.
|
|
248
|
+
* @returns the panel's pages that are not ours, or an empty list when the bridge cannot answer.
|
|
249
|
+
*/
|
|
250
|
+
async listPages(): Promise<readonly CarrierPage[]> {
|
|
251
|
+
try {
|
|
252
|
+
const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000)
|
|
253
|
+
const guests = Array.isArray(answer.sidebar) ? answer.sidebar : []
|
|
254
|
+
const ours = new Set<number>()
|
|
255
|
+
for (const view of this.views.values()) {
|
|
256
|
+
if (typeof view.guest === 'number') ours.add(view.guest)
|
|
257
|
+
}
|
|
258
|
+
return guests
|
|
259
|
+
.filter((guest): guest is { id: number; url?: unknown } => typeof guest?.id === 'number')
|
|
260
|
+
.filter(guest => !ours.has(guest.id))
|
|
261
|
+
.map(guest => ({
|
|
262
|
+
id: String(guest.id),
|
|
263
|
+
url: typeof guest.url === 'string' ? guest.url : '',
|
|
264
|
+
}))
|
|
265
|
+
} catch {
|
|
266
|
+
// A bridge that cannot answer has a real problem, and the next command reports it. Failing
|
|
267
|
+
// the tab LIST here would make a listing worse than the driving it exists to describe.
|
|
268
|
+
return []
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Close one sidebar page by the id {@link listPages} reported.
|
|
274
|
+
*
|
|
275
|
+
* The bridge closes sidebar pages by TITLE, and only the ones the caller owns — that is what
|
|
276
|
+
* stopped one conversation's teardown from destroying another's page. So the title is looked up
|
|
277
|
+
* through the same listing, and a page that no longer appears is treated as already gone rather
|
|
278
|
+
* than as an error: the caller asked for it to be closed, and it is.
|
|
279
|
+
* @param id - the sidebar guest id, as a string.
|
|
280
|
+
*/
|
|
281
|
+
async closePage(id: string): Promise<void> {
|
|
282
|
+
const numeric = Number(id)
|
|
283
|
+
if (!Number.isFinite(numeric)) throw new Error(`not a sidebar page id: ${id}`)
|
|
284
|
+
const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000)
|
|
285
|
+
const guests = Array.isArray(answer.sidebar) ? answer.sidebar : []
|
|
286
|
+
const title = guests
|
|
287
|
+
.filter((guest): guest is { id: number; title?: unknown } => Number(guest?.id) === numeric)
|
|
288
|
+
.map(guest => String(guest.title ?? ''))
|
|
289
|
+
.find(candidate => candidate !== '')
|
|
290
|
+
if (title === undefined) return
|
|
291
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles: [title], owner: this.owner }, 10_000)
|
|
292
|
+
}
|
|
293
|
+
|
|
189
294
|
/**
|
|
190
295
|
* The guest backing a view, materialized on first use.
|
|
191
296
|
*
|
|
@@ -223,7 +328,16 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
223
328
|
owner,
|
|
224
329
|
...url !== undefined && url !== '' ? { url } : {},
|
|
225
330
|
}, 30_000)
|
|
226
|
-
if (answer.ok !== true)
|
|
331
|
+
if (answer.ok !== true) {
|
|
332
|
+
// A bridge that answers but cannot produce a page is as unusable as one that has exited, and
|
|
333
|
+
// the difference matters here: `onLost` is what returns the surface to the self-hosted
|
|
334
|
+
// browser. Without this the same failure repeats on every call until DSH is restarted — on a
|
|
335
|
+
// shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
|
|
336
|
+
// the bridge made it worse than not installing it": the carrier was switched to one that
|
|
337
|
+
// cannot work, with no path back to the one that does.
|
|
338
|
+
if (String(answer.error).includes('produced no page')) this.reportLost()
|
|
339
|
+
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
|
|
340
|
+
}
|
|
227
341
|
const id = Number(answer.id)
|
|
228
342
|
if (!Number.isFinite(id) || id <= 0) {
|
|
229
343
|
throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation')
|
|
@@ -335,6 +335,21 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
335
335
|
adopt(sidebar, 'driving the desktop sidebar browser')
|
|
336
336
|
return
|
|
337
337
|
}
|
|
338
|
+
// A bridge exists but its sidebar panel is an iframe. Saying nothing here would leave the user
|
|
339
|
+
// with a second browser window on a machine where they just followed the instructions to
|
|
340
|
+
// install the bridge, and no way to know the two facts are related. The carrier is not
|
|
341
|
+
// unavailable by accident: an iframe has no CDP endpoint, so adopting it would fail every call
|
|
342
|
+
// instead of falling back — see DesktopBridgeViewHost.discover.
|
|
343
|
+
if (DesktopBridgeViewHost.refusedIframePanel()) {
|
|
344
|
+
ctx.logger?.warn?.(
|
|
345
|
+
'dsh-builtin-browser: a desktop bridge is installed, but this shell renders the sidebar '
|
|
346
|
+
+ 'browser panel as an iframe rather than a webview, which exposes no CDP endpoint and '
|
|
347
|
+
+ 'cannot be driven. Using the browser this plugin hosts itself instead — that is the '
|
|
348
|
+
+ 'independent window, not the sidebar. A shell that exposes `dshDesktop.browser` and '
|
|
349
|
+
+ 'enables `webviewTag` would provide a drivable panel.',
|
|
350
|
+
)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
338
353
|
// The user explicitly asked for the sidebar, so silence would leave them with a setting
|
|
339
354
|
// that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
|
|
340
355
|
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|