dsh-builtin-browser 0.1.21 → 0.1.22
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/README.en.md +18 -10
- package/README.md +18 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser-electron/host-main.js +97 -15
- package/lib/browser-electron/provider.d.ts +82 -8
- package/lib/browser-electron/provider.js +256 -38
- package/lib/browser-electron/remote-host.d.ts +25 -0
- package/lib/browser-electron/remote-host.js +97 -15
- package/lib/tool-browser/index.js +2 -2
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +106 -9
- package/src/browser-electron/provider.ts +284 -38
- package/src/browser-electron/remote-host.ts +102 -15
- package/src/tool-browser/index.ts +2 -2
package/README.en.md
CHANGED
|
@@ -150,7 +150,7 @@ See the full list in [Tool reference](#tool-reference).
|
|
|
150
150
|
| `browser_clear` | Clear an input/textarea/contenteditable, or uncheck (`target`-located) | ✅ |
|
|
151
151
|
| `browser_get_value` | Read an element's current value for verification (`target`-located) | – |
|
|
152
152
|
| `browser_scrape` | Structured extraction: container selector + field map (`selector@attr`), static CSS only, CSP-safe | – |
|
|
153
|
-
| `browser_screenshot` | Capture, optional `fullPage`, `savePath`, JPEG (`format`/`quality`) and scaling (`maxWidth`/`maxHeight`) | – |
|
|
153
|
+
| `browser_screenshot` | Capture, optional `fullPage`, `savePath`, JPEG (`format`/`quality`) and scaling (`maxWidth`/`maxHeight`); `savePath` shares the download gate (confined to `downloadDir`, never overwrites) | – |
|
|
154
154
|
| `browser_list_tabs` | List the session's tabs | – |
|
|
155
155
|
| `browser_switch_tab` | Switch to a tab by id (also switches the visible view when self-hosted) | ✅ |
|
|
156
156
|
| `browser_close_tab` | Close a tab by id; closing the active tab activates the next | – |
|
|
@@ -159,7 +159,7 @@ See the full list in [Tool reference](#tool-reference).
|
|
|
159
159
|
| `browser_reset_session` | Close and rebuild this task's browser session | ✅ |
|
|
160
160
|
| `browser_history` | Operation log (newest last), with per-step success/error and result summary | – |
|
|
161
161
|
| `browser_replay` | Replay one step by sequence number (navigate/execute/click/type) | ✅ |
|
|
162
|
-
| `browser_download` | Download an HTTP(S) URL with session cookies to a local file (absolute `savePath`, 256 MB cap) | ✅ |
|
|
162
|
+
| `browser_download` | Download an HTTP(S) URL with session cookies to a local file (absolute `savePath` inside `downloadDir`, never overwrites, 256 MB cap) | ✅ |
|
|
163
163
|
| `browser_auth` | Export/restore cookies (login persistence, self-hosted) | ✅ |
|
|
164
164
|
| `browser_challenge` | Detect a human-verification challenge (CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
|
|
165
165
|
| `browser_restrict` | Restrict allowed browser actions (allow-list; empty list lifts it). **Soft guardrail** — the model can lift it itself; not a security boundary | – |
|
|
@@ -168,7 +168,7 @@ See the full list in [Tool reference](#tool-reference).
|
|
|
168
168
|
|
|
169
169
|
### Waiting for the page
|
|
170
170
|
|
|
171
|
-
- **
|
|
171
|
+
- **`browser_open` and navigation already wait (bounded) for the new document to parse** (`readyState` plus a document fingerprint, so a same-URL reload or an A→B→A redirect is not mistaken for the old document), but they do **not** wait for async content: on slow sites or XHR-rendered pages, call `browser_wait` before `browser_snapshot` — pass `url` (what you opened) and an optional `selector`, and wait for `ready: true` — otherwise you snapshot the old document, a white screen, or an empty element list.
|
|
172
172
|
- **Content you cannot see may live in an iframe / Shadow DOM**: snapshots and the a11y tree pierce same-origin iframes and shadow roots and mark them `(iframe)`; coordinates are always top-document, so `browser_click` works directly. DOM selectors are frame-scoped — reach them via `iframe.contentDocument` in `browser_execute`.
|
|
173
173
|
|
|
174
174
|
### Semantic targets and the a11y tree
|
|
@@ -195,7 +195,7 @@ The plugin mounts through `cordis.patch.yml` (three rows); per-row config:
|
|
|
195
195
|
| `browser-electron` | `httpOnly` | boolean | `true` | Allow HTTP(S) navigation only; other protocols (e.g. `file:`/`data:`) rejected (`BROWSER_NAVIGATION_BLOCKED`) |
|
|
196
196
|
| `browser-electron` | `snapshotMaxElements` | number | `60` | Max snapshot elements before truncation |
|
|
197
197
|
| `browser-electron` | `contentMaxChars` | number | `100000` | Default content character cap |
|
|
198
|
-
| `browser-electron` | `downloadDir` | string |
|
|
198
|
+
| `browser-electron` | `downloadDir` | string | system Downloads folder (`Downloads`/`下载`/`下載`, or `XDG_DOWNLOAD_DIR`, auto-detected) | Confine `browser_download` AND `browser_screenshot` save paths to this directory, never overwriting an existing file (stops a prompt-injected agent writing or replacing arbitrary paths); override for a sandbox dir |
|
|
199
199
|
| `tool-browser` | `timeoutMs` | number | `60000` | Cooperative tool timeout (ms) |
|
|
200
200
|
| `tool-browser` | `tabTools` | boolean | `true` | Register tab-management tools (`browser_list_tabs` etc.) |
|
|
201
201
|
|
|
@@ -213,7 +213,7 @@ agent (browser_* tools)
|
|
|
213
213
|
- **Provider** (`browser-electron` row): operates views through the `ElectronBrowserViewHost` seam (create/destroy/show, `sendCommand`), implemented with real Electron objects by the shell.
|
|
214
214
|
- **Tools** (`tool-browser` row): the 33 model-facing `browser_*` tools, maintaining one browser session per calling task (DSH session).
|
|
215
215
|
|
|
216
|
-
**Self-hosted mode**: without a desktop shell, the plugin spawns its own Electron child process (`host-main.js`) and drives it over loopback TCP JSON-RPC. The RPC is authenticated with a random per-spawn token delivered over **both stdin and an environment variable** — on Windows the Electron GUI process never receives piped stdin, so the env fallback keeps the handshake reliable. The child auto-restarts after a crash; the plugin prefers its own bundled electron package — packaged app executables (e.g. DSH Desktop.exe) are never reused as the spawnable binary, which would launch the app itself and exit immediately; screenshots prefer Electron's native `capturePage` (CDP capture can hang with multiple views in the window); the
|
|
216
|
+
**Self-hosted mode**: without a desktop shell, the plugin spawns its own Electron child process (`host-main.js`) and drives it over loopback TCP JSON-RPC. The RPC is authenticated with a random per-spawn token delivered over **both stdin and an environment variable** — on Windows the Electron GUI process never receives piped stdin, so the env fallback keeps the handshake reliable. The child auto-restarts after a crash; the plugin prefers its own bundled electron package — packaged app executables (e.g. DSH Desktop.exe) are never reused as the spawnable binary, which would launch the app itself and exit immediately; screenshots prefer Electron's native `capturePage` (CDP capture can hang with multiple views in the window); the Electron lookup order follows below (33.x has a compositor defect; ≥ 40 recommended; the electron 44+ package no longer downloads its binary at install time — if it is missing on first use, the tool errors and tells you to run `npx install-electron` first, needs network).
|
|
217
217
|
|
|
218
218
|
**The self-hosted browser IS a real browser**: every task (DSH session) gets its **own browser window** with a full toolbar — address bar, back/forward/reload buttons, and a tab strip (new/switch/close tabs). A human can use it exactly like Chrome: type a URL in the address bar (https:// is added automatically), click tabs, open new ones. Keyboard focus follows your clicks — **click the address bar to type, click the page to interact** (Windows focus routing; fixes the case where clicks did not move focus and the address bar could not receive typed URLs). Human and agent actions feed the **same session model** (same tabs, history, and navigation); the window title always shows the task label plus the page title/URL, and views follow the window size on resize. A window closes automatically with its session when the task ends.
|
|
219
219
|
|
|
@@ -226,7 +226,7 @@ The browser's **visible view**, the **browser column layout**, and the **column-
|
|
|
226
226
|
## Requirements
|
|
227
227
|
|
|
228
228
|
- DeepSeek Harness (dsh) with the matching profile (`web` / `desktop`, etc.)
|
|
229
|
-
- **Electron runtime** (required dependency, installed automatically with the plugin, ≥ 40 recommended; 44+
|
|
229
|
+
- **Electron runtime** (required dependency, installed automatically with the plugin, ≥ 40 recommended; the 44+ binary is not downloaded at install time — if missing, follow the error and run `npx install-electron` first, needs network):
|
|
230
230
|
- `ELECTRON_PATH` can point at another binary explicitly (highest priority);
|
|
231
231
|
- **DSH Desktop**: the packaged host exe (`DSH Desktop.exe`) is **never reused** — packaged apps cannot be spawned with a script argument and misuse exits instantly (issue #6); the bundled electron is used directly, and dev-mode **bare** Electron hosts are still reusable;
|
|
232
232
|
- **plain `dsh web` self-hosted**: uses the bundled electron package directly
|
|
@@ -250,13 +250,13 @@ The browser's **visible view**, the **browser column layout**, and the **column-
|
|
|
250
250
|
- `fullPage` capture is flaky under software compositing on some hosts.
|
|
251
251
|
- CAPTCHA cannot be solved automatically: snapshots flag detected challenges; ask the human to complete it in the shared window instead of retrying.
|
|
252
252
|
- Private mode (`privateMode`) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
|
|
253
|
-
- `browser_download` fetches in the page context (keeps logins) and is subject to same-origin/CORS constraints; HTTP(S) targets only; `savePath` must be absolute (default
|
|
253
|
+
- `browser_download` fetches in the page context (keeps logins) and is subject to same-origin/CORS constraints; HTTP(S) targets only; `savePath` must be absolute and inside `downloadDir` (default: the system Downloads folder, auto-detecting `Downloads`/`下载`/`下載` and `XDG_DOWNLOAD_DIR`; override with `downloadDir`) and never replaces an existing file; `browser_screenshot`'s `savePath` goes through the same gate; single files are capped at 256 MB (streamed with a Content-Length early reject) and are written by the browser child itself (temp file + atomic rename).
|
|
254
254
|
- The self-hosted browser's cookies are stored in plaintext on disk (Electron default); deployments that need encrypted-at-rest should integrate a system keychain / DPAPI at the host layer.
|
|
255
255
|
- `browser_restrict` is a **soft guardrail** against accidental actions, not a security boundary: the model can lift it itself.
|
|
256
|
-
- Popups (`window.open` / `target=_blank`) open as a **new tab** in the same session window
|
|
256
|
+
- Popups (`window.open` / `target=_blank`) no longer overwrite the current view: HTTP(S) popups open as a **new tab** in the same session window, recorded in the session history, keeping the original page and its opener context alive. Non-HTTP(S) popups (empty-URL popup handoffs, `mailto:`, custom schemes) are still **allowed as native windows** and handed to the system — such windows are simply not part of the session model.
|
|
257
257
|
- The `browser_auth` cookie round-trip does not preserve `hostOnly`/`sameSite` (host-only cookies come back as domain cookies); it is available on the self-hosted browser only.
|
|
258
|
-
- After a self-hosted child crash (or a DSH restart that kills it) the browser host restarts automatically, and sessions opened before the crash **rebuild on their next use** — only page state is lost, no manual `browser_reset_session` needed (it still works for an explicit reset).
|
|
259
|
-
- Electron 44+ downloads its binary (~100 MB)
|
|
258
|
+
- After a self-hosted child crash (or a DSH restart that kills it) the browser host restarts automatically, and sessions opened before the crash **rebuild on their next use** — only page state is lost, no manual `browser_reset_session` needed (it still works for an explicit reset). A new view preloads `about:blank` (bounded 3 s) before creation so it always has a live renderer, host-side commands are bounded at 20 s, and the child's stderr plus exit code/signal are written to `$DSH_HOME/logs/dsh-builtin-browser-host.log` (2 MB self-truncating) so a plain `dsh web` self-hosted setup can diagnose a crash loop itself.
|
|
259
|
+
- The electron package ships with the plugin, but Electron 44+ no longer downloads its binary at install time (~100 MB, needs network) — the probe is filesystem-only and never triggers its lazy download, so a missing binary surfaces as a clear error on first use telling you to run `npx install-electron` first; alternatively pre-install a binary and point `ELECTRON_PATH` at it.
|
|
260
260
|
- This plugin contains no browser-column UI — that is host-shell territory; do not treat "browser column" as a plugin feature.
|
|
261
261
|
|
|
262
262
|
## Development
|
|
@@ -304,6 +304,14 @@ Code layout:
|
|
|
304
304
|
| 13 | 2026-08-28 | **macOS/Linux untypeable inputs fix** (issue #7): the Windows focus routing from 0.1.16 (mousedown force-focus + window-refocus restore) shipped without a platform guard and fought macOS native click-to-focus, leaving login inputs untypeable → both handlers are now gated to `win32`; non-Windows restores native behavior |
|
|
305
305
|
| 14 | 2026-08-28 | **window.open/target=_blank opens a new tab** (issue #8): HTTP(S) popups no longer loadURL over the current view; they are handed to the parent and open as a **new tab in the same session window** — the opener page and its context survive (portal "workspace" jumps no longer 403) and the jump lands in session history; ungrouped views keep the fallback; non-HTTP popups still go to the system |
|
|
306
306
|
| **0.1.21** | 2026-08-28 | **Release**: rounds 13/14 ship as **0.1.21** (build clean, 25/25 tests pass) |
|
|
307
|
+
| macOS binary probe | 2026-09-09 | **Electron.app layout detection** (issues #9 / #14): `electronDistExe()` only probed `dist/electron(.exe)`, so macOS's `dist/Electron.app/Contents/MacOS/Electron` was never found → darwin candidate added to the shared platform probe (bundled + profile/anchor layers both benefit); regression test added |
|
|
308
|
+
| 15 | 2026-09-16 | **Toolbar parse-time SyntaxError** (issue #11): the inline script's `const bridge = window.bridge` collided with the non-configurable global installed by `contextBridge.exposeInMainWorld` (`HasRestrictedGlobalProperty`) → a **parse-time** early error, so not one line ran and the address bar, the four nav buttons, the tab strip and the error bar were all dead → the whole script is wrapped in an IIFE and the handle renamed `tb`, making the class of collision structurally impossible; 3 toolbar regression tests parse the snippets out of the **published artifact** and execute them in a `vm` under contextBridge semantics |
|
|
309
|
+
| 16 | 2026-09-16 | **Self-hosted trio fixes** (issue #10): (1) `browser_open` waits (bounded 5 s) for the new document to settle via a `performance.timeOrigin` fingerprint + `readyState`, so it no longer returns a titled-but-empty snapshot; (2) a new waiting `presentView` barrier (materialize the view, then showView, then a ping barrier; child dispatch is strictly serial) is required before `click`/`type`/`key` dispatch `Input.*`, which now fail loudly with `BROWSER_VIEW_NOT_PRESENTED` instead of faking success; (3) `createView` preloads `about:blank` (bounded 3 s) so a fresh view always has a live renderer (the step that wedged after a host restart); (4) `did-navigate` forces re-presentation, host commands are bounded at 20 s, child stderr plus exit code/signal go to `$DSH_HOME/logs/dsh-builtin-browser-host.log` (2 MB self-truncating), and `locateTab` accepts a bare uuid as well as `tab:<uuid>` |
|
|
310
|
+
| 17 | 2026-09-16 | **Screenshot savePath confined + localized download dir** (issue #13): `browser_screenshot` wrote straight to `writeFileSync` — anywhere the process could reach, silently replacing existing files (bypassing the read-only sandbox's write protection) → one shared `admitSavePath` gate for downloads AND screenshots (absolute, inside `downloadDir`, never overwriting an existing file), plus parent-directory creation for screenshots; the default download directory is no longer hardcoded to `~/Downloads` but probed in order: `downloadDir` → `XDG_DOWNLOAD_DIR` → `~/Downloads` / `~/下载` / `~/下載` → fallback (a Chinese desktop needs no configuration) |
|
|
311
|
+
|
|
312
|
+
| **0.1.22** | 2026-09-16 | **Release**: the macOS binary-probe fix (issues #9 / #14) and rounds 15–17 (issue #11 toolbar SyntaxError / #10 self-hosted trio / #13 screenshot savePath + download dir) ship as **0.1.22** (build clean, **35/35 tests pass**, tag `v0.1.22`) |
|
|
313
|
+
|
|
314
|
+
> The npm badge at the top is the authority on the registry's latest version: `0.1.22` is committed and tagged; if the badge still reads `0.1.21`, that version is not published yet.
|
|
307
315
|
|
|
308
316
|
## Acknowledgements
|
|
309
317
|
|
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
150
150
|
| `browser_clear` | 清空输入/文本域/contenteditable,或取消勾选(按 `target` 定位) | ✅ |
|
|
151
151
|
| `browser_get_value` | 读取元素当前值(操作后验证用;按 `target` 定位) | – |
|
|
152
152
|
| `browser_scrape` | 结构化提取:容器选择器 + 字段映射(`选择器[@属性]`),静态 CSS 查询、CSP 安全 | – |
|
|
153
|
-
| `browser_screenshot` | 截图,可选 `fullPage`、`savePath`、JPEG(`format`/`quality`)与缩放(`maxWidth`/`maxHeight`) | – |
|
|
153
|
+
| `browser_screenshot` | 截图,可选 `fullPage`、`savePath`、JPEG(`format`/`quality`)与缩放(`maxWidth`/`maxHeight`);`savePath` 与下载同一准入门(限定在 `downloadDir` 内、不覆盖已有文件) | – |
|
|
154
154
|
| `browser_list_tabs` | 当前会话的标签列表 | – |
|
|
155
155
|
| `browser_switch_tab` | 按 id 切换标签(自托管下同步切换可见视图) | ✅ |
|
|
156
156
|
| `browser_close_tab` | 按 id 关闭标签;关闭活动标签后激活下一个 | – |
|
|
@@ -159,7 +159,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
159
159
|
| `browser_reset_session` | 关闭并重建本任务的浏览器会话 | ✅ |
|
|
160
160
|
| `browser_history` | 操作日志(最新在后),含成功/失败与结果摘要 | – |
|
|
161
161
|
| `browser_replay` | 按序号回放某一步(navigate/execute/click/type) | ✅ |
|
|
162
|
-
| `browser_download` | 带会话 cookie 下载 HTTP(S) URL 到本地文件(`savePath`
|
|
162
|
+
| `browser_download` | 带会话 cookie 下载 HTTP(S) URL 到本地文件(`savePath` 必须绝对路径且位于 `downloadDir` 内,不覆盖已有文件,上限 256MB) | ✅ |
|
|
163
163
|
| `browser_auth` | 导出/恢复 cookie(登录态持久化,自托管可用) | ✅ |
|
|
164
164
|
| `browser_challenge` | 检测人机验证(CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
|
|
165
165
|
| `browser_restrict` | 限制允许的浏览器动作(白名单;空列表解除)。**软护栏**,模型可自行解除,非安全边界 | – |
|
|
@@ -168,7 +168,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
168
168
|
|
|
169
169
|
### 等待页面就绪
|
|
170
170
|
|
|
171
|
-
- **`browser_open`
|
|
171
|
+
- **`browser_open`/导航已有界等待新文档解析完成**(`readyState` + 文档指纹,不把同 URL 重载或 A→B→A 重定向误判成旧文档),但仍**不等异步内容**:慢站点或依赖 XHR 渲染的页面,请在 `browser_snapshot` 之前先 `browser_wait`——传 `url`(你打开的地址)与可选的 `selector`,等它返回 `ready: true` 再拍照,否则拍到的是旧页面或白屏/空元素列表。
|
|
172
172
|
- 页面里看不到的内容先想 iframe / Shadow DOM:快照与无障碍树会穿透同源 iframe 与 shadow root 并标注 `(iframe)`,坐标始终是顶层文档坐标,可直接用 `browser_click`;DOM 选择器则是 frame 作用域的,需用 `browser_execute` 经 `iframe.contentDocument` 访问。
|
|
173
173
|
|
|
174
174
|
### 语义定位(`target`)与无障碍树
|
|
@@ -195,7 +195,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
195
195
|
| `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;其余协议(如 `file:`/`data:`)拒绝(`BROWSER_NAVIGATION_BLOCKED`) |
|
|
196
196
|
| `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数,超出截断 |
|
|
197
197
|
| `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
|
|
198
|
-
| `browser-electron` | `downloadDir` | 字符串 |
|
|
198
|
+
| `browser-electron` | `downloadDir` | 字符串 | 系统下载目录(自动识别 `Downloads`/`下载`/`下載`,或 `XDG_DOWNLOAD_DIR`) | 限定 `browser_download` 与 `browser_screenshot` 的保存路径必须位于该目录内,且不覆盖已有文件(防 agent 写任意路径或替换现有文件);默认收敛到系统下载目录,可改为沙箱目录 |
|
|
199
199
|
| `tool-browser` | `timeoutMs` | 数字 | `60000` | 工具协作超时(ms) |
|
|
200
200
|
| `tool-browser` | `tabTools` | 布尔 | `true` | 是否注册标签管理工具(`browser_list_tabs` 等) |
|
|
201
201
|
|
|
@@ -213,7 +213,7 @@ agent (browser_* 工具)
|
|
|
213
213
|
- **provider 层**(`browser-electron` 行)通过 `ElectronBrowserViewHost` 接缝操作视图(创建/销毁/显示/`sendCommand`),由真实外壳用 Electron 对象实现;
|
|
214
214
|
- **工具层**(`tool-browser` 行)提供模型侧的 33 个 `browser_*` 工具,按调用方任务(DSH 会话)维护独立的浏览器会话。
|
|
215
215
|
|
|
216
|
-
**自托管模式**:没有桌面外壳时,插件自己拉起一个 Electron 子进程(`host-main.js`),通过本机 TCP JSON-RPC 驱动。RPC 带随机 token 认证,token 经 **stdin + 环境变量双通道**传递——Windows 上 Electron 是 GUI 子系统进程、收不到 piped stdin,环境变量兜底保证握手稳定。子进程崩溃会自动重启;优先使用随插件安装的 electron 包(**打包应用如 DSH Desktop.exe 不会被误当作可复用二进制**,避免 spawn 秒退);截图优先走 Electron 原生 `capturePage`(CDP 截图在多视图下会挂起)
|
|
216
|
+
**自托管模式**:没有桌面外壳时,插件自己拉起一个 Electron 子进程(`host-main.js`),通过本机 TCP JSON-RPC 驱动。RPC 带随机 token 认证,token 经 **stdin + 环境变量双通道**传递——Windows 上 Electron 是 GUI 子系统进程、收不到 piped stdin,环境变量兜底保证握手稳定。子进程崩溃会自动重启;优先使用随插件安装的 electron 包(**打包应用如 DSH Desktop.exe 不会被误当作可复用二进制**,避免 spawn 秒退);截图优先走 Electron 原生 `capturePage`(CDP 截图在多视图下会挂起);Electron 的定位顺序见下(33.x 有合成器缺陷,建议 ≥ 40;44+ 的 electron 包不再随安装自动下载二进制,首次使用若缺失会按报错提示先 `npx install-electron`,需联网)。
|
|
217
217
|
|
|
218
218
|
**自托管浏览器就是一台真正的浏览器**:每个任务(DSH 会话)拥有**独立的浏览器窗口**,窗口自带完整工具栏——地址栏、后退/前进/刷新按钮、标签条(新建/切换/关闭标签)。人可以直接像用 Chrome 一样使用它:在地址栏输入网址(自动补 `https://`)、点标签切换页面、开新标签;键盘焦点跟随点击——**点地址栏即可输入、点页面即可操作**(Windows 焦点路由,修复了点击不转移焦点导致地址栏无法输入的问题)。agent 与人的操作都汇入**同一个会话模型**(同一套标签、历史与导航),窗口标题实时显示当前任务标识与页面标题/URL,窗口缩放时视图自动跟随。任务结束后窗口随会话自动关闭。
|
|
219
219
|
|
|
@@ -226,7 +226,7 @@ agent (browser_* 工具)
|
|
|
226
226
|
## 环境要求
|
|
227
227
|
|
|
228
228
|
- DeepSeek Harness(dsh),已安装对应 profile(`web` / `desktop` 等)
|
|
229
|
-
- **Electron 运行时**(必装依赖,随插件自动安装,建议 ≥ 40;44+
|
|
229
|
+
- **Electron 运行时**(必装依赖,随插件自动安装,建议 ≥ 40;44+ 的二进制不随安装自动下载,缺失时按报错提示先 `npx install-electron`,需网络):
|
|
230
230
|
- `ELECTRON_PATH` 可显式指定其他二进制(最优先);
|
|
231
231
|
- **DSH Desktop**:打包宿主 exe(`DSH Desktop.exe`)**不复用**——打包应用无法按脚本参数拉起,误用会秒退(issue #6);直接使用随包 electron,开发模式的**裸** Electron 宿主仍可复用;
|
|
232
232
|
- **纯 `dsh web` 自托管**:直接使用随插件安装的 electron 包
|
|
@@ -250,13 +250,13 @@ agent (browser_* 工具)
|
|
|
250
250
|
- 部分主机在软件合成下 `fullPage` 截图不稳定。
|
|
251
251
|
- 人机验证(CAPTCHA)无法自动解决:快照会标注检测到的挑战,此时应请用户在共享窗口中人工完成,而不是反复重试。
|
|
252
252
|
- 无痕模式(`privateMode`)未实现:它需要 Electron 的 session 分区能力,属于宿主层,本插件不承诺。
|
|
253
|
-
- `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath`
|
|
253
|
+
- `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`,可用 `downloadDir` 覆盖),不覆盖已存在文件;`browser_screenshot` 的 `savePath` 走同一准入门;单文件上限 256MB(流式限流,按 Content-Length 提前拒绝),文件由浏览器子进程直接落盘(临时文件 + 原子改名)。
|
|
254
254
|
- 自托管浏览器的 cookie 在磁盘上以明文存储(Electron 默认行为);需要加密落盘的部署应在宿主层接入系统钥匙串 / DPAPI。
|
|
255
255
|
- `browser_restrict` 是防误操作的**软护栏**,不是安全边界:模型可以自行解除白名单。
|
|
256
|
-
- 页面弹窗(`window.open` / `target=_blank`)
|
|
256
|
+
- 页面弹窗(`window.open` / `target=_blank`)不再覆盖当前视图:HTTP(S) 弹窗会在同一会话窗口**新开一个标签页**并计入历史,原页面与 opener 上下文保留;非 HTTP(S) 弹窗(空 URL 弹窗承接、`mailto:`、自定义协议)仍**放行原生窗口**,交给系统处理——这类弹窗不纳入会话模型。
|
|
257
257
|
- `browser_auth` 的 cookie 往返不保留 `hostOnly`/`sameSite` 字段(host-only cookie 恢复后变成 domain cookie);仅自托管浏览器可用。
|
|
258
|
-
- 自托管浏览器子进程崩溃(或宿主 DSH 重启)后会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session`
|
|
259
|
-
- electron
|
|
258
|
+
- 自托管浏览器子进程崩溃(或宿主 DSH 重启)后会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。新视图创建前会先有界加载 `about:blank`(保证视图一存在就有可响应的渲染进程),宿主侧命令另有 20s 有界超时;子进程 stderr 与退出码/信号落到 `$DSH_HOME/logs/dsh-builtin-browser-host.log`(2MB 自截断),纯 `dsh web` 自托管可据此自助排查崩溃循环。
|
|
259
|
+
- electron 随插件安装;但 Electron 44+ 不再随安装下载二进制(约 100MB,需网络)——插件探测是纯文件系统、不触发其懒下载,二进制缺失时首次使用会报错并提示先 `npx install-electron`;也可预装 `ELECTRON_PATH` 指定的二进制。
|
|
260
260
|
- 本插件不含浏览器列 UI——那是宿主外壳的配套,别把"浏览器列"当成插件能力。
|
|
261
261
|
|
|
262
262
|
## 开发
|
|
@@ -304,6 +304,14 @@ npm run build
|
|
|
304
304
|
| 第十三轮 | 2026-08-28 | **macOS/Linux 输入框无法键入修复**(issue #7):0.1.16 引入的 Windows 焦点路由(mousedown 强制 focus + 窗口 refocus 恢复)未做平台判断,与 macOS 原生 click-to-focus 冲突导致登录框收不到键入 → 两处焦点逻辑加 `win32` 平台门,非 Windows 恢复原生行为 |
|
|
305
305
|
| 第十四轮 | 2026-08-28 | **window.open/target=_blank 新开标签**(issue #8):HTTP(S) 弹窗不再 loadURL 覆盖当前视图,转交父进程在**同一会话窗口新开标签**——原页面与 opener 上下文保留(门户「工作台」类跳转不再 403),跳转计入会话历史;未分组视图保留回退;非 HTTP 弹窗仍放行系统 |
|
|
306
306
|
| **0.1.21** | 2026-08-28 | **发布**:第十三/十四轮随 **0.1.21** 发布(构建零错误、25 项测试全绿) |
|
|
307
|
+
| macOS 二进制探测 | 2026-09-09 | **Electron.app 布局探测**(issue #9 / #14):`electronDistExe()` 只认 `dist/electron(.exe)`,macOS 的 `dist/Electron.app/Contents/MacOS/Electron` 永远找不到 → 共用平台探测补 darwin 候选路径(bundled 与 profile/anchor 两层同时受益);新增回归测试 |
|
|
308
|
+
| 第十五轮 | 2026-09-16 | **工具栏脚本解析期 SyntaxError**(issue #11):内联脚本 `const bridge = window.bridge` 与 `contextBridge.exposeInMainWorld` 装上的不可配置全局冲突(`HasRestrictedGlobalProperty`)→ **解析期** early error,整段脚本一行都不执行(地址栏回车 / 四个导航按钮 / 标签条 / 错误条全失效)→ 整段包进 IIFE 并把句柄改名 `tb`,从结构上杜绝同类冲突;新增 3 个工具栏回归测试(从**发布产物**解析出脚本、在 `vm` 里按 contextBridge 语义真执行) |
|
|
309
|
+
| 第十六轮 | 2026-09-16 | **自托管三连修复**(issue #10):① `browser_open` 用 `performance.timeOrigin` 指纹 + `readyState` 有界(5s)等新文档 settle,不再返回"有标题、0 元素"的空快照;② 新增等待式 `presentView` 屏障(先 materialize 视图再 showView,再发 ping 屏障;子进程消息严格串行),`click`/`type`/`key` 派发 `Input.*` 前必须 present,失败明确报 `BROWSER_VIEW_NOT_PRESENTED` 而非假报成功;③ `createView` 前有界加载 `about:blank`,新视图必有渲染进程(宿主重启后卡死的根因);④ `did-navigate` 标记强制重呈现、命令 20s 有界超时、子进程 stderr 与退出码落 `$DSH_HOME/logs/dsh-builtin-browser-host.log`(2MB 自截断)、`locateTab` 兼容裸 uuid 与 `tab:<uuid>` |
|
|
310
|
+
| 第十七轮 | 2026-09-16 | **截图 savePath 收敛 + 下载目录本地化**(issue #13):`browser_screenshot` 原先直接 `writeFileSync`,可写进程可达的任意路径并**静默覆盖**已有文件(等于绕过只读沙箱的写保护)→ 抽出唯一下载/截图共用准入门 `admitSavePath`(绝对路径 + `downloadDir` 内 + **不覆盖已存在文件**),截图补父目录自动创建;默认下载目录不再写死 `~/Downloads`,按序探测 `downloadDir` → `XDG_DOWNLOAD_DIR` → `~/Downloads`/`~/下载`/`~/下載` → 回退(中文桌面免配置) |
|
|
311
|
+
|
|
312
|
+
| **0.1.22** | 2026-09-16 | **发布**:macOS 二进制探测修复(issue #9 / #14)与第十五~十七轮(issue #11 工具栏 SyntaxError / #10 自托管三连 / #13 截图 savePath + 下载目录)随 **0.1.22** 收录(构建零错误、**35 项测试全绿**,tag `v0.1.22`) |
|
|
313
|
+
|
|
314
|
+
> registry 上的最新版本以顶部 npm 徽章为准:本次 `0.1.22` 已入库并打 tag;若徽章仍显示 `0.1.21`,说明该版本尚未 publish。
|
|
307
315
|
|
|
308
316
|
## 特别感谢
|
|
309
317
|
|
package/docs/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| 了解插件为什么存在、与无头方案的区别 | [为什么做共享真实浏览器](why-browser.md) |
|
|
8
8
|
| 安装、配置、日常使用与常见问题 | [用户指南](user-guide.md) |
|
|
9
|
-
| 全部
|
|
9
|
+
| 全部 33 个工具的参数、输出与示例 | [工具参考](tool-reference.md) |
|
|
10
10
|
| 了解 seam / provider / 工具三层与自托管实现 | [架构说明](architecture.md) |
|
|
11
11
|
| 回到项目首页 | [README](../README.md) |
|
|
12
12
|
|
package/docs/architecture.md
CHANGED
|
@@ -37,7 +37,7 @@ agent (browser_* 工具)
|
|
|
37
37
|
|
|
38
38
|
### 工具层(`src/tool-browser/`)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
33 个 `browser_*` 工具,按**调用方任务**(`exec.agent.id`)维护独立浏览器会话:
|
|
41
41
|
|
|
42
42
|
- 会话缓存 `sessionsByTask`:同一任务复用同一会话,并发首开去重;
|
|
43
43
|
- `browser_reset_session` 关闭本任务会话并遗忘映射(即使 close 抛错也清除,下次调用重建);
|
|
@@ -59,7 +59,7 @@ RemoteElectronViewHost ──TCP JSON-RPC──▶ host-main.js
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
- **协议**:本机 loopback TCP,每行一个 JSON(`{ id, op, ... }` ↔ `{ id, ok, result|err }`);
|
|
62
|
-
- **认证**:每次 spawn 生成随机 token
|
|
62
|
+
- **认证**:每次 spawn 生成随机 token,经 **stdin 首行 + `DSH_BROWSER_RPC_TOKEN` 环境变量双通道**传给子进程(绝不进 argv,避免进程列表泄露;Windows GUI 进程收不到 piped stdin,环境变量兜底),子进程首条消息必须回传该 token(`hello`);服务端只接受**一个**连接,其余连接直接断开——本机其他进程无法伪冒子进程或注入回复;
|
|
63
63
|
- **Electron 定位**:① `ELECTRON_PATH`(显式覆盖,优先于一切自动发现)→ ② 随插件安装的 electron 包(纯文件系统探测,含 pnpm store 布局,不触发 44+ 懒下载)→ ③ 锚点与 pnpm store 中**版本最新**者(33.x 有合成器缺陷,建议 ≥ 40)→ ④ 当前进程是**裸** Electron(dev 模式)时复用宿主二进制(`process.execPath`)→ ⑤ 进程祖先树中的裸 Electron 二进制(Windows 用 PowerShell CIM 查询,仅在其它路径全部落空时才执行)。**打包应用不参与复用**:旁有 `resources/app.asar` 的可执行文件(如 DSH Desktop.exe)不能按脚本参数拉起——spawn 会启动应用本体并秒退(单实例锁,即 "browser host exited (code=0)"),一律跳过;
|
|
64
64
|
- **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启,已物化的视图句柄在下次调用时**自动重建并重试一次**(会话跨崩溃存活,仅页面状态丢失,不再出现 "browser host is not running" 僵尸态);物化失败可重试;下载仅限 HTTP(S)、`savePath` 必须绝对路径(默认限定 `~/Downloads`,可配置 `downloadDir` 覆盖)、流式限流 + Content-Length 提前拒绝、256MB 上限与 60s 超时,文件由**子进程直接落盘**(临时文件 + rename,不再经 RPC 传 base64);cookie 导出/恢复有 30s 超时;
|
|
65
65
|
- **视图可见性**:多标签/多会话时,`showView` 隐藏其他视图并把目标视图置顶(remove+re-add),确保用户看到的是活动标签;目标是当前可见视图时**跳过** remove/re-add(否则每次操作都闪烁);窗口标题实时显示当前可见会话的任务标识 + 页面标题/URL(`showView` 携带会话 label,子进程读 `getTitle`/`getURL`);窗口 resize 时视图 bounds 自动跟随;
|
package/docs/tool-reference.md
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
| --- | --- | --- | --- | --- |
|
|
51
51
|
| `browser_history` | – | `{ entries[] }` | – | 操作日志(最新在后),含 seq/action/ok/params/result/error |
|
|
52
52
|
| `browser_replay` | `seq`(必填) | `{ replayed }` | ✅ | 按序号回放某一步(navigate/execute/click/type/scroll/key) |
|
|
53
|
-
| `browser_download` | `url`(必填), `savePath`(必填) | `{ path }` | ✅ | 带会话 cookie 下载到本地(仅 http(s);`savePath`
|
|
53
|
+
| `browser_download` | `url`(必填), `savePath`(必填) | `{ path }` | ✅ | 带会话 cookie 下载到本地(仅 http(s);`savePath` 必须为绝对路径且位于 `downloadDir` 内——默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`;不覆盖已有文件;上限 256MB,受 CORS 约束;由子进程直接落盘) |
|
|
54
54
|
|
|
55
55
|
## 登录态与安全
|
|
56
56
|
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
|
|
64
64
|
| 工具 | 参数 | 输出 | 守卫 | 说明 |
|
|
65
65
|
| --- | --- | --- | --- | --- |
|
|
66
|
-
| `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,JPEG 仅自托管原生路径;`maxWidth`/`maxHeight` 等比缩放降低视觉模型开销;`savePath` 落盘供视觉模型读取 |
|
|
66
|
+
| `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,JPEG 仅自托管原生路径;`maxWidth`/`maxHeight` 等比缩放降低视觉模型开销;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
|
|
67
67
|
|
|
68
68
|
## 常用组合
|
|
69
69
|
|
package/docs/user-guide.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
- DeepSeek Harness(dsh),已安装对应 profile(`web` / `desktop` 等)
|
|
6
6
|
- **Electron 运行时**(必装依赖,随插件自动安装):
|
|
7
|
-
- **DSH Desktop
|
|
8
|
-
- **纯 `dsh web` 自托管**:直接使用随插件安装的 electron 包(建议 ≥ 40,33.x 存在截图合成器缺陷;44+
|
|
7
|
+
- **DSH Desktop**:打包宿主 exe(`DSH Desktop.exe`)**不复用**——打包应用无法按脚本参数拉起,误用会秒退(issue #6);直接使用随包 electron,开发模式的**裸** Electron 宿主仍可复用;
|
|
8
|
+
- **纯 `dsh web` 自托管**:直接使用随插件安装的 electron 包(建议 ≥ 40,33.x 存在截图合成器缺陷;44+ 二进制首次缺失时按报错提示先 `npx install-electron`,需网络)。
|
|
9
9
|
|
|
10
10
|
## 安装
|
|
11
11
|
|
|
@@ -35,7 +35,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
35
35
|
| `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;`file:`/`data:` 等拒绝 |
|
|
36
36
|
| `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数 |
|
|
37
37
|
| `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
|
|
38
|
-
| `browser-electron` | `downloadDir` | 字符串 |
|
|
38
|
+
| `browser-electron` | `downloadDir` | 字符串 | 系统下载目录(自动识别 `Downloads`/`下载`/`下載`,或 `XDG_DOWNLOAD_DIR`) | 限定 `browser_download` 与 `browser_screenshot` 保存路径必须位于该目录内且不覆盖已有文件;默认收敛到系统下载目录,可改沙箱目录 |
|
|
39
39
|
| `tool-browser` | `timeoutMs` | 数字 | `60000` | 工具协作超时(ms) |
|
|
40
40
|
| `tool-browser` | `tabTools` | 布尔 | `true` | 是否注册标签管理工具 |
|
|
41
41
|
|
|
@@ -76,7 +76,7 @@ dsh plugin --profile web add <本仓库路径>
|
|
|
76
76
|
**Q:找不到 Electron?**
|
|
77
77
|
插件按顺序自动定位:① `ELECTRON_PATH` 环境变量(显式覆盖,优先于一切自动发现)→ ② 随插件安装的 electron 包(纯文件系统探测,不触发 44+ 懒下载)→ ③ DSH 锚点(profile / 全局 prefix 中单独安装的 electron)中版本最新者 → ④ 当前进程就是**裸** Electron(dev 模式)时复用宿主二进制 → ⑤ 进程祖先树中的裸 Electron 二进制。**打包应用不参与复用**:旁有 `resources/app.asar` 的可执行文件(如 DSH Desktop.exe)无法按脚本参数拉起,spawn 会启动应用本体并秒退(单实例锁)——一律跳过。
|
|
78
78
|
|
|
79
|
-
DSH Desktop
|
|
79
|
+
DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进制缺失时先 `npx install-electron`,需网络);dev 模式宿主走④复用,亦零安装。全部落空时,报错会给出指引:electron 已随插件安装,44+ 二进制缺失时先 `npx install-electron`(需网络);必要时可设置 `ELECTRON_PATH`。
|
|
80
80
|
|
|
81
81
|
**Q:截图失败或挂起?**
|
|
82
82
|
确保 Electron ≥ 40(33.x 有合成器缺陷)。自托管截图优先走原生 `capturePage`,多视图/窗口未激活时自动兜底到 CDP。
|
|
@@ -85,7 +85,7 @@ DSH Desktop 上①命中即可用(0.1.18+ 插件自带 electron 包,44+ 二进
|
|
|
85
85
|
窗口标题为 `dsh-browser`(自托管)。若子进程崩溃(或宿主 DSH 重启)会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。
|
|
86
86
|
|
|
87
87
|
**Q:下载报 CORS 错误?**
|
|
88
|
-
`browser_download` 在页面上下文内 `fetch`,受同源/CORS 约束;跨域文件请先在同源页面内操作,或直接请求用户提供。仅支持 HTTP(S) URL;`savePath`
|
|
88
|
+
`browser_download` 在页面上下文内 `fetch`,受同源/CORS 约束;跨域文件请先在同源页面内操作,或直接请求用户提供。仅支持 HTTP(S) URL;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載`),不覆盖已有文件;`browser_screenshot` 的 `savePath` 受同一套限制。
|
|
89
89
|
|
|
90
90
|
**Q:如何禁止 agent 乱点?**
|
|
91
91
|
`browser_restrict` 设置白名单(如只允许 `browser_snapshot`/`browser_content`);传空列表解除。注意它是防误操作的**软护栏**,模型可自行解除,不是安全边界。
|
|
@@ -94,7 +94,7 @@ DSH Desktop 上①命中即可用(0.1.18+ 插件自带 electron 包,44+ 二进
|
|
|
94
94
|
|
|
95
95
|
| 现象 | 可能原因 | 处理 |
|
|
96
96
|
| --- | --- | --- |
|
|
97
|
-
| `BROWSER_SESSION_UNKNOWN` |
|
|
97
|
+
| `BROWSER_SESSION_UNKNOWN` | 会话已被关闭/重建(如 `browser_reset_session` 后仍引用旧会话) | `browser_session` 查看现状;或 `browser_reset_session` 重建 |
|
|
98
98
|
| 工具超时 | 页面卡死/未渲染完成 | 稍后重试;`browser_reset` 重置标签 |
|
|
99
99
|
| 导航被拒 | 非 HTTP(S) 协议 | 检查 URL;`httpOnly` 配置 |
|
|
100
100
|
| 快照为空 | 页面尚未加载 | 等待后重试 `browser_snapshot` |
|
|
@@ -32,19 +32,19 @@ import { dirname, join } from 'node:path';
|
|
|
32
32
|
* The spawn token the parent sent via stdin (first line). Must be echoed in
|
|
33
33
|
* our hello. Read from stdin so the token never appears in argv (WMI /
|
|
34
34
|
* Process Explorer / /proc/*).
|
|
35
|
-
|
|
35
|
+
*/
|
|
36
36
|
// Isolate this host's profile from the DSH app's default Electron userData:
|
|
37
37
|
// several Electron instances sharing Roaming\Electron fight over the GPU
|
|
38
38
|
// cache/session locks, which can leave the window without a display surface
|
|
39
39
|
// (capturePage then fails). A dedicated userData also persists cookies across
|
|
40
40
|
// host restarts (on top of browser_auth). Must run before app is ready.
|
|
41
41
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
42
|
+
const base = process.env.DSH_HOME ?? app.getPath('appData');
|
|
43
|
+
app.setPath('userData', join(base, 'dsh-builtin-browser-host'));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`);
|
|
46
47
|
}
|
|
47
|
-
|
|
48
48
|
/**
|
|
49
49
|
* Read the spawn token. The parent sends it two ways and the child prefers
|
|
50
50
|
* the first that yields a value:
|
|
@@ -141,7 +141,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
|
|
|
141
141
|
<div class="strip" id="strip"></div>
|
|
142
142
|
<div id="err"></div>
|
|
143
143
|
<script>
|
|
144
|
-
|
|
144
|
+
// The body runs inside an IIFE on purpose. The preload above exposes
|
|
145
|
+
// window.bridge via contextBridge.exposeInMainWorld(), which installs a
|
|
146
|
+
// NON-CONFIGURABLE global property. A top-level const/let named bridge here
|
|
147
|
+
// would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
|
|
148
|
+
// — an early error, so the whole script is discarded: nothing gets bound, the
|
|
149
|
+
// address bar and nav buttons go silently dead, and the user sees no error.
|
|
150
|
+
// Function scope sidesteps that check (the tb name below stops the collision
|
|
151
|
+
// from returning by habit) and keeps these bindings off the page global.
|
|
152
|
+
// Keep this block free of backticks — it lives inside a TS template literal.
|
|
153
|
+
(() => {
|
|
154
|
+
const tb = window.bridge
|
|
145
155
|
const addr = document.getElementById('addr')
|
|
146
156
|
const strip = document.getElementById('strip')
|
|
147
157
|
const errBox = document.getElementById('err')
|
|
@@ -152,7 +162,7 @@ function showErr(text) {
|
|
|
152
162
|
clearTimeout(showErrT)
|
|
153
163
|
showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
|
|
154
164
|
}
|
|
155
|
-
function post(action, payload) {
|
|
165
|
+
function post(action, payload) { tb.post(action, payload || {}) }
|
|
156
166
|
document.getElementById('back').onclick = () => post('back')
|
|
157
167
|
document.getElementById('fwd').onclick = () => post('forward')
|
|
158
168
|
document.getElementById('reload').onclick = () => post('reload')
|
|
@@ -167,7 +177,7 @@ addr.addEventListener('keydown', e => {
|
|
|
167
177
|
if (v !== '') { post('navigate', { url: v }); addr.blur() }
|
|
168
178
|
}
|
|
169
179
|
})
|
|
170
|
-
|
|
180
|
+
tb.onTabs(payload => {
|
|
171
181
|
const tabs = (payload && payload.tabs) || []
|
|
172
182
|
strip.textContent = ''
|
|
173
183
|
for (const t of tabs) {
|
|
@@ -189,7 +199,8 @@ bridge.onTabs(payload => {
|
|
|
189
199
|
const act = tabs.find(t => t.active)
|
|
190
200
|
if (act && document.activeElement !== addr) addr.value = act.url || ''
|
|
191
201
|
})
|
|
192
|
-
|
|
202
|
+
tb.onError(text => showErr(text))
|
|
203
|
+
})()
|
|
193
204
|
</script>
|
|
194
205
|
</body>
|
|
195
206
|
</html>
|
|
@@ -206,6 +217,42 @@ function ensureToolbarFiles() {
|
|
|
206
217
|
writeFileSync(join(TOOLBAR_DIR, 'preload.js'), TOOLBAR_PRELOAD);
|
|
207
218
|
writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML);
|
|
208
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Bound an await with a timeout. Used so a wedged renderer surfaces as an
|
|
222
|
+
* explicit error instead of a command that never settles.
|
|
223
|
+
*/
|
|
224
|
+
function withTimeout(promise, ms, label) {
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
228
|
+
}, ms);
|
|
229
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* CDP commands are bounded here as well as in the parent: a view whose
|
|
234
|
+
* renderer stopped responding must produce a clear error, not a hang that
|
|
235
|
+
* only the tool layer's 10–30s timeout eventually cuts off.
|
|
236
|
+
*/
|
|
237
|
+
const COMMAND_TIMEOUT_MS = 20000;
|
|
238
|
+
/**
|
|
239
|
+
* A brand-new WebContentsView has no renderer process until something loads,
|
|
240
|
+
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
241
|
+
* settle. That is what made a host restart unrecoverable: the provider
|
|
242
|
+
* rebuilds the session with a fresh blank view whose very first action is a
|
|
243
|
+
* location.href read, which hung until the tool timeout.
|
|
244
|
+
*
|
|
245
|
+
* Loading about:blank spawns the renderer, so the view answers the moment it
|
|
246
|
+
* exists; a real navigation replaces it right after. Bounded + best-effort —
|
|
247
|
+
* the caller still gets its reply if this fails.
|
|
248
|
+
*/
|
|
249
|
+
const RENDERER_PRIME_TIMEOUT_MS = 3000;
|
|
250
|
+
async function primeRenderer(view) {
|
|
251
|
+
try {
|
|
252
|
+
await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime');
|
|
253
|
+
}
|
|
254
|
+
catch { /* best effort: the first real navigation creates the renderer */ }
|
|
255
|
+
}
|
|
209
256
|
/** Reply to the parent over the RPC socket. */
|
|
210
257
|
function reply(id, payload) {
|
|
211
258
|
if (rpcSocket === undefined) {
|
|
@@ -434,7 +481,19 @@ function showViewInWindow(win, viewId, label) {
|
|
|
434
481
|
const entry = win.views.get(viewId);
|
|
435
482
|
if (entry === undefined)
|
|
436
483
|
return;
|
|
437
|
-
|
|
484
|
+
// A navigation replaced this view's renderer, and the new one has no
|
|
485
|
+
// display surface yet. Chromium drops CDP synthesized input (Input.*) for a
|
|
486
|
+
// view without a surface, which silently swallowed the FIRST click/keystroke
|
|
487
|
+
// after every navigation — and because the view was already the visible one,
|
|
488
|
+
// the dance below used to be skipped entirely. Force it once.
|
|
489
|
+
const mustRepresent = entry.needsRepresent;
|
|
490
|
+
if (mustRepresent) {
|
|
491
|
+
try {
|
|
492
|
+
entry.webContentsView.setVisible(false);
|
|
493
|
+
}
|
|
494
|
+
catch { /* destroyed */ }
|
|
495
|
+
}
|
|
496
|
+
if (win.visibleViewId !== viewId || mustRepresent) {
|
|
438
497
|
// Hide every other view in THIS window, then show and RAISE the target
|
|
439
498
|
// (topmost child wins). When the target is already visible, skip the
|
|
440
499
|
// remove/re-add dance — doing it on every operation flickered.
|
|
@@ -453,6 +512,7 @@ function showViewInWindow(win, viewId, label) {
|
|
|
453
512
|
}
|
|
454
513
|
catch { /* window closing */ }
|
|
455
514
|
win.visibleViewId = viewId;
|
|
515
|
+
entry.needsRepresent = false;
|
|
456
516
|
syncToolbar(win);
|
|
457
517
|
}
|
|
458
518
|
// Always refresh the title: it reflects the CURRENT page of the visible
|
|
@@ -551,11 +611,19 @@ async function handle(op, msg) {
|
|
|
551
611
|
// New views start hidden: only the shown one may be visible.
|
|
552
612
|
view.setVisible(false);
|
|
553
613
|
win.window.contentView.addChildView(view);
|
|
554
|
-
win.views.set(viewId, { webContentsView: view });
|
|
614
|
+
win.views.set(viewId, { webContentsView: view, needsRepresent: false });
|
|
555
615
|
// Keep the window title and the toolbar tab strip live as the page
|
|
556
616
|
// changes (navigations, title updates).
|
|
557
617
|
view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win); });
|
|
558
|
-
view.webContents.on('did-navigate', () => {
|
|
618
|
+
view.webContents.on('did-navigate', () => {
|
|
619
|
+
// The renderer process was just replaced: mark the view so the next
|
|
620
|
+
// show forces a fresh display surface (see showViewInWindow).
|
|
621
|
+
const current = win.views.get(viewId);
|
|
622
|
+
if (current !== undefined)
|
|
623
|
+
current.needsRepresent = true;
|
|
624
|
+
updateWindowTitle(win, viewId, undefined);
|
|
625
|
+
syncToolbar(win);
|
|
626
|
+
});
|
|
559
627
|
view.webContents.on('did-navigate-in-page', () => syncToolbar(win));
|
|
560
628
|
wireFocusRouting(win, view, viewId);
|
|
561
629
|
const first = win.views.size === 1;
|
|
@@ -567,6 +635,10 @@ async function handle(op, msg) {
|
|
|
567
635
|
if (first)
|
|
568
636
|
updateWindowTitle(win, viewId, undefined);
|
|
569
637
|
syncToolbar(win);
|
|
638
|
+
// Prime the renderer BEFORE replying: the parent's next command
|
|
639
|
+
// (a location.href read) would otherwise hang on a view that has no
|
|
640
|
+
// renderer yet — the root of "host restarted and never recovers".
|
|
641
|
+
await primeRenderer(view);
|
|
570
642
|
reply(msg.id, { ok: true });
|
|
571
643
|
return;
|
|
572
644
|
}
|
|
@@ -649,7 +721,7 @@ async function handle(op, msg) {
|
|
|
649
721
|
const method = msg.method;
|
|
650
722
|
if (typeof method !== 'string')
|
|
651
723
|
throw new Error('command missing method');
|
|
652
|
-
const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
|
|
724
|
+
const result = await withTimeout(entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}), COMMAND_TIMEOUT_MS, `CDP ${method}`);
|
|
653
725
|
reply(msg.id, { ok: true, result });
|
|
654
726
|
return;
|
|
655
727
|
}
|
|
@@ -965,6 +1037,13 @@ void app.whenReady().then(() => {
|
|
|
965
1037
|
}
|
|
966
1038
|
}
|
|
967
1039
|
const rl = createInterface({ input: socket });
|
|
1040
|
+
// Handle messages STRICTLY in order. Ops depend on each other completing:
|
|
1041
|
+
// a `command` for a view must not overtake the `createView` that makes it,
|
|
1042
|
+
// and the parent's present-barrier (`showView` then `ping`) is only
|
|
1043
|
+
// meaningful if the child cannot answer the ping mid-show. The previous
|
|
1044
|
+
// fire-and-forget loop let all of them race, which is how a fresh tab's
|
|
1045
|
+
// first click could be dispatched before its view was ever on screen.
|
|
1046
|
+
let queue = Promise.resolve();
|
|
968
1047
|
rl.on('line', line => {
|
|
969
1048
|
const text = line.trim();
|
|
970
1049
|
if (text === '')
|
|
@@ -978,7 +1057,10 @@ void app.whenReady().then(() => {
|
|
|
978
1057
|
}
|
|
979
1058
|
if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
|
|
980
1059
|
return;
|
|
981
|
-
|
|
1060
|
+
// Bind the narrowed op now: the check above does not survive into the
|
|
1061
|
+
// deferred callback (msg is a mutable binding).
|
|
1062
|
+
const op = msg.op;
|
|
1063
|
+
queue = queue.then(() => handle(op, msg)).catch(() => { });
|
|
982
1064
|
});
|
|
983
1065
|
socket.on('error', error => {
|
|
984
1066
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
|