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
|
@@ -36,6 +36,15 @@ import { join } from 'node:path';
|
|
|
36
36
|
* exited, and that exit was reported as a bad browser path.
|
|
37
37
|
*/
|
|
38
38
|
const EXIT_GRACE_MS = 3_000;
|
|
39
|
+
/**
|
|
40
|
+
* How long a browser is given to close itself after being asked, before it is killed.
|
|
41
|
+
*
|
|
42
|
+
* Long enough for Chrome to flush its profile, which is what keeps the next launch from reading
|
|
43
|
+
* the profile as crashed and offering a session restore. Deliberately not the same knob as
|
|
44
|
+
* {@link EXIT_GRACE_MS}: that one waits for a browser to die because a NEW one cannot start until
|
|
45
|
+
* it does, this one merely prefers a graceful exit to a kill.
|
|
46
|
+
*/
|
|
47
|
+
const CLOSE_GRACE_MS = 6_000;
|
|
39
48
|
/**
|
|
40
49
|
* Ceiling on one attempt to ask a discovered CDP port who it is.
|
|
41
50
|
*
|
|
@@ -147,6 +156,33 @@ const INSTALL_LOCATIONS = {
|
|
|
147
156
|
* @param env - the environment to resolve against.
|
|
148
157
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
149
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
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately narrow: `about:blank` and Chrome's own new-tab pages, and nothing else. A wider
|
|
175
|
+
* test ("has no path", "starts with chrome://") would sooner or later match a page the user is
|
|
176
|
+
* reading, and the caller closes what this returns.
|
|
177
|
+
* @param url - the target's reported URL.
|
|
178
|
+
*/
|
|
179
|
+
function isBlankUrl(url) {
|
|
180
|
+
if (typeof url !== 'string')
|
|
181
|
+
return false;
|
|
182
|
+
return url === 'about:blank'
|
|
183
|
+
|| url.startsWith('chrome://newtab')
|
|
184
|
+
|| url.startsWith('chrome://new-tab-page');
|
|
185
|
+
}
|
|
150
186
|
function expandLocation(location, env) {
|
|
151
187
|
return location.replace(/%([^%]+)%/g, (whole, name) => env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole);
|
|
152
188
|
}
|
|
@@ -385,6 +421,16 @@ export class SystemBrowserViewHost {
|
|
|
385
421
|
*/
|
|
386
422
|
clientSuspect = false;
|
|
387
423
|
child;
|
|
424
|
+
/**
|
|
425
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
426
|
+
*
|
|
427
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
428
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
429
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
430
|
+
*/
|
|
431
|
+
adoptedRunning = false;
|
|
432
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
433
|
+
blanksNormalized = false;
|
|
388
434
|
starting;
|
|
389
435
|
/**
|
|
390
436
|
* Browsers that ignored a kill.
|
|
@@ -468,6 +514,10 @@ export class SystemBrowserViewHost {
|
|
|
468
514
|
forgetBrowserState() {
|
|
469
515
|
this.views.clear();
|
|
470
516
|
this.sessions.clear();
|
|
517
|
+
// A new browser comes with its own surplus pages, so the tidy-up runs again for it. Without
|
|
518
|
+
// this, a restart would inherit the previous connection's "already handled" and leave the new
|
|
519
|
+
// browser's extra blank page in place for the rest of the session.
|
|
520
|
+
this.blanksNormalized = false;
|
|
471
521
|
}
|
|
472
522
|
async ensureClient() {
|
|
473
523
|
// A disposed host must never start a browser: doing so would spawn a process
|
|
@@ -501,7 +551,78 @@ export class SystemBrowserViewHost {
|
|
|
501
551
|
}
|
|
502
552
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
503
553
|
this.starting ??= this.start().finally(() => { this.starting = undefined; });
|
|
504
|
-
|
|
554
|
+
const client = await this.starting;
|
|
555
|
+
// Once per connection. A browser opens more than one page when it starts, and the surplus is
|
|
556
|
+
// invisible to the plugin's tab model — see normalizeBlankPages for why that matters.
|
|
557
|
+
if (!this.blanksNormalized) {
|
|
558
|
+
this.blanksNormalized = true;
|
|
559
|
+
await this.normalizeBlankPages(client);
|
|
560
|
+
}
|
|
561
|
+
return client;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
565
|
+
*
|
|
566
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
567
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
568
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
569
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
570
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
571
|
+
* exactly what was reported.
|
|
572
|
+
*
|
|
573
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
574
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
575
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
576
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
577
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
578
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
579
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
580
|
+
*/
|
|
581
|
+
async adoptRunningBrowser(why) {
|
|
582
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
583
|
+
let port;
|
|
584
|
+
try {
|
|
585
|
+
if (!existsSync(portFile))
|
|
586
|
+
return undefined;
|
|
587
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '');
|
|
588
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
589
|
+
return undefined;
|
|
590
|
+
port = value;
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
597
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
598
|
+
})).json();
|
|
599
|
+
if (typeof version.webSocketDebuggerUrl !== 'string')
|
|
600
|
+
return undefined;
|
|
601
|
+
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
602
|
+
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
603
|
+
if (!isBrowserProduct(version.Browser))
|
|
604
|
+
return undefined;
|
|
605
|
+
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
606
|
+
await client.whenReady();
|
|
607
|
+
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
608
|
+
// mistaken for a browser that answers.
|
|
609
|
+
const info = await client.send('Browser.getVersion', {});
|
|
610
|
+
if (!isBrowserProduct(info?.product)) {
|
|
611
|
+
client.close();
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
if (this.disposed) {
|
|
615
|
+
client.close();
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
this.client = client;
|
|
619
|
+
this.adoptedRunning = true;
|
|
620
|
+
return client;
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// No endpoint, nothing listening, or not a browser: all of them mean "launch one".
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
505
626
|
}
|
|
506
627
|
/**
|
|
507
628
|
* Launch the browser with a private profile and connect over CDP.
|
|
@@ -512,6 +633,12 @@ export class SystemBrowserViewHost {
|
|
|
512
633
|
* @returns the connected client.
|
|
513
634
|
*/
|
|
514
635
|
async start() {
|
|
636
|
+
// Before spawning anything: is a browser for THIS profile already running? A DSH restart
|
|
637
|
+
// leaves one behind, and launching into it is the failure reported as "exited immediately
|
|
638
|
+
// after launch (exit code 0)" that required killing seven processes by hand.
|
|
639
|
+
const adopted = await this.adoptRunningBrowser('before launch');
|
|
640
|
+
if (adopted !== undefined)
|
|
641
|
+
return adopted;
|
|
515
642
|
const args = [
|
|
516
643
|
'--remote-debugging-port=0',
|
|
517
644
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -569,15 +696,14 @@ export class SystemBrowserViewHost {
|
|
|
569
696
|
});
|
|
570
697
|
});
|
|
571
698
|
}
|
|
572
|
-
// The port file
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
catch { /* nothing to remove */ }
|
|
699
|
+
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
700
|
+
//
|
|
701
|
+
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
702
|
+
// the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
|
|
703
|
+
// deletion never protected it — while it DID destroy the only record of a browser that may
|
|
704
|
+
// still be running for this profile. That record is what `adoptRunningBrowser` needs when the
|
|
705
|
+
// launch comes back as a hand-off: the browser was alive the whole time, and without the file
|
|
706
|
+
// there is no way to find it again.
|
|
581
707
|
const spawnedAt = Date.now();
|
|
582
708
|
let child;
|
|
583
709
|
try {
|
|
@@ -728,6 +854,14 @@ export class SystemBrowserViewHost {
|
|
|
728
854
|
const code = child.exitCode;
|
|
729
855
|
const signal = child.signalCode;
|
|
730
856
|
const clean = code === 0 && signal === null;
|
|
857
|
+
// Exit code 0 IS the hand-off: the request went to an instance already holding this profile,
|
|
858
|
+
// so that instance is our browser and it is running right now. Adopting it turns the
|
|
859
|
+
// reported "kill the leftovers by hand, on every restart" into a call that just works.
|
|
860
|
+
if (clean) {
|
|
861
|
+
const recovered = await this.adoptRunningBrowser('after a hand-off');
|
|
862
|
+
if (recovered !== undefined)
|
|
863
|
+
return recovered;
|
|
864
|
+
}
|
|
731
865
|
throw new Error(clean
|
|
732
866
|
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
733
867
|
+ 'That means it handed the request to an instance already using this profile '
|
|
@@ -753,6 +887,66 @@ export class SystemBrowserViewHost {
|
|
|
753
887
|
available() {
|
|
754
888
|
return !this.disposed;
|
|
755
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Every page the browser currently holds, including ones this plugin did not create.
|
|
892
|
+
*
|
|
893
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
894
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
895
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
896
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
897
|
+
*
|
|
898
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
899
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
900
|
+
* @returns the browser's pages, or an empty list when it cannot answer.
|
|
901
|
+
*/
|
|
902
|
+
/**
|
|
903
|
+
* Every page the browser holds that THIS HOST did not open.
|
|
904
|
+
*
|
|
905
|
+
* Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
|
|
906
|
+
* `window.open` — produces a page the provider never asked for, and nothing reports it back.
|
|
907
|
+
* Those pages are real: they hold memory and stay until something closes them. Measured on a real
|
|
908
|
+
* window: four pages present while `dsh_browser_list_tabs` reported one.
|
|
909
|
+
*
|
|
910
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
911
|
+
* caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
|
|
912
|
+
* CDP target id, and the two never match. The first version returned everything and relied on the
|
|
913
|
+
* caller to filter, which reported the same page twice.
|
|
914
|
+
*
|
|
915
|
+
* Discovery is already on at connection time (see the blank-page normalisation), so
|
|
916
|
+
* `Target.getTargets` also lists pages that existed before this client connected.
|
|
917
|
+
* @returns the browser's pages that are not ours, or an empty list when it cannot answer.
|
|
918
|
+
*/
|
|
919
|
+
async listPages() {
|
|
920
|
+
try {
|
|
921
|
+
const client = await this.ensureClient();
|
|
922
|
+
const listed = await client.send('Target.getTargets', {});
|
|
923
|
+
const ours = new Set(this.sessions.values());
|
|
924
|
+
return (listed?.targetInfos ?? [])
|
|
925
|
+
.filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
|
|
926
|
+
.filter(entry => !ours.has(entry.targetId))
|
|
927
|
+
.map(entry => ({
|
|
928
|
+
id: entry.targetId,
|
|
929
|
+
url: typeof entry.url === 'string' ? entry.url : '',
|
|
930
|
+
...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
// A listing must not fail harder than driving does; the next command reports a real problem.
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Close one page by the target id {@link listPages} reported.
|
|
940
|
+
*
|
|
941
|
+
* `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
|
|
942
|
+
* created. The page may already be gone if the human closed it in the meantime — the caller
|
|
943
|
+
* asked for it to be closed and it is, so that is not an error.
|
|
944
|
+
* @param id - the CDP target id.
|
|
945
|
+
*/
|
|
946
|
+
async closePage(id) {
|
|
947
|
+
const client = await this.ensureClient();
|
|
948
|
+
await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined);
|
|
949
|
+
}
|
|
756
950
|
createView() {
|
|
757
951
|
const viewId = randomUUID();
|
|
758
952
|
return {
|
|
@@ -892,20 +1086,39 @@ export class SystemBrowserViewHost {
|
|
|
892
1086
|
if (this.disposed)
|
|
893
1087
|
return;
|
|
894
1088
|
this.disposed = true;
|
|
1089
|
+
const client = this.client;
|
|
895
1090
|
this.forgetBrowserState();
|
|
896
|
-
this.client
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
// running means the next launch contends with a browser nobody owns.
|
|
903
|
-
for (const leftover of this.stubborn.splice(0)) {
|
|
1091
|
+
this.client = undefined;
|
|
1092
|
+
// Ask before killing. `kill()` on Windows is TerminateProcess, which gives the browser no
|
|
1093
|
+
// chance to flush its profile — and Chrome records how it ended in that profile, so a
|
|
1094
|
+
// hard kill leaves it marked as a crash and the NEXT launch offers to restore a session.
|
|
1095
|
+
// That is a visible symptom (an extra window) traced all the way back to this line.
|
|
1096
|
+
if (client !== undefined) {
|
|
904
1097
|
try {
|
|
905
|
-
|
|
1098
|
+
void client.send('Browser.close', {}).catch(() => { });
|
|
906
1099
|
}
|
|
907
1100
|
catch { /* already gone */ }
|
|
908
1101
|
}
|
|
1102
|
+
// Both the browser we launched and anything that ignored an earlier kill. They hold this
|
|
1103
|
+
// profile, so leaving them running means the next launch contends with a browser nobody owns.
|
|
1104
|
+
const doomed = [this.child, ...this.stubborn.splice(0)].filter((entry) => entry !== undefined);
|
|
1105
|
+
this.child = undefined;
|
|
1106
|
+
for (const dying of doomed) {
|
|
1107
|
+
/**
|
|
1108
|
+
* A bounded reprieve, then the kill we used to do immediately.
|
|
1109
|
+
*
|
|
1110
|
+
* The timer is unref'd so it never holds the process open: a DSH shutdown that is itself
|
|
1111
|
+
* ending does not wait for this, and the browser — having been asked to close — generally
|
|
1112
|
+
* gets there on its own. If it does not, the kill still lands while the process lives.
|
|
1113
|
+
*/
|
|
1114
|
+
const force = () => { try {
|
|
1115
|
+
dying.kill();
|
|
1116
|
+
}
|
|
1117
|
+
catch { /* already gone */ } };
|
|
1118
|
+
const timer = setTimeout(force, CLOSE_GRACE_MS);
|
|
1119
|
+
timer.unref?.();
|
|
1120
|
+
dying.once('exit', () => { clearTimeout(timer); });
|
|
1121
|
+
}
|
|
909
1122
|
const ephemeral = this.ephemeralDir;
|
|
910
1123
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
911
1124
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
@@ -957,10 +1170,15 @@ export class SystemBrowserViewHost {
|
|
|
957
1170
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
958
1171
|
async buildSession(viewId) {
|
|
959
1172
|
const client = await this.ensureClient();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1173
|
+
// Adopt a blank page the browser opened on its own before creating another one.
|
|
1174
|
+
//
|
|
1175
|
+
// Chrome starts with more than one page when handed a URL on top of its own start page, so
|
|
1176
|
+
// creating a target here discarded the original and left it behind as a tab that is not in
|
|
1177
|
+
// the plugin's tab model — invisible to `dsh_browser_list_tabs`, and therefore unclosable
|
|
1178
|
+
// through the tools. Taking the blank instead makes the page the browser already opened the
|
|
1179
|
+
// one the caller gets.
|
|
1180
|
+
const adopted = await this.adoptBlankTarget(client);
|
|
1181
|
+
const targetId = adopted ?? await this.createTarget(client);
|
|
964
1182
|
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
965
1183
|
const sessionId = attached?.sessionId;
|
|
966
1184
|
if (typeof sessionId !== 'string')
|
|
@@ -970,6 +1188,94 @@ export class SystemBrowserViewHost {
|
|
|
970
1188
|
this.views.set(viewId, { session: sessionId, client });
|
|
971
1189
|
return sessionId;
|
|
972
1190
|
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
1193
|
+
*
|
|
1194
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
1195
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
1196
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
1197
|
+
* @param client - the connected browser.
|
|
1198
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
1199
|
+
*/
|
|
1200
|
+
async adoptBlankTarget(client) {
|
|
1201
|
+
try {
|
|
1202
|
+
const listed = await client.send('Target.getTargets', {});
|
|
1203
|
+
const claimed = new Set(this.sessions.values());
|
|
1204
|
+
for (const entry of listed?.targetInfos ?? []) {
|
|
1205
|
+
if (entry.type !== 'page' || typeof entry.targetId !== 'string')
|
|
1206
|
+
continue;
|
|
1207
|
+
if (!isBlankUrl(entry.url))
|
|
1208
|
+
continue;
|
|
1209
|
+
if (claimed.has(entry.targetId))
|
|
1210
|
+
continue;
|
|
1211
|
+
return entry.targetId;
|
|
1212
|
+
}
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
// Discovery is best-effort; creating a page is the behaviour this replaced.
|
|
1217
|
+
return undefined;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/** Create one page, refusing to go on without an id. */
|
|
1221
|
+
async createTarget(client) {
|
|
1222
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
1223
|
+
const targetId = created?.targetId;
|
|
1224
|
+
if (typeof targetId !== 'string')
|
|
1225
|
+
throw new Error('dsh-builtin-browser: the browser did not create a page');
|
|
1226
|
+
return targetId;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
1230
|
+
*
|
|
1231
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
1232
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
1233
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
1234
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
1235
|
+
*
|
|
1236
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
1237
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
1238
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
1239
|
+
*
|
|
1240
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
1241
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
1242
|
+
* reason the first attempt at this fix failed silently.
|
|
1243
|
+
* @param client - the connected browser.
|
|
1244
|
+
* @returns how many surplus blank pages were closed.
|
|
1245
|
+
*/
|
|
1246
|
+
async normalizeBlankPages(client) {
|
|
1247
|
+
try {
|
|
1248
|
+
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
1249
|
+
const listed = await client.send('Target.getTargets', {});
|
|
1250
|
+
const blanks = (listed?.targetInfos ?? []).filter(entry => entry.type === 'page' && typeof entry.targetId === 'string' && isBlankUrl(entry.url));
|
|
1251
|
+
const claimed = new Set(this.sessions.values());
|
|
1252
|
+
let closed = 0;
|
|
1253
|
+
// Keep the first unclaimed blank (that is the one buildSession will adopt) and close the rest.
|
|
1254
|
+
let kept = false;
|
|
1255
|
+
for (const entry of blanks) {
|
|
1256
|
+
const targetId = entry.targetId;
|
|
1257
|
+
if (claimed.has(targetId))
|
|
1258
|
+
continue;
|
|
1259
|
+
if (!kept) {
|
|
1260
|
+
kept = true;
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
await client.send('Target.closeTarget', { targetId });
|
|
1265
|
+
closed += 1;
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
// It may already be gone; one failure must not stop the rest.
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return closed;
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
// A browser that will not answer Target commands has a real problem, and the next command
|
|
1275
|
+
// will report it. Turning a tidy-up into a functional error here would hide that.
|
|
1276
|
+
return 0;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
973
1279
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
974
1280
|
sessionBuilding = new Map();
|
|
975
1281
|
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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
|