dsh-builtin-browser 0.4.8 → 0.4.9
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
CHANGED
|
@@ -4,6 +4,52 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
# 第四十五轮(2026-10-15,系统浏览器载体的启动生命周期:多开空白页 + 重启后孤儿)
|
|
8
|
+
|
|
9
|
+
报告者给了一份很硬的证据链 —— CDP `/json/list` 每 200ms 采样的时序、五处建议改法、以及**一条自我更正**(他撤回的第一条建议:去掉启动参数里的 URL 会让 Chrome 变成两个窗口,更糟)。两个问题都发生"把浏览器拉起来"的那一刻,除此之外毫无关系。
|
|
10
|
+
|
|
11
|
+
## 一、启动那个页面被丢弃,多出来的空白页无法关闭
|
|
12
|
+
|
|
13
|
+
**现象**:每次载体启动都看到 2 个空白标签,其中一个被关掉或残留;残留的那个**不在插件的标签模型里**(`dsh_browser_list_tabs` 只报 1 个),所以 `dsh_browser_close_tab` 关不掉它,窗口长期不关时会累积。
|
|
14
|
+
|
|
15
|
+
**根因两处**:
|
|
16
|
+
1. `start()` 的启动参数末尾带 `about:blank`,而 Chrome 自己的启动页也在 —— 实测**干净退出后的全新启动也是 2 个页面**,所以不是崩溃恢复,是 Chrome 的固有行为(报告者做了对照实验排除,并保留了那句参数:去掉它会变成 2 个**窗口**)。
|
|
17
|
+
2. `buildSession()` 用 `Target.createTarget({url:'about:blank'})` **新建**,而不是领养已经存在的那一个 → 启动那个被永久丢弃。
|
|
18
|
+
|
|
19
|
+
**修法**:连上后 `Target.setDiscoverTargets({discover:true})` + `Target.getTargets`,把无人认领的空白页收成**最多 1 个**(只关 `about:blank` 与 `chrome://newtab*`,且**绝不碰已在 `sessions` 里的**);`buildSession()` 先领养那个空白页,没有再新建。**`setDiscoverTargets` 不能省** —— 不先开发现,`getTargets` 可能列不出握手前就存在的页面,而**那正是需要关掉的那个**;报告者第一版补丁就是因此静默失效。
|
|
20
|
+
|
|
21
|
+
## 二、DSH 重启后旧的载体浏览器成为孤儿,下一次 open 必定失败
|
|
22
|
+
|
|
23
|
+
**现象**:重启 dsh 后,上次拉起的 Chrome 仍在跑;第一次调用直接报 `exited immediately after launch (exit code 0)`,必须手动杀掉用该 profile 的进程才能恢复。
|
|
24
|
+
|
|
25
|
+
**根因**:`start()` 里"杀掉上一个浏览器"只在 `this.child !== undefined` 时执行,而 **DSH 重启后新进程里 `this.child` 是空的** → 旧实例没人管 → 新实例把请求移交给它然后退出 0 → 而这个退出被判成"启动失败"。
|
|
26
|
+
|
|
27
|
+
**修法**:`start()` **在 spawn 之前**先 `adoptRunningBrowser()` —— 读本 profile 的 `DevToolsActivePort`,要求端点自报家门(`/json/version` 的 `Browser` 字段 + `Browser.getVersion` 的 `product`,都必须像浏览器),通过就接管,**不 spawn**;启动回来是 hand-off(exit 0)时再试一次接管。
|
|
28
|
+
|
|
29
|
+
**为什么"接管"是安全的**:启动循环刻意不信任上一次留下的端口文件,理由正当(端口号会被复用到非浏览器进程上)。但**这里多两个前提**:文件在**本插件自己的 profile 目录**里(只有用这个 `--user-data-dir` 启动的浏览器会写它),而且端点要**两次**自证是浏览器。
|
|
30
|
+
|
|
31
|
+
**顺带撤销一处有害的"保护"**:spawn 前删除 `DevToolsActivePort` 被去掉了。它保护不到任何东西 —— `freshPort()` 本来就只认 spawn 之后写入的文件 —— 却销毁了"那个浏览器还在跑"的唯一记录,而那正是接管所需要的。测试里那条"过期文件必须已被删除"的断言随之反正。
|
|
32
|
+
|
|
33
|
+
## 三、teardown:先请它自己关,再杀
|
|
34
|
+
|
|
35
|
+
`dispose()` 原本固定超时后 `kill()`,而 Windows 上那是 `TerminateProcess`:浏览器来不及写 profile,Chrome 会把这次结束记成崩溃 —— **下一次启动因此触发会话恢复,用户看到一个多出来的窗口**。现在先发 CDP `Browser.close`,给 `CLOSE_GRACE_MS`(6s,`unref` 的定时器,不拖住进程退出)让它自己走,仍在才杀。
|
|
36
|
+
|
|
37
|
+
## 四、这一轮最该记住的事:同一个位置之前碰过三次,每次只修了一层
|
|
38
|
+
|
|
39
|
+
用户的疑问是"我记得之前改过类似的,怎么又犯了"。查证后:**不是回归,是一个决定从来没被做过**。
|
|
40
|
+
|
|
41
|
+
| 轮次 | 当时解决的 | 当时没解决的 |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| 第三十三轮 | "只认本次启动写下的端口"(mtime),不再误连到别的进程 | 那个**真浏览器**被留在原地没人管 |
|
|
44
|
+
| 第三十六轮 | `'exited'` 的文案按退出码区分,不再把崩溃码也说成残留进程 | **它已经准确描述了 hand-off 这个现象,却仍然把它当失败报出来** |
|
|
45
|
+
| 第三十七轮 | `forgetBrowserState` 覆盖"进程已退出、`this.child` 被清空"的形态,视图不再指向死浏览器 | 孤儿进程**仍然占着 profile** |
|
|
46
|
+
|
|
47
|
+
三次都走到了同一个岔路口,三次都只修了自己那一面 —— **没人问"那浏览器还在跑,我们为什么不接上它"**。第三十六轮那句文案尤其值得记:它把 hand-off 描述得足够清楚,于是读到它的人和报告者都得出"去手动杀掉残留进程"的结论,而正确的结论是"这不该是一个错误"。
|
|
48
|
+
|
|
49
|
+
**测试**:254 → 260(新增 6 条生命周期守卫:接管先于启动、hand-off 读作"已经在那儿"、发现先于列举、只保留一个空白、领养而非丢弃、teardown 先请后杀)。`tsc` 干净。
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
7
53
|
# 第四十四轮(2026-10-14,工具名空间:`browser_*` → `dsh_browser_*`)
|
|
8
54
|
|
|
9
55
|
腾讯 BrowserSkill 的 DSH 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin` @0.3.2)与本插件同装时,它六个工具一个都注册不上。根因不是它的 bug:双方都往**全局**工具层注册了 `browser_session`,宿主对重名直接抛错,而它把注册包在 `try/catch` 里只 `console.warn` —— 桌面版看不到 stdout,于是表现成"插件完全没反应"。用户先怀疑的是它坏了。
|
|
@@ -111,6 +111,16 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
111
111
|
*/
|
|
112
112
|
private clientSuspect;
|
|
113
113
|
private child;
|
|
114
|
+
/**
|
|
115
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
116
|
+
*
|
|
117
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
118
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
119
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
120
|
+
*/
|
|
121
|
+
private adoptedRunning;
|
|
122
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
123
|
+
private blanksNormalized;
|
|
114
124
|
private starting;
|
|
115
125
|
/**
|
|
116
126
|
* Browsers that ignored a kill.
|
|
@@ -167,6 +177,25 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
167
177
|
*/
|
|
168
178
|
private forgetBrowserState;
|
|
169
179
|
private ensureClient;
|
|
180
|
+
/**
|
|
181
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
182
|
+
*
|
|
183
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
184
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
185
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
186
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
187
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
188
|
+
* exactly what was reported.
|
|
189
|
+
*
|
|
190
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
191
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
192
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
193
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
194
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
195
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
196
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
197
|
+
*/
|
|
198
|
+
private adoptRunningBrowser;
|
|
170
199
|
/**
|
|
171
200
|
* Launch the browser with a private profile and connect over CDP.
|
|
172
201
|
*
|
|
@@ -236,6 +265,37 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
236
265
|
private ensureSession;
|
|
237
266
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
238
267
|
private buildSession;
|
|
268
|
+
/**
|
|
269
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
270
|
+
*
|
|
271
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
272
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
273
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
274
|
+
* @param client - the connected browser.
|
|
275
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
276
|
+
*/
|
|
277
|
+
private adoptBlankTarget;
|
|
278
|
+
/** Create one page, refusing to go on without an id. */
|
|
279
|
+
private createTarget;
|
|
280
|
+
/**
|
|
281
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
282
|
+
*
|
|
283
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
284
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
285
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
286
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
287
|
+
*
|
|
288
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
289
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
290
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
291
|
+
*
|
|
292
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
293
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
294
|
+
* reason the first attempt at this fix failed silently.
|
|
295
|
+
* @param client - the connected browser.
|
|
296
|
+
* @returns how many surplus blank pages were closed.
|
|
297
|
+
*/
|
|
298
|
+
private normalizeBlankPages;
|
|
239
299
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
240
300
|
private readonly sessionBuilding;
|
|
241
301
|
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
@@ -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,21 @@ 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 target URL is one of the blanks a browser opens by itself.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately narrow: `about:blank` and Chrome's own new-tab pages, and nothing else. A wider
|
|
163
|
+
* test ("has no path", "starts with chrome://") would sooner or later match a page the user is
|
|
164
|
+
* reading, and the caller closes what this returns.
|
|
165
|
+
* @param url - the target's reported URL.
|
|
166
|
+
*/
|
|
167
|
+
function isBlankUrl(url) {
|
|
168
|
+
if (typeof url !== 'string')
|
|
169
|
+
return false;
|
|
170
|
+
return url === 'about:blank'
|
|
171
|
+
|| url.startsWith('chrome://newtab')
|
|
172
|
+
|| url.startsWith('chrome://new-tab-page');
|
|
173
|
+
}
|
|
150
174
|
function expandLocation(location, env) {
|
|
151
175
|
return location.replace(/%([^%]+)%/g, (whole, name) => env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole);
|
|
152
176
|
}
|
|
@@ -385,6 +409,16 @@ export class SystemBrowserViewHost {
|
|
|
385
409
|
*/
|
|
386
410
|
clientSuspect = false;
|
|
387
411
|
child;
|
|
412
|
+
/**
|
|
413
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
414
|
+
*
|
|
415
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
416
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
417
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
418
|
+
*/
|
|
419
|
+
adoptedRunning = false;
|
|
420
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
421
|
+
blanksNormalized = false;
|
|
388
422
|
starting;
|
|
389
423
|
/**
|
|
390
424
|
* Browsers that ignored a kill.
|
|
@@ -468,6 +502,10 @@ export class SystemBrowserViewHost {
|
|
|
468
502
|
forgetBrowserState() {
|
|
469
503
|
this.views.clear();
|
|
470
504
|
this.sessions.clear();
|
|
505
|
+
// A new browser comes with its own surplus pages, so the tidy-up runs again for it. Without
|
|
506
|
+
// this, a restart would inherit the previous connection's "already handled" and leave the new
|
|
507
|
+
// browser's extra blank page in place for the rest of the session.
|
|
508
|
+
this.blanksNormalized = false;
|
|
471
509
|
}
|
|
472
510
|
async ensureClient() {
|
|
473
511
|
// A disposed host must never start a browser: doing so would spawn a process
|
|
@@ -501,7 +539,78 @@ export class SystemBrowserViewHost {
|
|
|
501
539
|
}
|
|
502
540
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
503
541
|
this.starting ??= this.start().finally(() => { this.starting = undefined; });
|
|
504
|
-
|
|
542
|
+
const client = await this.starting;
|
|
543
|
+
// Once per connection. A browser opens more than one page when it starts, and the surplus is
|
|
544
|
+
// invisible to the plugin's tab model — see normalizeBlankPages for why that matters.
|
|
545
|
+
if (!this.blanksNormalized) {
|
|
546
|
+
this.blanksNormalized = true;
|
|
547
|
+
await this.normalizeBlankPages(client);
|
|
548
|
+
}
|
|
549
|
+
return client;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
553
|
+
*
|
|
554
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
555
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
556
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
557
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
558
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
559
|
+
* exactly what was reported.
|
|
560
|
+
*
|
|
561
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
562
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
563
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
564
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
565
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
566
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
567
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
568
|
+
*/
|
|
569
|
+
async adoptRunningBrowser(why) {
|
|
570
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
571
|
+
let port;
|
|
572
|
+
try {
|
|
573
|
+
if (!existsSync(portFile))
|
|
574
|
+
return undefined;
|
|
575
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '');
|
|
576
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
577
|
+
return undefined;
|
|
578
|
+
port = value;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
585
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
586
|
+
})).json();
|
|
587
|
+
if (typeof version.webSocketDebuggerUrl !== 'string')
|
|
588
|
+
return undefined;
|
|
589
|
+
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
590
|
+
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
591
|
+
if (typeof version.Browser !== 'string' || !/chrome|chromium|edge/i.test(version.Browser))
|
|
592
|
+
return undefined;
|
|
593
|
+
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
594
|
+
await client.whenReady();
|
|
595
|
+
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
596
|
+
// mistaken for a browser that answers.
|
|
597
|
+
const info = await client.send('Browser.getVersion', {});
|
|
598
|
+
if (typeof info?.product !== 'string' || !/chrome|chromium|edge/i.test(info.product)) {
|
|
599
|
+
client.close();
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
if (this.disposed) {
|
|
603
|
+
client.close();
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
this.client = client;
|
|
607
|
+
this.adoptedRunning = true;
|
|
608
|
+
return client;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// No endpoint, nothing listening, or not a browser: all of them mean "launch one".
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
505
614
|
}
|
|
506
615
|
/**
|
|
507
616
|
* Launch the browser with a private profile and connect over CDP.
|
|
@@ -512,6 +621,12 @@ export class SystemBrowserViewHost {
|
|
|
512
621
|
* @returns the connected client.
|
|
513
622
|
*/
|
|
514
623
|
async start() {
|
|
624
|
+
// Before spawning anything: is a browser for THIS profile already running? A DSH restart
|
|
625
|
+
// leaves one behind, and launching into it is the failure reported as "exited immediately
|
|
626
|
+
// after launch (exit code 0)" that required killing seven processes by hand.
|
|
627
|
+
const adopted = await this.adoptRunningBrowser('before launch');
|
|
628
|
+
if (adopted !== undefined)
|
|
629
|
+
return adopted;
|
|
515
630
|
const args = [
|
|
516
631
|
'--remote-debugging-port=0',
|
|
517
632
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -569,15 +684,14 @@ export class SystemBrowserViewHost {
|
|
|
569
684
|
});
|
|
570
685
|
});
|
|
571
686
|
}
|
|
572
|
-
// The port file
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
catch { /* nothing to remove */ }
|
|
687
|
+
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
688
|
+
//
|
|
689
|
+
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
690
|
+
// the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
|
|
691
|
+
// deletion never protected it — while it DID destroy the only record of a browser that may
|
|
692
|
+
// still be running for this profile. That record is what `adoptRunningBrowser` needs when the
|
|
693
|
+
// launch comes back as a hand-off: the browser was alive the whole time, and without the file
|
|
694
|
+
// there is no way to find it again.
|
|
581
695
|
const spawnedAt = Date.now();
|
|
582
696
|
let child;
|
|
583
697
|
try {
|
|
@@ -728,6 +842,14 @@ export class SystemBrowserViewHost {
|
|
|
728
842
|
const code = child.exitCode;
|
|
729
843
|
const signal = child.signalCode;
|
|
730
844
|
const clean = code === 0 && signal === null;
|
|
845
|
+
// Exit code 0 IS the hand-off: the request went to an instance already holding this profile,
|
|
846
|
+
// so that instance is our browser and it is running right now. Adopting it turns the
|
|
847
|
+
// reported "kill the leftovers by hand, on every restart" into a call that just works.
|
|
848
|
+
if (clean) {
|
|
849
|
+
const recovered = await this.adoptRunningBrowser('after a hand-off');
|
|
850
|
+
if (recovered !== undefined)
|
|
851
|
+
return recovered;
|
|
852
|
+
}
|
|
731
853
|
throw new Error(clean
|
|
732
854
|
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
733
855
|
+ 'That means it handed the request to an instance already using this profile '
|
|
@@ -892,20 +1014,39 @@ export class SystemBrowserViewHost {
|
|
|
892
1014
|
if (this.disposed)
|
|
893
1015
|
return;
|
|
894
1016
|
this.disposed = true;
|
|
1017
|
+
const client = this.client;
|
|
895
1018
|
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)) {
|
|
1019
|
+
this.client = undefined;
|
|
1020
|
+
// Ask before killing. `kill()` on Windows is TerminateProcess, which gives the browser no
|
|
1021
|
+
// chance to flush its profile — and Chrome records how it ended in that profile, so a
|
|
1022
|
+
// hard kill leaves it marked as a crash and the NEXT launch offers to restore a session.
|
|
1023
|
+
// That is a visible symptom (an extra window) traced all the way back to this line.
|
|
1024
|
+
if (client !== undefined) {
|
|
904
1025
|
try {
|
|
905
|
-
|
|
1026
|
+
void client.send('Browser.close', {}).catch(() => { });
|
|
906
1027
|
}
|
|
907
1028
|
catch { /* already gone */ }
|
|
908
1029
|
}
|
|
1030
|
+
// Both the browser we launched and anything that ignored an earlier kill. They hold this
|
|
1031
|
+
// profile, so leaving them running means the next launch contends with a browser nobody owns.
|
|
1032
|
+
const doomed = [this.child, ...this.stubborn.splice(0)].filter((entry) => entry !== undefined);
|
|
1033
|
+
this.child = undefined;
|
|
1034
|
+
for (const dying of doomed) {
|
|
1035
|
+
/**
|
|
1036
|
+
* A bounded reprieve, then the kill we used to do immediately.
|
|
1037
|
+
*
|
|
1038
|
+
* The timer is unref'd so it never holds the process open: a DSH shutdown that is itself
|
|
1039
|
+
* ending does not wait for this, and the browser — having been asked to close — generally
|
|
1040
|
+
* gets there on its own. If it does not, the kill still lands while the process lives.
|
|
1041
|
+
*/
|
|
1042
|
+
const force = () => { try {
|
|
1043
|
+
dying.kill();
|
|
1044
|
+
}
|
|
1045
|
+
catch { /* already gone */ } };
|
|
1046
|
+
const timer = setTimeout(force, CLOSE_GRACE_MS);
|
|
1047
|
+
timer.unref?.();
|
|
1048
|
+
dying.once('exit', () => { clearTimeout(timer); });
|
|
1049
|
+
}
|
|
909
1050
|
const ephemeral = this.ephemeralDir;
|
|
910
1051
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
911
1052
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
@@ -957,10 +1098,15 @@ export class SystemBrowserViewHost {
|
|
|
957
1098
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
958
1099
|
async buildSession(viewId) {
|
|
959
1100
|
const client = await this.ensureClient();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1101
|
+
// Adopt a blank page the browser opened on its own before creating another one.
|
|
1102
|
+
//
|
|
1103
|
+
// Chrome starts with more than one page when handed a URL on top of its own start page, so
|
|
1104
|
+
// creating a target here discarded the original and left it behind as a tab that is not in
|
|
1105
|
+
// the plugin's tab model — invisible to `dsh_browser_list_tabs`, and therefore unclosable
|
|
1106
|
+
// through the tools. Taking the blank instead makes the page the browser already opened the
|
|
1107
|
+
// one the caller gets.
|
|
1108
|
+
const adopted = await this.adoptBlankTarget(client);
|
|
1109
|
+
const targetId = adopted ?? await this.createTarget(client);
|
|
964
1110
|
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
965
1111
|
const sessionId = attached?.sessionId;
|
|
966
1112
|
if (typeof sessionId !== 'string')
|
|
@@ -970,6 +1116,94 @@ export class SystemBrowserViewHost {
|
|
|
970
1116
|
this.views.set(viewId, { session: sessionId, client });
|
|
971
1117
|
return sessionId;
|
|
972
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
1121
|
+
*
|
|
1122
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
1123
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
1124
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
1125
|
+
* @param client - the connected browser.
|
|
1126
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
1127
|
+
*/
|
|
1128
|
+
async adoptBlankTarget(client) {
|
|
1129
|
+
try {
|
|
1130
|
+
const listed = await client.send('Target.getTargets', {});
|
|
1131
|
+
const claimed = new Set(this.sessions.values());
|
|
1132
|
+
for (const entry of listed?.targetInfos ?? []) {
|
|
1133
|
+
if (entry.type !== 'page' || typeof entry.targetId !== 'string')
|
|
1134
|
+
continue;
|
|
1135
|
+
if (!isBlankUrl(entry.url))
|
|
1136
|
+
continue;
|
|
1137
|
+
if (claimed.has(entry.targetId))
|
|
1138
|
+
continue;
|
|
1139
|
+
return entry.targetId;
|
|
1140
|
+
}
|
|
1141
|
+
return undefined;
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
// Discovery is best-effort; creating a page is the behaviour this replaced.
|
|
1145
|
+
return undefined;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** Create one page, refusing to go on without an id. */
|
|
1149
|
+
async createTarget(client) {
|
|
1150
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
1151
|
+
const targetId = created?.targetId;
|
|
1152
|
+
if (typeof targetId !== 'string')
|
|
1153
|
+
throw new Error('dsh-builtin-browser: the browser did not create a page');
|
|
1154
|
+
return targetId;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
1158
|
+
*
|
|
1159
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
1160
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
1161
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
1162
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
1163
|
+
*
|
|
1164
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
1165
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
1166
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
1167
|
+
*
|
|
1168
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
1169
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
1170
|
+
* reason the first attempt at this fix failed silently.
|
|
1171
|
+
* @param client - the connected browser.
|
|
1172
|
+
* @returns how many surplus blank pages were closed.
|
|
1173
|
+
*/
|
|
1174
|
+
async normalizeBlankPages(client) {
|
|
1175
|
+
try {
|
|
1176
|
+
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
1177
|
+
const listed = await client.send('Target.getTargets', {});
|
|
1178
|
+
const blanks = (listed?.targetInfos ?? []).filter(entry => entry.type === 'page' && typeof entry.targetId === 'string' && isBlankUrl(entry.url));
|
|
1179
|
+
const claimed = new Set(this.sessions.values());
|
|
1180
|
+
let closed = 0;
|
|
1181
|
+
// Keep the first unclaimed blank (that is the one buildSession will adopt) and close the rest.
|
|
1182
|
+
let kept = false;
|
|
1183
|
+
for (const entry of blanks) {
|
|
1184
|
+
const targetId = entry.targetId;
|
|
1185
|
+
if (claimed.has(targetId))
|
|
1186
|
+
continue;
|
|
1187
|
+
if (!kept) {
|
|
1188
|
+
kept = true;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
await client.send('Target.closeTarget', { targetId });
|
|
1193
|
+
closed += 1;
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
// It may already be gone; one failure must not stop the rest.
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return closed;
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
// A browser that will not answer Target commands has a real problem, and the next command
|
|
1203
|
+
// will report it. Turning a tidy-up into a functional error here would hide that.
|
|
1204
|
+
return 0;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
973
1207
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
974
1208
|
sessionBuilding = new Map();
|
|
975
1209
|
/** 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.9",
|
|
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",
|
|
@@ -38,6 +38,15 @@ import { join } from 'node:path'
|
|
|
38
38
|
* exited, and that exit was reported as a bad browser path.
|
|
39
39
|
*/
|
|
40
40
|
const EXIT_GRACE_MS = 3_000
|
|
41
|
+
/**
|
|
42
|
+
* How long a browser is given to close itself after being asked, before it is killed.
|
|
43
|
+
*
|
|
44
|
+
* Long enough for Chrome to flush its profile, which is what keeps the next launch from reading
|
|
45
|
+
* the profile as crashed and offering a session restore. Deliberately not the same knob as
|
|
46
|
+
* {@link EXIT_GRACE_MS}: that one waits for a browser to die because a NEW one cannot start until
|
|
47
|
+
* it does, this one merely prefers a graceful exit to a kill.
|
|
48
|
+
*/
|
|
49
|
+
const CLOSE_GRACE_MS = 6_000
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Ceiling on one attempt to ask a discovered CDP port who it is.
|
|
@@ -178,6 +187,21 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
178
187
|
* @param env - the environment to resolve against.
|
|
179
188
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
180
189
|
*/
|
|
190
|
+
/**
|
|
191
|
+
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
192
|
+
*
|
|
193
|
+
* Deliberately narrow: `about:blank` and Chrome's own new-tab pages, and nothing else. A wider
|
|
194
|
+
* test ("has no path", "starts with chrome://") would sooner or later match a page the user is
|
|
195
|
+
* reading, and the caller closes what this returns.
|
|
196
|
+
* @param url - the target's reported URL.
|
|
197
|
+
*/
|
|
198
|
+
function isBlankUrl(url: unknown): boolean {
|
|
199
|
+
if (typeof url !== 'string') return false
|
|
200
|
+
return url === 'about:blank'
|
|
201
|
+
|| url.startsWith('chrome://newtab')
|
|
202
|
+
|| url.startsWith('chrome://new-tab-page')
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
|
|
182
206
|
return location.replace(/%([^%]+)%/g, (whole, name: string) =>
|
|
183
207
|
env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
|
|
@@ -415,6 +439,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
415
439
|
*/
|
|
416
440
|
private clientSuspect = false
|
|
417
441
|
private child: ChildProcess | undefined
|
|
442
|
+
/**
|
|
443
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
444
|
+
*
|
|
445
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
446
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
447
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
448
|
+
*/
|
|
449
|
+
private adoptedRunning = false
|
|
450
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
451
|
+
private blanksNormalized = false
|
|
418
452
|
private starting: Promise<CdpClient> | undefined
|
|
419
453
|
|
|
420
454
|
/**
|
|
@@ -503,6 +537,10 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
503
537
|
private forgetBrowserState(): void {
|
|
504
538
|
this.views.clear()
|
|
505
539
|
this.sessions.clear()
|
|
540
|
+
// A new browser comes with its own surplus pages, so the tidy-up runs again for it. Without
|
|
541
|
+
// this, a restart would inherit the previous connection's "already handled" and leave the new
|
|
542
|
+
// browser's extra blank page in place for the rest of the session.
|
|
543
|
+
this.blanksNormalized = false
|
|
506
544
|
}
|
|
507
545
|
|
|
508
546
|
private async ensureClient(): Promise<CdpClient> {
|
|
@@ -535,7 +573,70 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
535
573
|
}
|
|
536
574
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
537
575
|
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
538
|
-
|
|
576
|
+
const client = await this.starting
|
|
577
|
+
// Once per connection. A browser opens more than one page when it starts, and the surplus is
|
|
578
|
+
// invisible to the plugin's tab model — see normalizeBlankPages for why that matters.
|
|
579
|
+
if (!this.blanksNormalized) {
|
|
580
|
+
this.blanksNormalized = true
|
|
581
|
+
await this.normalizeBlankPages(client)
|
|
582
|
+
}
|
|
583
|
+
return client
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
588
|
+
*
|
|
589
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
590
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
591
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
592
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
593
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
594
|
+
* exactly what was reported.
|
|
595
|
+
*
|
|
596
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
597
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
598
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
599
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
600
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
601
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
602
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
603
|
+
*/
|
|
604
|
+
private async adoptRunningBrowser(why: string): Promise<CdpClient | undefined> {
|
|
605
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
606
|
+
let port: number
|
|
607
|
+
try {
|
|
608
|
+
if (!existsSync(portFile)) return undefined
|
|
609
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
|
|
610
|
+
if (!Number.isInteger(value) || value <= 0) return undefined
|
|
611
|
+
port = value
|
|
612
|
+
} catch {
|
|
613
|
+
return undefined
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
617
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
618
|
+
})).json() as { webSocketDebuggerUrl?: string; Browser?: string }
|
|
619
|
+
if (typeof version.webSocketDebuggerUrl !== 'string') return undefined
|
|
620
|
+
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
621
|
+
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
622
|
+
if (typeof version.Browser !== 'string' || !/chrome|chromium|edge/i.test(version.Browser)) return undefined
|
|
623
|
+
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
624
|
+
await client.whenReady()
|
|
625
|
+
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
626
|
+
// mistaken for a browser that answers.
|
|
627
|
+
const info = await client.send('Browser.getVersion', {}) as { product?: string }
|
|
628
|
+
if (typeof info?.product !== 'string' || !/chrome|chromium|edge/i.test(info.product)) {
|
|
629
|
+
client.close()
|
|
630
|
+
return undefined
|
|
631
|
+
}
|
|
632
|
+
if (this.disposed) { client.close(); return undefined }
|
|
633
|
+
this.client = client
|
|
634
|
+
this.adoptedRunning = true
|
|
635
|
+
return client
|
|
636
|
+
} catch {
|
|
637
|
+
// No endpoint, nothing listening, or not a browser: all of them mean "launch one".
|
|
638
|
+
return undefined
|
|
639
|
+
}
|
|
539
640
|
}
|
|
540
641
|
|
|
541
642
|
/**
|
|
@@ -547,6 +648,11 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
547
648
|
* @returns the connected client.
|
|
548
649
|
*/
|
|
549
650
|
private async start(): Promise<CdpClient> {
|
|
651
|
+
// Before spawning anything: is a browser for THIS profile already running? A DSH restart
|
|
652
|
+
// leaves one behind, and launching into it is the failure reported as "exited immediately
|
|
653
|
+
// after launch (exit code 0)" that required killing seven processes by hand.
|
|
654
|
+
const adopted = await this.adoptRunningBrowser('before launch')
|
|
655
|
+
if (adopted !== undefined) return adopted
|
|
550
656
|
const args = [
|
|
551
657
|
'--remote-debugging-port=0',
|
|
552
658
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -598,12 +704,14 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
598
704
|
})
|
|
599
705
|
})
|
|
600
706
|
}
|
|
601
|
-
// The port file
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
707
|
+
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
708
|
+
//
|
|
709
|
+
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
710
|
+
// the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
|
|
711
|
+
// deletion never protected it — while it DID destroy the only record of a browser that may
|
|
712
|
+
// still be running for this profile. That record is what `adoptRunningBrowser` needs when the
|
|
713
|
+
// launch comes back as a hand-off: the browser was alive the whole time, and without the file
|
|
714
|
+
// there is no way to find it again.
|
|
607
715
|
const spawnedAt = Date.now()
|
|
608
716
|
let child: ReturnType<typeof spawn>
|
|
609
717
|
try {
|
|
@@ -723,6 +831,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
723
831
|
const code = child.exitCode
|
|
724
832
|
const signal = child.signalCode
|
|
725
833
|
const clean = code === 0 && signal === null
|
|
834
|
+
// Exit code 0 IS the hand-off: the request went to an instance already holding this profile,
|
|
835
|
+
// so that instance is our browser and it is running right now. Adopting it turns the
|
|
836
|
+
// reported "kill the leftovers by hand, on every restart" into a call that just works.
|
|
837
|
+
if (clean) {
|
|
838
|
+
const recovered = await this.adoptRunningBrowser('after a hand-off')
|
|
839
|
+
if (recovered !== undefined) return recovered
|
|
840
|
+
}
|
|
726
841
|
throw new Error(
|
|
727
842
|
clean
|
|
728
843
|
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
@@ -892,13 +1007,34 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
892
1007
|
dispose(): void {
|
|
893
1008
|
if (this.disposed) return
|
|
894
1009
|
this.disposed = true
|
|
1010
|
+
const client = this.client
|
|
895
1011
|
this.forgetBrowserState()
|
|
896
|
-
this.client
|
|
897
|
-
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
1012
|
+
this.client = undefined
|
|
1013
|
+
// Ask before killing. `kill()` on Windows is TerminateProcess, which gives the browser no
|
|
1014
|
+
// chance to flush its profile — and Chrome records how it ended in that profile, so a
|
|
1015
|
+
// hard kill leaves it marked as a crash and the NEXT launch offers to restore a session.
|
|
1016
|
+
// That is a visible symptom (an extra window) traced all the way back to this line.
|
|
1017
|
+
if (client !== undefined) {
|
|
1018
|
+
try { void client.send('Browser.close', {}).catch(() => { /* it is closing, not talking */ }) } catch { /* already gone */ }
|
|
1019
|
+
}
|
|
1020
|
+
// Both the browser we launched and anything that ignored an earlier kill. They hold this
|
|
1021
|
+
// profile, so leaving them running means the next launch contends with a browser nobody owns.
|
|
1022
|
+
const doomed = [this.child, ...this.stubborn.splice(0)].filter(
|
|
1023
|
+
(entry): entry is ChildProcess => entry !== undefined,
|
|
1024
|
+
)
|
|
1025
|
+
this.child = undefined
|
|
1026
|
+
for (const dying of doomed) {
|
|
1027
|
+
/**
|
|
1028
|
+
* A bounded reprieve, then the kill we used to do immediately.
|
|
1029
|
+
*
|
|
1030
|
+
* The timer is unref'd so it never holds the process open: a DSH shutdown that is itself
|
|
1031
|
+
* ending does not wait for this, and the browser — having been asked to close — generally
|
|
1032
|
+
* gets there on its own. If it does not, the kill still lands while the process lives.
|
|
1033
|
+
*/
|
|
1034
|
+
const force = (): void => { try { dying.kill() } catch { /* already gone */ } }
|
|
1035
|
+
const timer = setTimeout(force, CLOSE_GRACE_MS)
|
|
1036
|
+
timer.unref?.()
|
|
1037
|
+
dying.once('exit', () => { clearTimeout(timer) })
|
|
902
1038
|
}
|
|
903
1039
|
const ephemeral = this.ephemeralDir
|
|
904
1040
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
@@ -945,9 +1081,15 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
945
1081
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
946
1082
|
private async buildSession(viewId: string): Promise<string> {
|
|
947
1083
|
const client = await this.ensureClient()
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1084
|
+
// Adopt a blank page the browser opened on its own before creating another one.
|
|
1085
|
+
//
|
|
1086
|
+
// Chrome starts with more than one page when handed a URL on top of its own start page, so
|
|
1087
|
+
// creating a target here discarded the original and left it behind as a tab that is not in
|
|
1088
|
+
// the plugin's tab model — invisible to `dsh_browser_list_tabs`, and therefore unclosable
|
|
1089
|
+
// through the tools. Taking the blank instead makes the page the browser already opened the
|
|
1090
|
+
// one the caller gets.
|
|
1091
|
+
const adopted = await this.adoptBlankTarget(client)
|
|
1092
|
+
const targetId = adopted ?? await this.createTarget(client)
|
|
951
1093
|
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
952
1094
|
const sessionId = attached?.sessionId
|
|
953
1095
|
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
@@ -957,6 +1099,92 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
957
1099
|
return sessionId
|
|
958
1100
|
}
|
|
959
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
1104
|
+
*
|
|
1105
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
1106
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
1107
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
1108
|
+
* @param client - the connected browser.
|
|
1109
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
1110
|
+
*/
|
|
1111
|
+
private async adoptBlankTarget(client: CdpClient): Promise<string | undefined> {
|
|
1112
|
+
try {
|
|
1113
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1114
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1115
|
+
}
|
|
1116
|
+
const claimed = new Set(this.sessions.values())
|
|
1117
|
+
for (const entry of listed?.targetInfos ?? []) {
|
|
1118
|
+
if (entry.type !== 'page' || typeof entry.targetId !== 'string') continue
|
|
1119
|
+
if (!isBlankUrl(entry.url)) continue
|
|
1120
|
+
if (claimed.has(entry.targetId)) continue
|
|
1121
|
+
return entry.targetId
|
|
1122
|
+
}
|
|
1123
|
+
return undefined
|
|
1124
|
+
} catch {
|
|
1125
|
+
// Discovery is best-effort; creating a page is the behaviour this replaced.
|
|
1126
|
+
return undefined
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Create one page, refusing to go on without an id. */
|
|
1131
|
+
private async createTarget(client: CdpClient): Promise<string> {
|
|
1132
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
1133
|
+
const targetId = created?.targetId
|
|
1134
|
+
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
1135
|
+
return targetId
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
1140
|
+
*
|
|
1141
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
1142
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
1143
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
1144
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
1145
|
+
*
|
|
1146
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
1147
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
1148
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
1149
|
+
*
|
|
1150
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
1151
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
1152
|
+
* reason the first attempt at this fix failed silently.
|
|
1153
|
+
* @param client - the connected browser.
|
|
1154
|
+
* @returns how many surplus blank pages were closed.
|
|
1155
|
+
*/
|
|
1156
|
+
private async normalizeBlankPages(client: CdpClient): Promise<number> {
|
|
1157
|
+
try {
|
|
1158
|
+
await client.send('Target.setDiscoverTargets', { discover: true })
|
|
1159
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1160
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1161
|
+
}
|
|
1162
|
+
const blanks = (listed?.targetInfos ?? []).filter(
|
|
1163
|
+
entry => entry.type === 'page' && typeof entry.targetId === 'string' && isBlankUrl(entry.url),
|
|
1164
|
+
)
|
|
1165
|
+
const claimed = new Set(this.sessions.values())
|
|
1166
|
+
let closed = 0
|
|
1167
|
+
// Keep the first unclaimed blank (that is the one buildSession will adopt) and close the rest.
|
|
1168
|
+
let kept = false
|
|
1169
|
+
for (const entry of blanks) {
|
|
1170
|
+
const targetId = entry.targetId as string
|
|
1171
|
+
if (claimed.has(targetId)) continue
|
|
1172
|
+
if (!kept) { kept = true; continue }
|
|
1173
|
+
try {
|
|
1174
|
+
await client.send('Target.closeTarget', { targetId })
|
|
1175
|
+
closed += 1
|
|
1176
|
+
} catch {
|
|
1177
|
+
// It may already be gone; one failure must not stop the rest.
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return closed
|
|
1181
|
+
} catch {
|
|
1182
|
+
// A browser that will not answer Target commands has a real problem, and the next command
|
|
1183
|
+
// will report it. Turning a tidy-up into a functional error here would hide that.
|
|
1184
|
+
return 0
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
960
1188
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
961
1189
|
private readonly sessionBuilding = new Map<string, Promise<string>>()
|
|
962
1190
|
|