dsh-builtin-browser 0.4.9 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/desktop-bridge/install.mjs +77 -19
- package/desktop-bridge/plugin-browser-bridge.js +49 -3
- package/docs/limitations.md +8 -0
- package/lib/browser/types.d.ts +8 -2
- package/lib/browser-electron/desktop-bridge-host.d.ts +49 -0
- package/lib/browser-electron/desktop-bridge-host.js +116 -2
- package/lib/browser-electron/entry.js +13 -0
- package/lib/browser-electron/provider.d.ts +41 -0
- package/lib/browser-electron/provider.js +36 -3
- package/lib/browser-electron/system-browser.d.ts +40 -0
- package/lib/browser-electron/system-browser.js +119 -14
- package/package.json +1 -1
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/desktop-bridge-host.ts +116 -2
- package/src/browser-electron/entry.ts +15 -0
- package/src/browser-electron/provider.ts +79 -3
- package/src/browser-electron/system-browser.ts +123 -14
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,179 @@
|
|
|
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
|
+
|
|
7
180
|
# 第四十五轮(2026-10-15,系统浏览器载体的启动生命周期:多开空白页 + 重启后孤儿)
|
|
8
181
|
|
|
9
182
|
报告者给了一份很硬的证据链 —— CDP `/json/list` 每 200ms 采样的时序、五处建议改法、以及**一条自我更正**(他撤回的第一条建议:去掉启动参数里的 URL 会让 Chrome 变成两个窗口,更糟)。两个问题都发生"把浏览器拉起来"的那一刻,除此之外毫无关系。
|
|
@@ -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 =
|
|
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
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// this
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/docs/limitations.md
CHANGED
|
@@ -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:`、自定义协议)仍**放行原生窗口**,交给系统处理 —— 这类弹窗不纳入会话模型。
|
package/lib/browser/types.d.ts
CHANGED
|
@@ -465,8 +465,14 @@ export interface BrowserTab {
|
|
|
465
465
|
/** Whether this tab is the active one. */
|
|
466
466
|
readonly active: boolean;
|
|
467
467
|
}
|
|
468
|
-
/**
|
|
469
|
-
export interface
|
|
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
|
*
|
|
@@ -163,7 +163,27 @@ export class DesktopBridgeViewHost {
|
|
|
163
163
|
const probe = new BridgeConnection(endpoint, bridgeEndpointPath());
|
|
164
164
|
try {
|
|
165
165
|
const answer = await probe.call({ op: 'list' }, 5_000);
|
|
166
|
-
|
|
166
|
+
if (answer.ok !== true)
|
|
167
|
+
return undefined;
|
|
168
|
+
// Refuse a panel this plugin cannot drive, before adopting it.
|
|
169
|
+
//
|
|
170
|
+
// The sidebar browser renders as a <webview> only where the shell exposes
|
|
171
|
+
// `dshDesktop.browser` and enables `webviewTag`. Where it renders as an <iframe> there is
|
|
172
|
+
// no webContents, no `getWebContentsId()` and no CDP endpoint — and every tool here is
|
|
173
|
+
// built on CDP, so adopting it means failing every call, with no path back to a carrier
|
|
174
|
+
// that works. Reported on DSH Desktop 2.0.17 with both conditions measured.
|
|
175
|
+
//
|
|
176
|
+
// Read here WITHOUT an owner on purpose, because the question is not about a conversation:
|
|
177
|
+
// whether the panel is a webview is decided by the shell's preload and window options, so
|
|
178
|
+
// it is the same answer for every conversation and needs no session context.
|
|
179
|
+
//
|
|
180
|
+
// `none` and `unknown` are NOT refusals: a panel that does not exist yet is created by the
|
|
181
|
+
// first open, and a probe that could not read the attribute has learned nothing.
|
|
182
|
+
if (String(answer.frame ?? 'unknown') === 'iframe') {
|
|
183
|
+
DesktopBridgeViewHost.refuseIframe = true;
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
return new DesktopBridgeViewHost(endpoint, sessionId);
|
|
167
187
|
}
|
|
168
188
|
finally {
|
|
169
189
|
probe.close();
|
|
@@ -174,10 +194,95 @@ export class DesktopBridgeViewHost {
|
|
|
174
194
|
return undefined;
|
|
175
195
|
}
|
|
176
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether the last discovery refused a bridge because its sidebar panel is an iframe.
|
|
199
|
+
*
|
|
200
|
+
* Recorded so the entry point can say WHY the plugin is self-hosting on a shell that does have a
|
|
201
|
+
* bridge. Otherwise the user sees a second browser window and no explanation, on a machine where
|
|
202
|
+
* they just followed instructions to install the bridge.
|
|
203
|
+
*/
|
|
204
|
+
static refuseIframe = false;
|
|
205
|
+
/** Whether discovery refused a bridge for an undrivable sidebar panel (see {@link discover}). */
|
|
206
|
+
static refusedIframePanel() {
|
|
207
|
+
return this.refuseIframe;
|
|
208
|
+
}
|
|
177
209
|
/** Whether this host can back views: the bridge already answered `list`. */
|
|
178
210
|
available() {
|
|
179
211
|
return true;
|
|
180
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Every page this conversation's sidebar panel currently holds.
|
|
215
|
+
*
|
|
216
|
+
* The shell owns page creation inside the panel: a link the user or the agent clicks may open a
|
|
217
|
+
* new page, and nothing tells the provider. Those pages are real — they are what the human sees
|
|
218
|
+
* — but without this the tab model reports only the ones the provider created, so the rest are
|
|
219
|
+
* invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
|
|
220
|
+
*
|
|
221
|
+
* The bridge's `list` already answers with the sidebar guests, which is the same information, so
|
|
222
|
+
* this asks for it rather than reaching into the shell again.
|
|
223
|
+
* @returns the panel's pages, or an empty list when the bridge cannot answer.
|
|
224
|
+
*/
|
|
225
|
+
/**
|
|
226
|
+
* Every sidebar page this conversation holds that THIS HOST did not open.
|
|
227
|
+
*
|
|
228
|
+
* The shell owns page creation inside the panel: a link the user or the agent clicks may open a
|
|
229
|
+
* new page, and nothing tells the provider. Those pages are real — they are what the human sees
|
|
230
|
+
* — but without this the tab model reports only the ones the provider created, so the rest are
|
|
231
|
+
* invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
|
|
232
|
+
*
|
|
233
|
+
* Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
|
|
234
|
+
* caller cannot do it: the provider knows a tab by its own id and the carrier knows a page by the
|
|
235
|
+
* guest id, and the two never match. The first version returned everything and relied on the
|
|
236
|
+
* caller to filter, which reported the same page twice — the mapping that makes the match
|
|
237
|
+
* possible exists only here.
|
|
238
|
+
* @returns the panel's pages that are not ours, or an empty list when the bridge cannot answer.
|
|
239
|
+
*/
|
|
240
|
+
async listPages() {
|
|
241
|
+
try {
|
|
242
|
+
const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000);
|
|
243
|
+
const guests = Array.isArray(answer.sidebar) ? answer.sidebar : [];
|
|
244
|
+
const ours = new Set();
|
|
245
|
+
for (const view of this.views.values()) {
|
|
246
|
+
if (typeof view.guest === 'number')
|
|
247
|
+
ours.add(view.guest);
|
|
248
|
+
}
|
|
249
|
+
return guests
|
|
250
|
+
.filter((guest) => typeof guest?.id === 'number')
|
|
251
|
+
.filter(guest => !ours.has(guest.id))
|
|
252
|
+
.map(guest => ({
|
|
253
|
+
id: String(guest.id),
|
|
254
|
+
url: typeof guest.url === 'string' ? guest.url : '',
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// A bridge that cannot answer has a real problem, and the next command reports it. Failing
|
|
259
|
+
// the tab LIST here would make a listing worse than the driving it exists to describe.
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Close one sidebar page by the id {@link listPages} reported.
|
|
265
|
+
*
|
|
266
|
+
* The bridge closes sidebar pages by TITLE, and only the ones the caller owns — that is what
|
|
267
|
+
* stopped one conversation's teardown from destroying another's page. So the title is looked up
|
|
268
|
+
* through the same listing, and a page that no longer appears is treated as already gone rather
|
|
269
|
+
* than as an error: the caller asked for it to be closed, and it is.
|
|
270
|
+
* @param id - the sidebar guest id, as a string.
|
|
271
|
+
*/
|
|
272
|
+
async closePage(id) {
|
|
273
|
+
const numeric = Number(id);
|
|
274
|
+
if (!Number.isFinite(numeric))
|
|
275
|
+
throw new Error(`not a sidebar page id: ${id}`);
|
|
276
|
+
const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000);
|
|
277
|
+
const guests = Array.isArray(answer.sidebar) ? answer.sidebar : [];
|
|
278
|
+
const title = guests
|
|
279
|
+
.filter((guest) => Number(guest?.id) === numeric)
|
|
280
|
+
.map(guest => String(guest.title ?? ''))
|
|
281
|
+
.find(candidate => candidate !== '');
|
|
282
|
+
if (title === undefined)
|
|
283
|
+
return;
|
|
284
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles: [title], owner: this.owner }, 10_000);
|
|
285
|
+
}
|
|
181
286
|
/**
|
|
182
287
|
* The guest backing a view, materialized on first use.
|
|
183
288
|
*
|
|
@@ -214,8 +319,17 @@ export class DesktopBridgeViewHost {
|
|
|
214
319
|
owner,
|
|
215
320
|
...url !== undefined && url !== '' ? { url } : {},
|
|
216
321
|
}, 30_000);
|
|
217
|
-
if (answer.ok !== true)
|
|
322
|
+
if (answer.ok !== true) {
|
|
323
|
+
// A bridge that answers but cannot produce a page is as unusable as one that has exited, and
|
|
324
|
+
// the difference matters here: `onLost` is what returns the surface to the self-hosted
|
|
325
|
+
// browser. Without this the same failure repeats on every call until DSH is restarted — on a
|
|
326
|
+
// shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
|
|
327
|
+
// the bridge made it worse than not installing it": the carrier was switched to one that
|
|
328
|
+
// cannot work, with no path back to the one that does.
|
|
329
|
+
if (String(answer.error).includes('produced no page'))
|
|
330
|
+
this.reportLost();
|
|
218
331
|
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
|
|
332
|
+
}
|
|
219
333
|
const id = Number(answer.id);
|
|
220
334
|
if (!Number.isFinite(id) || id <= 0) {
|
|
221
335
|
throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation');
|
|
@@ -303,6 +303,19 @@ export function apply(ctx, config) {
|
|
|
303
303
|
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
304
304
|
return;
|
|
305
305
|
}
|
|
306
|
+
// A bridge exists but its sidebar panel is an iframe. Saying nothing here would leave the user
|
|
307
|
+
// with a second browser window on a machine where they just followed the instructions to
|
|
308
|
+
// install the bridge, and no way to know the two facts are related. The carrier is not
|
|
309
|
+
// unavailable by accident: an iframe has no CDP endpoint, so adopting it would fail every call
|
|
310
|
+
// instead of falling back — see DesktopBridgeViewHost.discover.
|
|
311
|
+
if (DesktopBridgeViewHost.refusedIframePanel()) {
|
|
312
|
+
ctx.logger?.warn?.('dsh-builtin-browser: a desktop bridge is installed, but this shell renders the sidebar '
|
|
313
|
+
+ 'browser panel as an iframe rather than a webview, which exposes no CDP endpoint and '
|
|
314
|
+
+ 'cannot be driven. Using the browser this plugin hosts itself instead — that is the '
|
|
315
|
+
+ 'independent window, not the sidebar. A shell that exposes `dshDesktop.browser` and '
|
|
316
|
+
+ 'enables `webviewTag` would provide a drivable panel.');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
306
319
|
// The user explicitly asked for the sidebar, so silence would leave them with a setting
|
|
307
320
|
// that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
|
|
308
321
|
// whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
|