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 CHANGED
@@ -4,6 +4,225 @@
4
4
 
5
5
  ---
6
6
 
7
+ # 第四十八轮(2026-10-16,实机四载体检验:三次误判、一个真 bug、一个跨载体缺陷)
8
+
9
+ YG 要求逐个检验四种载体(侧栏 / 自托管 / Chrome / Edge)能否正常拉开并操作。检验过程里我犯了一个贯穿始终的错误,也发现了两个真实缺陷。
10
+
11
+ ## 一、我判断错了:"点击无效"是我用错了判据
12
+
13
+ 我反复报告"点击不触发导航",依据是**点击后当前标签的 `location.href` 没有变化**。**这个判据是错的。**
14
+
15
+ 实测数据:点击确实开出新页面 —— 侧栏载体上三次点击产生三个 webview guest,Chrome 窗口里有四个页面:
16
+
17
+ ```
18
+ frame=webview guests=4 sidebar(webview)=3
19
+ id=5 webview https://example.com/ ← 点击注入链接的结果
20
+ id=4 webview https://www.4399.com/flash_fl/5_1.htm ← 点击"益智"的结果
21
+ id=3 webview https://www.4399.com/flash_fl/3_1.htm ← 点击"体育"的结果
22
+ id=1 window dsh-app://app/
23
+ ```
24
+
25
+ 另一次独立验证:点击 `#chskin` 后 DOM 从 236014 字符变为 237758。
26
+
27
+ **根因是我把两个不同的信号混淆了**:
28
+ 1. **虚拟光标**(`showCursor` 在点击位置画的红色标记)—— 那是给用户看"agent 正在此处操作"的,不代表导航;
29
+ 2. **真实的页面变化**。
30
+
31
+ 而我把"当前标签地址未变"当成了失败的证据。**页面开在新标签里,当前标签的地址当然不变。**
32
+
33
+ 这个错误值得记下来,因为它有代价:它让我把一个**正常工作的功能**报了三次故障,并据此写了一个错误的修复(`buttons` 位掩码 —— 见下,那个改动本身是对的,但当时被当成了根因)。
34
+
35
+ ## 二、修复一:`buttons` 位掩码(改动正确,但不是根因)
36
+
37
+ `Input.dispatchMouseEvent` 需要两个不同字段:`button`(这个事件关于哪个键)与 `buttons`(**事件发生时有哪些键被按住**的位掩码)。原来只发 `button`,不发 `buttons`。
38
+
39
+ 改动本身是对 CDP 语义的正确修正(合成按压应当声明"主键按下中"),实测页面上 `mousedown` 现在带 `buttons: 1`、`mouseup`/`click` 带 `buttons: 0`,序列完整。**但它不是"导航失败"的原因** —— 那个现象不存在。
40
+
41
+ ## 三、修复二:Edge 自报为 `Edg`,被我的判据拒绝
42
+
43
+ 切换到 `edge` 载体时报:
44
+
45
+ ```
46
+ edge (...) exited immediately after launch (exit code 0). That means it handed the request to an
47
+ instance already using this profile directory and quit
48
+ ```
49
+
50
+ **而 Edge 全程都在运行**:插件自己的 profile 上有 15 个进程,`/json/version` 回答 `Browser=Edg/155.0.4283.45`。
51
+
52
+ 接管判据写的是 `/chrome|chromium|edge/i`,而 **Edge 的产品名是 `Edg`** —— 三个字母,不含 "edge"。于是活的 Edge 被拒绝,接管返回 undefined,移交被报告成启动失败。**那正是我加接管逻辑要消除的错误,被我对字符串的假设重新引入了。**
53
+
54
+ 改为 `isBrowserProduct()`,匹配 `edg`,同时仍然拒绝 `Fake/1.0`、`not-a-browser/1.0` 这类不声称自己是浏览器的应答。
55
+
56
+ ## 四、修复三:载体自己开的页面不进标签模型(跨载体的真实缺陷)
57
+
58
+ 每个载体都会自己创建页面:点击一个在新页面打开的链接,或页面里的任何 `window.open`。**这些页面没有任何路径回报给插件**,于是 `dsh_browser_list_tabs` 只报插件自己创建的那些 —— 而那些页面**看得见(用户在浏览器窗口里看得到)、占内存、关不掉**,会一直累积到浏览器退出。
59
+
60
+ **三个载体的差别决定了这是宿主层能力而不是 provider 自己能做的事**:
61
+
62
+ - **自托管**:Electron 的 `setWindowOpenHandler` 把弹窗转成 `newTab` user action,provider 的 `case 'newTab'` **确实使用了 url**(L3347-3349),所以这条路径本来就正确,无需改动;
63
+ - **侧栏**:页面由外壳在面板内创建,只经 `list` 的 `sidebar` 字段报出;
64
+ - **Chrome/Edge**:页面由浏览器创建,只经 `Target.getTargets` 报出。
65
+
66
+ **修法**:接口加一对能力 —— `listPages?()` 与 `closePage?(id)`。一对,不是单个:**列出一个关不掉的页面比不列出更糟**,两个工具会互相矛盾。
67
+
68
+ - `DesktopBridgeViewHost` 用 bridge 的 `list` 取 sidebar guests,关闭走 `closeSidebarBrowser`(**按标题、且只关调用者自己的 guest** —— 那条限制阻止过"一个会话的清理销毁另一个会话的页面",所以是查标题而不是绕过它);
69
+ - `SystemBrowserViewHost` 用 `Target.getTargets` 列、`Target.closeTarget` 关;
70
+ - provider 的 `listTabs` 以 `page:` 前缀合并这些页面并**去重**(provider 自己的 view 也在载体的列表里,不去重会每个标签报两次),空白页跳过;
71
+ - `closeTab` 认得 `page:` 前缀并走宿主关闭;宿主不支持关闭时明确报错,而不是假装该 id 不存在。
72
+
73
+ **测试**:272 → 278(新增 6 条:`tests/carrier-pages.test.mjs`)。`tsc` 干净。
74
+
75
+ **未验证部分**:上述修复**都还没有在实机上跑过** —— 运行中的插件进程加载的是改动之前的 `lib/`。检验方法已就绪(四种载体逐个切换 + 桥探针读 guests + CDP `/json/list` 读真实页面),等一次重启。
76
+
77
+ ---
78
+
79
+ # 第四十七轮(2026-10-16,侧栏面板是 iframe 的外壳:装上 bridge 反而把浏览器弄坏)
80
+
81
+ 报告者的现象一句话概括:**不装 bridge 时自托管载体工作正常;装上 bridge 反而彻底不能用** —— 载体被换成侧栏,而侧栏在那个外壳上给不出 guest,且没有任何回退路径。他的排查给出了两条独立的硬约束与七处证据(含对照实验与完整预算观察)。
82
+
83
+ ## 一、为什么这个外壳的侧栏不可驱动
84
+
85
+ 官方侧栏浏览器面板只在**外壳同时满足**两个条件时才是 `<webview>`:
86
+
87
+ 1. `preload` 暴露 `dshDesktop.browser`(而不只是 `protocolVersion`);
88
+ 2. 外壳窗口开启 `webviewTag`。
89
+
90
+ **DSH Desktop 2.0.17 两者皆无**。报告者给出的证据链:主窗口 preload 为 `lib/preload.cjs`,其中 `exposeInMainWorld("dshDesktop", Object.freeze({ protocolVersion: 1 }))`;官方面板据此二选一(`carrier?.protocolVersion === 1 ? carrier.browser : void 0` → `undefined`),走 `createIframePage`;而窗口的 `webPreferences` 里 `webviewTag: false`。**carrier 是 `Object.freeze` 的,页面脚本也补不上 `browser` 字段。**
91
+
92
+ 两条各自独立成立,所以这不是配置问题。**iframe 没有 webContents、没有 `getWebContentsId()`、没有 CDP 端点** —— 而本插件的 34 个工具全部建立在 CDP 之上,所以不是"暂时取不到",是原理上够不着。
93
+
94
+ **本机无法复现**:这台机器的桌面端是另一种布局(`resources/app.asar` 存在、无 `preload.cjs`、desktop profile 里没有装 `dsh-client-ui-sidebar-browser`)。因此这一轮的依据是**报告者给出的行号与代码片段**,以及我们这侧可以独立核实的三处(见下)。
95
+
96
+ ## 二、修法一:接管之前先读面板形态
97
+
98
+ 面板自己带标记:官方客户端在两种呈现上都写 `element.dataset.sidebarBrowserFrame = "webview" | "iframe"`。插件从这一轮起在 `list` 探活时**一并返回该属性**,`discover()` 判到 `iframe` 就**不接管**,自然停在自托管。
99
+
100
+ 判据被刻意收窄:`none`(面板尚未建立)与 `unknown`(读不到)**都不算拒绝** —— 前者是每个会话首次打开时的正常状态,后者只说明这次探测跑早了。
101
+
102
+ **形态的读取不带会话**:两个条件都在**窗口创建时**固定,答案不随会话变化。这一点同时保住了一条既有不变量 —— **探活必须保持 owner-less,因为它不代表任何会话**。(第一版实现传了 `sessionId`,被 `tests/bridge-boundary.test.mjs` 的"探活是唯一 owner-less 的 list 调用"当场拦下。)
103
+
104
+ ## 三、修法二:让"面板给不出 guest"也能触发回退
105
+
106
+ 这是"装桥更糟"的直接原因。`ensureSidebar` 超时后抛 `produced no page`,而 `isGuestGone()` 把它归为"值得换一个 guest 重试一次" —— **重试的仍是同一个不可用的载体**。真正能救场的 `restoreSelfHosted` 只由 `host.onLost?.()` 触发,而它挂在 `BridgeConnection.notifyLost()` 上,**只有"外壳进程退出、端点停止刷新"才会到达**。外壳活着、面板却给不出 guest,这个组合不在任何回退条件里,于是每次调用都失败,重启后又选中侧栏继续失败。
107
+
108
+ 修法:`ensureSidebar` 的失败若指名 `produced no page`,即视为载体丢失,走同一条回退路径 —— 机制本来就有,只是触发面太窄。
109
+
110
+ ## 四、修法三:文档写明前置条件
111
+
112
+ `docs/limitations.md` 的"载体行为"新增这两条前置条件,并写明 DSH Desktop 2.0.17 两者皆无、以及 0.4.10 起的行为是"能用但多一个窗口"。此前 README 首页的"装好即用,载体自动选择"与桌面端小节的"bridge 不是必做项"叠加,会让人以为 `dsh plugin add` 之后就应当看到侧栏。
113
+
114
+ ## 五、这一轮里被抓到的两个自己的错误
115
+
116
+ 两条既有守护测试当场拦下了我第一版实现里的错误,值得记下来:
117
+
118
+ 1. **`the liveness probe is the only owner-less list call`** —— 我给探活传了 `sessionId` 以便"读那个会话的面板",破坏了"探活无主"这条不变量。修正后形态改从外壳级读取,不涉及会话。
119
+ 2. **`every reply value that drives a branch has a fallback`** —— 我写的是 `answer.frame === 'iframe'`,而对方可能不给这个字段。改成 `String(answer.frame ?? 'unknown')`。
120
+
121
+ **测试**:268 → 272(新增 4 条:`tests/sidebar-frame-kind.test.mjs`)。`tsc` 与 `node --check` 干净。
122
+
123
+ ---
124
+
125
+ # 第四十六轮(2026-10-16,desktop-bridge/install.mjs 的三处缺陷:回滚毁掉还原点、默认路径过期、提示语误导)
126
+
127
+ 报告者逐条给了行号、字节数与复现清单。**第 1 条最严重:它让回滚在正常情形下执行"销毁还原点"而不是"还原"。**
128
+
129
+ ## 一、`--revert` 在正常安装后不还原,并把原始备份覆盖掉
130
+
131
+ **判据是反的**。原代码:
132
+
133
+ ```js
134
+ const identical = backedUp.equals(current)
135
+ if (!identical && stillHasOurMarker && !backupHasOurMarker) {
136
+ writeFileSync(backupPath, current) // 刷新备份(销毁原始)
137
+ reverted = true // 且以 0 退出
138
+ } else if (!identical && !stillHasOurMarker) { /* 跳过 */ }
139
+ else { copyFileSync(backupPath, mainPath) } // 真正的还原
140
+ ```
141
+
142
+ **刚装完的稳定状态恰好满足第一个条件**:备份取于补丁**之前**(无 marker),当前文件是补丁**之后**(有 marker),两者必然不同。所以**正常回滚必定走进"刷新备份"分支** —— `main.js` 没还原,原始备份(213913 B)被覆盖成补丁版(215576 B),第二次 `--revert` 更无从下手。而 `reverted = true` 让脚本以退出码 0 结束并打印成功语义,调用方无法察觉回滚没有发生。
143
+
144
+ 注释里要防的是 `dsh-purge` 那类第三方补丁,但 `stillHasOurMarker && !backupHasOurMarker` **无法区分"第三方补丁过"与"我们刚补丁过"**。
145
+
146
+ **修法**:安装是在末尾 `export {};` 之前插入 `SNIPPET`,因此反向切除是精确可逆的。判据改为**"切除我们的片段之后是否恰好等于备份"**(`withoutOurSnippet`),不再需要任何启发式:
147
+
148
+ - 成立 → 还原,`reverted = true`;
149
+ - 当前文件无我们的 marker 且与备份不同 → 别人改过,跳过(原逻辑,保留);
150
+ - **有我们的 marker 但切除后不等于备份** → 中间确有别的改动 → **不写任何文件**,备份保持不动,并说明原因;
151
+ - 与备份相同 → 无需还原。
152
+
153
+ 第 3 条分支是新加的:原实现里这个情形会走进"刷新备份",而那是**用猜测覆盖唯一的还原依据**。
154
+
155
+ ## 二、`DEFAULT_INSTALL` 指向不存在的目录名
156
+
157
+ 原为 `%LOCALAPPDATA%\Programs\DeepSeek Harness`,而当前 DSH Desktop 的安装目录是 `...\Programs\DSH Desktop` —— 于是 README 与用户指南里**不带参数的裸命令必定失败**。两个名字在不同构建上都出现过(本机即 `DeepSeek Harness`),所以固定猜测不可靠。
158
+
159
+ **修法**:改为按**形状**探测 —— `resources/app/lib/main.js` 或 `resources/app.asar` 存在即为一个安装;先试 `DSH Desktop` 与 `DeepSeek Harness` 两个已知名,再扫描 `Programs` 下的目录,全部落空才回退到历史默认值(并在错误信息里给出具体路径)。
160
+
161
+ ## 三、unpacked 布局下提示删除正在使用的目录
162
+
163
+ 回滚结束时无条件打印 `note: <resources/app> is inert while app.asar exists; delete it to reclaim ~350 MB`。**unpacked 布局里 `app.asar` 并不存在,`resources/app` 正是 Electron 实际加载的目录** —— 这句话会让用户删掉应用本体。
164
+
165
+ **修法**:按已有的 `packedLayout` 判定后再打印。
166
+
167
+ ## 四、验证方式:先确认断言能区分修复前后
168
+
169
+ 本节的价值不在于"新代码通过测试",而在于**同一组断言在修复前的脚本上失败**。
170
+
171
+ - 回滚往返:取 `git show HEAD:desktop-bridge/install.mjs` 在沙箱里跑同一组断言 —— 旧脚本 `revert restored main.js: false`、`backup still the original: false`(正是报告描述的两条);新脚本两项皆真。
172
+ - 默认探测:把 `LOCALAPPDATA` 指向一个只含 `Programs\Some Other Name\resources\app\lib\main.js` 的假目录 —— 旧脚本以 `not a DSH Desktop install` 退出 1,新脚本找到并备份。
173
+
174
+ **一个恒真的断言不能构成证据**,这一点在第 44 轮已经付过代价(守护测试漏掉含数字的工具名却"通过")。
175
+
176
+ **测试**:264 → 268(新增 4 条:`tests/install-revert.test.mjs`,驱动真实脚本对真实目录形状做逐字节比对)。`tsc` 干净。`node --check` 干净。
177
+
178
+ ---
179
+
180
+ # 第四十五轮(2026-10-15,系统浏览器载体的启动生命周期:多开空白页 + 重启后孤儿)
181
+
182
+ 报告者给了一份很硬的证据链 —— CDP `/json/list` 每 200ms 采样的时序、五处建议改法、以及**一条自我更正**(他撤回的第一条建议:去掉启动参数里的 URL 会让 Chrome 变成两个窗口,更糟)。两个问题都发生"把浏览器拉起来"的那一刻,除此之外毫无关系。
183
+
184
+ ## 一、启动那个页面被丢弃,多出来的空白页无法关闭
185
+
186
+ **现象**:每次载体启动都看到 2 个空白标签,其中一个被关掉或残留;残留的那个**不在插件的标签模型里**(`dsh_browser_list_tabs` 只报 1 个),所以 `dsh_browser_close_tab` 关不掉它,窗口长期不关时会累积。
187
+
188
+ **根因两处**:
189
+ 1. `start()` 的启动参数末尾带 `about:blank`,而 Chrome 自己的启动页也在 —— 实测**干净退出后的全新启动也是 2 个页面**,所以不是崩溃恢复,是 Chrome 的固有行为(报告者做了对照实验排除,并保留了那句参数:去掉它会变成 2 个**窗口**)。
190
+ 2. `buildSession()` 用 `Target.createTarget({url:'about:blank'})` **新建**,而不是领养已经存在的那一个 → 启动那个被永久丢弃。
191
+
192
+ **修法**:连上后 `Target.setDiscoverTargets({discover:true})` + `Target.getTargets`,把无人认领的空白页收成**最多 1 个**(只关 `about:blank` 与 `chrome://newtab*`,且**绝不碰已在 `sessions` 里的**);`buildSession()` 先领养那个空白页,没有再新建。**`setDiscoverTargets` 不能省** —— 不先开发现,`getTargets` 可能列不出握手前就存在的页面,而**那正是需要关掉的那个**;报告者第一版补丁就是因此静默失效。
193
+
194
+ ## 二、DSH 重启后旧的载体浏览器成为孤儿,下一次 open 必定失败
195
+
196
+ **现象**:重启 dsh 后,上次拉起的 Chrome 仍在跑;第一次调用直接报 `exited immediately after launch (exit code 0)`,必须手动杀掉用该 profile 的进程才能恢复。
197
+
198
+ **根因**:`start()` 里"杀掉上一个浏览器"只在 `this.child !== undefined` 时执行,而 **DSH 重启后新进程里 `this.child` 是空的** → 旧实例没人管 → 新实例把请求移交给它然后退出 0 → 而这个退出被判成"启动失败"。
199
+
200
+ **修法**:`start()` **在 spawn 之前**先 `adoptRunningBrowser()` —— 读本 profile 的 `DevToolsActivePort`,要求端点自报家门(`/json/version` 的 `Browser` 字段 + `Browser.getVersion` 的 `product`,都必须像浏览器),通过就接管,**不 spawn**;启动回来是 hand-off(exit 0)时再试一次接管。
201
+
202
+ **为什么"接管"是安全的**:启动循环刻意不信任上一次留下的端口文件,理由正当(端口号会被复用到非浏览器进程上)。但**这里多两个前提**:文件在**本插件自己的 profile 目录**里(只有用这个 `--user-data-dir` 启动的浏览器会写它),而且端点要**两次**自证是浏览器。
203
+
204
+ **顺带撤销一处有害的"保护"**:spawn 前删除 `DevToolsActivePort` 被去掉了。它保护不到任何东西 —— `freshPort()` 本来就只认 spawn 之后写入的文件 —— 却销毁了"那个浏览器还在跑"的唯一记录,而那正是接管所需要的。测试里那条"过期文件必须已被删除"的断言随之反正。
205
+
206
+ ## 三、teardown:先请它自己关,再杀
207
+
208
+ `dispose()` 原本固定超时后 `kill()`,而 Windows 上那是 `TerminateProcess`:浏览器来不及写 profile,Chrome 会把这次结束记成崩溃 —— **下一次启动因此触发会话恢复,用户看到一个多出来的窗口**。现在先发 CDP `Browser.close`,给 `CLOSE_GRACE_MS`(6s,`unref` 的定时器,不拖住进程退出)让它自己走,仍在才杀。
209
+
210
+ ## 四、这一轮最该记住的事:同一个位置之前碰过三次,每次只修了一层
211
+
212
+ 用户的疑问是"我记得之前改过类似的,怎么又犯了"。查证后:**不是回归,是一个决定从来没被做过**。
213
+
214
+ | 轮次 | 当时解决的 | 当时没解决的 |
215
+ | --- | --- | --- |
216
+ | 第三十三轮 | "只认本次启动写下的端口"(mtime),不再误连到别的进程 | 那个**真浏览器**被留在原地没人管 |
217
+ | 第三十六轮 | `'exited'` 的文案按退出码区分,不再把崩溃码也说成残留进程 | **它已经准确描述了 hand-off 这个现象,却仍然把它当失败报出来** |
218
+ | 第三十七轮 | `forgetBrowserState` 覆盖"进程已退出、`this.child` 被清空"的形态,视图不再指向死浏览器 | 孤儿进程**仍然占着 profile** |
219
+
220
+ 三次都走到了同一个岔路口,三次都只修了自己那一面 —— **没人问"那浏览器还在跑,我们为什么不接上它"**。第三十六轮那句文案尤其值得记:它把 hand-off 描述得足够清楚,于是读到它的人和报告者都得出"去手动杀掉残留进程"的结论,而正确的结论是"这不该是一个错误"。
221
+
222
+ **测试**:254 → 260(新增 6 条生命周期守卫:接管先于启动、hand-off 读作"已经在那儿"、发现先于列举、只保留一个空白、领养而非丢弃、teardown 先请后杀)。`tsc` 干净。
223
+
224
+ ---
225
+
7
226
  # 第四十四轮(2026-10-14,工具名空间:`browser_*` → `dsh_browser_*`)
8
227
 
9
228
  腾讯 BrowserSkill 的 DSH 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin` @0.3.2)与本插件同装时,它六个工具一个都注册不上。根因不是它的 bug:双方都往**全局**工具层注册了 `browser_session`,宿主对重名直接抛错,而它把注册包在 `try/catch` 里只 `console.warn` —— 桌面版看不到 stdout,于是表现成"插件完全没反应"。用户先怀疑的是它坏了。
@@ -61,10 +61,56 @@
61
61
  * Default path: %LOCALAPPDATA%\Programs\DeepSeek Harness
62
62
  * Pass --revert to restore the pre-bridge state instead.
63
63
  */
64
- import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
64
+ import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
65
65
  import { dirname, join } from 'node:path'
66
66
 
67
- const DEFAULT_INSTALL = join(process.env.LOCALAPPDATA ?? '', 'Programs', 'DeepSeek Harness')
67
+ const DEFAULT_INSTALL = findDefaultInstall()
68
+ /**
69
+ * The desktop install to patch when none is given on the command line.
70
+ *
71
+ * Not a fixed name: the product directory is `DeepSeek Harness` on some builds and `DSH Desktop`
72
+ * on others, and the README's bare `node desktop-bridge/install.mjs` therefore fails outright
73
+ * when the guess is wrong. The shape that identifies an install — `resources/app/lib/main.js` or
74
+ * `resources/app.asar` — is stable across both, so that is what is probed.
75
+ * @returns the install directory, or the historical default when nothing matches.
76
+ */
77
+ function findDefaultInstall() {
78
+ const programs = join(process.env.LOCALAPPDATA ?? '', 'Programs')
79
+ const looksInstalled = candidate => existsSync(join(candidate, 'resources', 'app', 'lib', 'main.js'))
80
+ || existsSync(join(candidate, 'resources', 'app.asar'))
81
+ for (const named of ['DSH Desktop', 'DeepSeek Harness']) {
82
+ const candidate = join(programs, named)
83
+ if (looksInstalled(candidate)) return candidate
84
+ }
85
+ try {
86
+ for (const entry of readdirSync(programs, { withFileTypes: true })) {
87
+ if (!entry.isDirectory()) continue
88
+ const candidate = join(programs, entry.name)
89
+ if (looksInstalled(candidate)) return candidate
90
+ }
91
+ } catch {
92
+ // No Programs directory, or it cannot be read: fall through so the error below names a
93
+ // concrete path instead of throwing before the check that explains what to pass.
94
+ }
95
+ return join(programs, 'DeepSeek Harness')
96
+ }
97
+
98
+ /**
99
+ * Remove our patch from a main.js body, or undefined when it is not present verbatim.
100
+ *
101
+ * Exact rather than heuristic: installation inserts SNIPPET immediately before the trailing
102
+ * `export {};`, so removing that same string reproduces the original byte-for-byte. A snippet
103
+ * from a different version therefore reads as "not cleanly ours" and reaches the branch that asks
104
+ * a human — the correct outcome for a file this script did not patch.
105
+ * @param text - the current main.js body.
106
+ * @returns the body with our snippet cut out, or undefined when the snippet is absent.
107
+ */
108
+ function withoutOurSnippet(text) {
109
+ const at = text.indexOf(SNIPPET)
110
+ if (at === -1) return undefined
111
+ return text.slice(0, at) + text.slice(at + SNIPPET.length)
112
+ }
113
+
68
114
  const args = process.argv.slice(2)
69
115
  const revert = args.includes('--revert')
70
116
  const installRoot = args.find(arg => !arg.startsWith('--')) ?? DEFAULT_INSTALL
@@ -311,29 +357,36 @@ if (revert) {
311
357
  current = undefined
312
358
  }
313
359
  const identical = backedUp !== undefined && current !== undefined && backedUp.equals(current)
314
- const stillHasOurMarker = current !== undefined && current.includes(MARKER)
315
- // A backup taken BEFORE the file was patched by anything else is not a clean restore
316
- // point: reverting to it discards those patches. The marker test alone does not catch
317
- // this, because the current file legitimately carries our marker — that is exactly what
318
- // this script put there — while the backup predates a patch by another tool.
319
- // `dsh-purge` patches this same file, so on this machine that is not hypothetical.
320
- const backupHasOurMarker = backedUp !== undefined && backedUp.includes(MARKER)
321
- if (!identical && stillHasOurMarker && !backupHasOurMarker) {
322
- console.error(`WARNING: ${backupPath} predates the bridge patch, so it is not a clean`)
323
- console.error(` restore point for ${mainPath}. Reverting would also discard anything`)
324
- console.error(' patched in between (dsh-purge patches this same file). Refreshing the')
325
- console.error(' backup from the current file instead, so a later --revert is safe.')
326
- writeFileSync(backupPath, current ?? Buffer.alloc(0))
360
+ // The unambiguous test for "this file is the backup plus our patch and nothing else": remove
361
+ // our snippet and see whether what remains IS the backup.
362
+ //
363
+ // The test this replaces asked whether the current file carries our marker while the backup
364
+ // does not — which is true of EVERY freshly installed file, because the backup is taken before
365
+ // patching. So a normal `--revert` took the "something else patched this" branch, refreshed
366
+ // the backup from the patched file, and destroyed the restore point it had just declined to
367
+ // use — while setting `reverted`, so the script exited 0 and said nothing was wrong.
368
+ const pristine = current === undefined ? undefined : withoutOurSnippet(current.toString('utf8'))
369
+ const isCleanlyOurs = pristine !== undefined && backedUp !== undefined
370
+ && pristine === backedUp.toString('utf8')
371
+ if (isCleanlyOurs) {
372
+ copyFileSync(backupPath, mainPath)
373
+ console.log(`restored ${mainPath} from ${backupPath}`)
327
374
  reverted = true
328
- } else if (!identical && !stillHasOurMarker) {
375
+ } else if (!identical && current !== undefined && !current.includes(MARKER)) {
329
376
  console.error(`WARNING: ${mainPath} has changed since the backup was taken, and it does`)
330
377
  console.error(' not contain the bridge import either — something else has edited it')
331
378
  console.error(' (a desktop upgrade, or another patch tool). Restoring the backup would')
332
379
  console.error(` discard that. The backup is at ${backupPath}; restore it by hand if that`)
333
380
  console.error(' is what you want. Skipping this file.')
381
+ } else if (!identical) {
382
+ // It carries our marker but does not reduce to the backup: something was patched around
383
+ // our snippet. The backup is left untouched, because guessing here is what destroyed it.
384
+ console.error(`WARNING: ${mainPath} carries the bridge import, but removing it does not`)
385
+ console.error(` reproduce ${backupPath} — another tool has patched this file too`)
386
+ console.error(' (dsh-purge patches it). Reverting would discard those changes, so nothing')
387
+ console.error(' was written. Restore by hand from the backup if that is what you want.')
334
388
  } else {
335
- copyFileSync(backupPath, mainPath)
336
- console.log(`restored ${mainPath} from ${backupPath}`)
389
+ console.log(`${mainPath} already matches the backup; nothing to restore`)
337
390
  reverted = true
338
391
  }
339
392
  }
@@ -351,7 +404,12 @@ if (revert) {
351
404
  console.error(`nothing to revert in ${installRoot}`)
352
405
  process.exit(1)
353
406
  }
354
- console.log(`note: ${appDir} is inert while app.asar exists; delete it to reclaim ~350 MB`)
407
+ // Only where an archive actually exists. In the unpacked layout `resources/app` IS what Electron
408
+ // loads, so telling the user it is inert and safe to delete would invite them to remove the
409
+ // application.
410
+ if (packedLayout) {
411
+ console.log(`note: ${appDir} is inert while app.asar exists; delete it to reclaim ~350 MB`)
412
+ }
355
413
  process.exit(0)
356
414
  }
357
415
 
@@ -319,9 +319,49 @@ function guestShows(id, url) {
319
319
  }
320
320
  }
321
321
 
322
+ /**
323
+ * Which kind of frame the sidebar browser panel is built from, on this shell.
324
+ *
325
+ * The official panel renders as a `<webview>` only where the shell exposes `dshDesktop.browser`
326
+ * AND enables `webviewTag`; where it does neither it renders as an `<iframe>`. An iframe has no
327
+ * webContents, no `getWebContentsId()` and therefore no CDP endpoint — and every tool this plugin
328
+ * offers is built on CDP. So a bridge adopted against one cannot work, and reports a failure on
329
+ * every call instead of falling back to a carrier that does.
330
+ *
331
+ * Measured on DSH Desktop 2.0.17: `lib/preload.cjs` exposes only `protocolVersion`, and the shell
332
+ * window sets `webviewTag: false`. Neither is something the plugin can repair, and both are visible
333
+ * from the renderer — which is why this is read rather than inferred from a timeout 20 seconds
334
+ * later.
335
+ *
336
+ * Read WITHOUT a conversation, because the answer is a property of the shell rather than of any
337
+ * panel: both conditions are fixed when the window is created. That also keeps the liveness probe
338
+ * owner-less, which it must stay — it is not acting for anyone.
339
+ *
340
+ * @param shellId - the shell window's webContents id, when one is visible.
341
+ * @returns 'webview' | 'iframe' | 'none' (no panel yet) | 'unknown' (could not be read).
342
+ */
343
+ async function sidebarFrameKind(shellId) {
344
+ if (typeof shellId !== 'number') return 'unknown'
345
+ try {
346
+ // `executeJavaScript`, not the debugger: the same measured reason as every other shell-side
347
+ // read — an attachment to the shell WINDOW hangs while the same call answers immediately.
348
+ const shell = guestById(shellId)
349
+ if (shell === undefined || shell.isDestroyed()) return 'unknown'
350
+ const value = await shell.executeJavaScript(`(() => {
351
+ const el = document.querySelector('[data-sidebar-browser-frame]');
352
+ if (el === null || el === undefined) return 'none';
353
+ return el.getAttribute('data-sidebar-browser-frame') || 'unknown';
354
+ })()`, true)
355
+ return typeof value === 'string' && value !== '' ? value : 'unknown'
356
+ } catch {
357
+ // An unreadable panel is not evidence of an iframe: 'unknown' keeps discovery from refusing a
358
+ // carrier merely because this probe ran before the panel existed.
359
+ return 'unknown'
360
+ }
361
+ }
362
+
322
363
  /** Handle one JSON request line; every answer is a JSON line too. */
323
- async function handle(request) {
324
- const op = String(request?.op ?? '')
364
+ async function handle(request) { const op = String(request?.op ?? '')
325
365
  // Who is asking. Declared once, for every op, because the first attempt at this put `owner` in
326
366
  // the allocation path only and the reuse path — which every later call takes — stayed blind,
327
367
  // so one session was handed another's page and counted another's tabs as its own.
@@ -337,7 +377,13 @@ async function handle(request) {
337
377
  // title are that session's business; handing them over is how a session came to list pages it
338
378
  // never opened. The shell window is always reported: it is what every op drives.
339
379
  const mineOrFree = all.filter(g => g.type !== 'webview' || !claims.has(g.id) || claims.get(g.id) === owner)
340
- return { ok: true, guests: mineOrFree, sidebar: mineOrFree.filter(g => g.type === 'webview') }
380
+ const shell = all.find(g => g.type !== 'webview')
381
+ return {
382
+ ok: true,
383
+ guests: mineOrFree,
384
+ sidebar: mineOrFree.filter(g => g.type === 'webview'),
385
+ frame: await sidebarFrameKind(shell?.id, owner),
386
+ }
341
387
  }
342
388
  if (op === 'cdp') {
343
389
  const id = Number(request.id)
@@ -32,6 +32,14 @@
32
32
 
33
33
  ## 载体行为
34
34
 
35
+ - **侧栏载体有前置条件**,不是装上 bridge 就成立。官方侧栏浏览器面板只在**外壳同时满足**两个条件时才是 `<webview>`:
36
+ 1. `preload` 暴露 `dshDesktop.browser`(而不只是 `protocolVersion`);
37
+ 2. 外壳窗口开启 `webviewTag`。
38
+
39
+ **两个条件缺任一个,面板就是 `<iframe>`** —— 没有 webContents、没有 `getWebContentsId()`、没有 CDP 端点,而本插件的全部工具都建立在 CDP 之上。**DSH Desktop 2.0.17 两者皆无**(`lib/preload.cjs` 只暴露 `protocolVersion`;窗口设置里 `webviewTag: false`),所以那个版本上的侧栏面板不可驱动。
40
+
41
+ 插件从 0.4.10 起**在接管之前先读面板形态**(面板自己带的 `data-sidebar-browser-frame` 属性),判到 `iframe` 就不接管,直接用自托管载体,并在日志里说明原因。**结果是"能用但多一个窗口",而不是"装完就坏"。**
42
+
35
43
  - **侧栏载体不上报"用户操作"事件**:人在那个页面里点击是外壳自己的事件,而 bridge 没有用于回报它的操作。因此依赖该事件的功能(如自定义的接管提示)在桌面端侧栏下不会触发;换到自托管载体则可以。
36
44
  - **本机 Chrome / Edge 载体使用插件自己的 profile**:插件用它自己的用户数据目录启动浏览器(`$DSH_HOME/dsh-builtin-browser-host/<chrome|edge>-profile`),所以你日常浏览器里的书签、扩展与登录态**不会自动继承** —— 这是刻意的,避免插件操作与你的个人会话混在一起;登录态是否跨重启保留由 `cookies.persist` 决定。
37
45
  - 页面弹窗(`window.open` / `target=_blank`)不再覆盖当前视图:HTTP(S) 弹窗会在同一会话窗口**新开一个标签页**并计入历史,原页面与 opener 上下文保留;非 HTTP(S) 弹窗(空 URL 弹窗承接、`mailto:`、自定义协议)仍**放行原生窗口**,交给系统处理 —— 这类弹窗不纳入会话模型。
@@ -465,8 +465,14 @@ export interface BrowserTab {
465
465
  /** Whether this tab is the active one. */
466
466
  readonly active: boolean;
467
467
  }
468
- /** Request to open a URL: into the active tab or a new tab. */
469
- export interface BrowserOpenRequest {
468
+ /** One page a carrier holds, whether or not this plugin created it. */
469
+ export interface CarrierPage {
470
+ /** Stable within one carrier session; used to address the page. */
471
+ readonly id: string;
472
+ readonly url: string;
473
+ readonly title?: string;
474
+ }
475
+ /** Request to open a URL: into the active tab or a new tab. */ export interface BrowserOpenRequest {
470
476
  /** The URL to open. */
471
477
  readonly url: string;
472
478
  /** Open in a new tab (parallel). Default false (reuse the active tab). */
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
26
26
  import type { BrowserUserAction } from './provider.js';
27
+ import type { CarrierPage } from '../browser/types.js';
27
28
  /** Absolute path of the endpoint file the shell writes. */
28
29
  export declare function bridgeEndpointPath(): string;
29
30
  /**
@@ -118,8 +119,56 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
118
119
  * @returns the host, or undefined when no bridge is available.
119
120
  */
120
121
  static discover(sessionId?: string): Promise<DesktopBridgeViewHost | undefined>;
122
+ /**
123
+ * Whether the last discovery refused a bridge because its sidebar panel is an iframe.
124
+ *
125
+ * Recorded so the entry point can say WHY the plugin is self-hosting on a shell that does have a
126
+ * bridge. Otherwise the user sees a second browser window and no explanation, on a machine where
127
+ * they just followed instructions to install the bridge.
128
+ */
129
+ private static refuseIframe;
130
+ /** Whether discovery refused a bridge for an undrivable sidebar panel (see {@link discover}). */
131
+ static refusedIframePanel(): boolean;
121
132
  /** Whether this host can back views: the bridge already answered `list`. */
122
133
  available(): boolean;
134
+ /**
135
+ * Every page this conversation's sidebar panel currently holds.
136
+ *
137
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
138
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
139
+ * — but without this the tab model reports only the ones the provider created, so the rest are
140
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
141
+ *
142
+ * The bridge's `list` already answers with the sidebar guests, which is the same information, so
143
+ * this asks for it rather than reaching into the shell again.
144
+ * @returns the panel's pages, or an empty list when the bridge cannot answer.
145
+ */
146
+ /**
147
+ * Every sidebar page this conversation holds that THIS HOST did not open.
148
+ *
149
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
150
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
151
+ * — but without this the tab model reports only the ones the provider created, so the rest are
152
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
153
+ *
154
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
155
+ * caller cannot do it: the provider knows a tab by its own id and the carrier knows a page by the
156
+ * guest id, and the two never match. The first version returned everything and relied on the
157
+ * caller to filter, which reported the same page twice — the mapping that makes the match
158
+ * possible exists only here.
159
+ * @returns the panel's pages that are not ours, or an empty list when the bridge cannot answer.
160
+ */
161
+ listPages(): Promise<readonly CarrierPage[]>;
162
+ /**
163
+ * Close one sidebar page by the id {@link listPages} reported.
164
+ *
165
+ * The bridge closes sidebar pages by TITLE, and only the ones the caller owns — that is what
166
+ * stopped one conversation's teardown from destroying another's page. So the title is looked up
167
+ * through the same listing, and a page that no longer appears is treated as already gone rather
168
+ * than as an error: the caller asked for it to be closed, and it is.
169
+ * @param id - the sidebar guest id, as a string.
170
+ */
171
+ closePage(id: string): Promise<void>;
123
172
  /**
124
173
  * The guest backing a view, materialized on first use.
125
174
  *