dsh-builtin-browser 0.1.16 → 0.1.17

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 CHANGED
@@ -217,7 +217,7 @@ agent (browser_* tools)
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
 
220
- **Electron lookup order**: ① `require('electron')` (peer dependency) → ② `ELECTRON_PATH` (explicit override) → ③ the newest among DSH install anchors and pnpm virtual stores. A clear error tells you when none is found.
220
+ **Electron lookup order**: ① the current process IS Electron (DSH Desktop main process) → reuse the host binary directly; ② walk the process ancestry for the host's Electron binary (covers hosts that run the plugin in a child Node process, e.g. DSH Desktop; PowerShell CIM on Windows, last resort only); ③ `require('electron')` (peer dependency); ④ `ELECTRON_PATH` (explicit override); ⑤ the newest among DSH install anchors and pnpm virtual stores. **Zero extra install on DSH Desktop**; when nothing is found a clear error tells you what to do (including the per-profile install command).
221
221
 
222
222
  ## Division of labor with the desktop shell
223
223
 
@@ -225,8 +225,10 @@ The browser's **visible view**, the **browser column layout**, and the **column-
225
225
 
226
226
  ## Requirements
227
227
 
228
- - DeepSeek Harness (dsh) with the `web` profile
229
- - **Electron runtime** (optional peer dependency): the desktop shell carries it; on plain `dsh web` the plugin locates an Electron binary automatically (see above; ≥ 40 recommended)
228
+ - DeepSeek Harness (dsh) with the matching profile (`web` / `desktop`, etc.)
229
+ - **Electron runtime** (optional peer dependency):
230
+ - **DSH Desktop**: the host itself runs on Electron — the plugin reuses the host binary automatically, **zero extra install**;
231
+ - **plain `dsh web` self-hosted**: an Electron binary is required and located automatically (see above; ≥ 40 recommended; 44+ downloads its binary on first use, needs network)
230
232
 
231
233
  ### Verified versions
232
234
 
@@ -235,7 +237,7 @@ The browser's **visible view**, the **browser column layout**, and the **column-
235
237
  | DeepSeek Harness (dsh) | `0.1.1-rc.2` (peer range `^0.1.1-rc.2`) |
236
238
  | Electron | `44.0.0` (≥ 40 recommended; 33.x has a compositor defect) |
237
239
  | Node.js | `22.20.0` |
238
- | dsh-builtin-browser | `0.1.16` |
240
+ | dsh-builtin-browser | `0.1.17` |
239
241
  | OS | Windows 10 (10.0.26200) |
240
242
 
241
243
  > The plugin declares `electron >= 30`; it has **only been verified on Windows** (macOS/Linux untested, not yet promised).
@@ -288,6 +290,8 @@ Code layout:
288
290
  | 6 | 2026-08 | **Windows handshake & tab lookup**: the Electron GUI process never receives piped stdin → RPC token now flows over **stdin + env var**; `browser_switch_tab`/`browser_close_tab` locate tabs across sessions (`locateTab`), `browser_close_tab` no longer fakes success, unknown ids error with the session's actual tab list |
289
291
  | 7 | 2026-08 | **Toolbar interaction (Windows focus routing)**: keyboard input only reaches the focused view and the page view grabbed it, so the address bar could not receive input → added `wireFocusRouting` (clicking a view focuses it) + window refocus restores the last-clicked view; verified with real OS input probes |
290
292
  | **0.1.16** | 2026-08-26 | **Release**: all seven rounds ship as **0.1.16** (build clean, 21/21 tests pass, `v0.1.16`) |
293
+ | 8 | 2026-08-27 | **DSH Desktop host-Electron reuse**: running inside an Electron process reuses the host binary directly; when the host runs the plugin in a child Node process, walk the process ancestry to find the host's Electron (PowerShell CIM on Windows, last resort only) — **DSH Desktop works with zero install**; error now hints per active profile; electron shim completed to fix the CI typecheck; docs updated |
294
+ | **0.1.17** | 2026-08-27 | **Release**: round 8 ships as **0.1.17** (build clean, 21/21 tests pass) |
291
295
 
292
296
  ## Acknowledgements
293
297
 
package/README.md CHANGED
@@ -217,7 +217,7 @@ agent (browser_* 工具)
217
217
 
218
218
  **自托管浏览器就是一台真正的浏览器**:每个任务(DSH 会话)拥有**独立的浏览器窗口**,窗口自带完整工具栏——地址栏、后退/前进/刷新按钮、标签条(新建/切换/关闭标签)。人可以直接像用 Chrome 一样使用它:在地址栏输入网址(自动补 `https://`)、点标签切换页面、开新标签;键盘焦点跟随点击——**点地址栏即可输入、点页面即可操作**(Windows 焦点路由,修复了点击不转移焦点导致地址栏无法输入的问题)。agent 与人的操作都汇入**同一个会话模型**(同一套标签、历史与导航),窗口标题实时显示当前任务标识与页面标题/URL,窗口缩放时视图自动跟随。任务结束后窗口随会话自动关闭。
219
219
 
220
- **Electron 定位顺序**:① `require('electron')`(peer 依赖)→ ② `ELECTRON_PATH`(显式覆盖)→ ③ DSH 安装锚点与 pnpm 虚拟仓库中**版本最新**者。找不到时工具会报清晰的错误提示。
220
+ **Electron 定位顺序**:① 插件运行在 Electron 进程内(DSH Desktop 主进程)时直接复用宿主二进制 → ② 进程祖先树中的宿主 Electron 二进制(DSH Desktop 把插件跑在子 Node 进程时的兜底)→ ③ `require('electron')`(peer 依赖)→ ④ `ELECTRON_PATH`(显式覆盖)→ ⑤ DSH 安装锚点与 pnpm 虚拟仓库中**版本最新**者。**DSH Desktop 上零额外安装**;找不到时工具会报清晰的错误提示(含按当前 profile 的安装命令)。
221
221
 
222
222
  ## 与桌面外壳的分工
223
223
 
@@ -225,8 +225,10 @@ agent (browser_* 工具)
225
225
 
226
226
  ## 环境要求
227
227
 
228
- - DeepSeek Harness(dsh)且安装了 `web` profile
229
- - **Electron 运行时**(可选 peer 依赖):桌面外壳自带;纯 `dsh web` 下插件自动定位 Electron 二进制(见上,建议 ≥ 40)
228
+ - DeepSeek Harness(dsh),已安装对应 profile(`web` / `desktop` 等)
229
+ - **Electron 运行时**(可选 peer 依赖):
230
+ - **DSH Desktop**:宿主本身基于 Electron,**插件自动复用宿主二进制,零额外安装**;
231
+ - **纯 `dsh web` 自托管**:需要 Electron 二进制,插件自动定位(见上,建议 ≥ 40;44+ 首次使用自动下载,需网络)
230
232
 
231
233
  ### 验证过的版本
232
234
 
@@ -235,7 +237,7 @@ agent (browser_* 工具)
235
237
  | DeepSeek Harness(dsh) | `0.1.1-rc.2`(peer 声明 `^0.1.1-rc.2`) |
236
238
  | Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
237
239
  | Node.js | `22.20.0` |
238
- | dsh-builtin-browser | `0.1.16` |
240
+ | dsh-builtin-browser | `0.1.17` |
239
241
  | 操作系统 | Windows 10 (10.0.26200) |
240
242
 
241
243
  > 插件声明 `electron >= 30`;**当前仅在 Windows 环境实测**(macOS/Linux 未验证,暂不承诺)。
@@ -288,6 +290,8 @@ npm run build
288
290
  | 第六轮 | 2026-08 | **Windows 握手与标签定位**:Electron GUI 进程收不到 piped stdin → RPC token 改 **stdin + 环境变量双通道**;`browser_switch_tab`/`browser_close_tab` 跨会话按 id 定位(`locateTab`),`browser_close_tab` 不再静默假成功,未知 id 报错附带现有标签列表 |
289
291
  | 第七轮 | 2026-08 | **工具栏交互(Windows 焦点路由)**:键盘输入只进有焦点的 view,页面 view 抢占焦点导致地址栏无法输入 → 新增 `wireFocusRouting`(点击即聚焦该 view)+ 窗口 refocus 恢复上次点击的 view;真机 OS 输入探针验证 |
290
292
  | **0.1.16** | 2026-08-26 | **发布**:以上七轮全部随 **0.1.16** 发布(构建零错误、21 项测试全绿,`v0.1.16`) |
293
+ | 第八轮 | 2026-08-27 | **DSH Desktop 宿主 Electron 复用**:插件运行在 Electron 进程内直接复用宿主二进制;插件跑在宿主子 Node 进程时沿进程祖先树找到宿主 Electron 兜底(Windows 用 PowerShell CIM,仅最后手段)——DSH Desktop **零安装开箱可用**;报错按当前 profile 动态提示;补齐 electron shim 修复 CI 类型检查;文档同步 |
294
+ | **0.1.17** | 2026-08-27 | **发布**:第八轮修复随 **0.1.17** 发布(构建零错误、21 项测试全绿) |
291
295
 
292
296
  ## 特别感谢
293
297
 
@@ -60,7 +60,7 @@ RemoteElectronViewHost ──TCP JSON-RPC──▶ host-main.js
60
60
 
61
61
  - **协议**:本机 loopback TCP,每行一个 JSON(`{ id, op, ... }` ↔ `{ id, ok, result|err }`);
62
62
  - **认证**:每次 spawn 生成随机 token(经 `--rpc-token` 传入),子进程首条消息必须回传该 token(`hello`);服务端只接受**一个**连接,其余连接直接断开——本机其他进程无法伪冒子进程或注入回复;
63
- - **Electron 定位**:① peer 依赖 → ② `ELECTRON_PATH` → ③ 锚点与 pnpm store 中**版本最新**者(33.x 有合成器缺陷,建议 ≥ 40);
63
+ - **Electron 定位**:① 插件运行在 Electron 进程内(DSH Desktop 主进程)时直接复用宿主二进制(`process.execPath`);② 进程祖先树中的宿主 Electron 二进制(DSH Desktop 把插件跑在子 Node 进程时的兜底,Windows 用 PowerShell CIM 查询,仅在其它路径全部落空时才执行)→ ③ peer 依赖 → ④ `ELECTRON_PATH` → ⑤ 锚点与 pnpm store 中**版本最新**者(33.x 有合成器缺陷,建议 ≥ 40);
64
64
  - **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启;物化失败可重试;下载仅限 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 自动跟随;
66
66
  - **孤儿防护**:父进程断开时子进程自动退出,不留僵尸窗口;
@@ -2,13 +2,15 @@
2
2
 
3
3
  ## 环境要求
4
4
 
5
- - DeepSeek Harness(dsh)且安装了 `web` profile
6
- - **Electron 运行时**(可选 peer 依赖):桌面外壳自带;纯 `dsh web` 下插件自动定位 Electron 二进制(建议 ≥ 40,33.x 存在截图合成器缺陷)
5
+ - DeepSeek Harness(dsh),已安装对应 profile(`web` / `desktop` 等)
6
+ - **Electron 运行时**(可选 peer 依赖):
7
+ - **DSH Desktop**:宿主本身基于 Electron,**插件自动复用宿主二进制,零额外安装**;
8
+ - **纯 `dsh web` 自托管**:需要 Electron 二进制,插件自动定位(建议 ≥ 40,33.x 存在截图合成器缺陷;44+ 首次使用自动下载,需网络),找不到时按下文 FAQ 安装即可。
7
9
 
8
10
  ## 安装
9
11
 
10
12
  ```sh
11
- # 从 npm 安装(已发布)
13
+ # 从 npm 安装(已发布);`--profile` 换成你实际使用的 profile(DSH Desktop 为 `desktop`)
12
14
  dsh plugin --profile web add dsh-builtin-browser
13
15
 
14
16
  # 或从源码目录(独立仓库,一插件一仓库)
@@ -72,7 +74,9 @@ dsh plugin --profile web add <本仓库路径>
72
74
  能。插件自托管:自己拉起 Electron 窗口,无需桌面外壳。
73
75
 
74
76
  **Q:找不到 Electron?**
75
- 插件按顺序自动定位:① peer 依赖 `require('electron')` → ② `ELECTRON_PATH` 环境变量 → ③ DSH 锚点与 pnpm 虚拟仓库中版本最新者。都找不到时报清晰错误,可 `dsh plugin --profile web add electron` 或设置 `ELECTRON_PATH`。
77
+ 插件按顺序自动定位:① 插件运行在 Electron 进程内(DSH Desktop 主进程)时直接复用宿主二进制 → ② 进程祖先树中的宿主 Electron 二进制(DSH Desktop 把插件跑在子 Node 进程时的兜底)→ ③ peer 依赖 `require('electron')`(仅二进制已就绪时)→ ④ `ELECTRON_PATH` 环境变量 → ⑤ DSH 锚点与 pnpm 虚拟仓库中版本最新者。
78
+
79
+ DSH Desktop 上①②任一命中即开箱可用,无需任何安装。纯 `dsh web` 下全部落空时,报错会给出指引:可 `dsh plugin --profile <你的-profile> add electron`(DSH Desktop 的 profile 是 `desktop`)或设置 `ELECTRON_PATH`。
76
80
 
77
81
  **Q:截图失败或挂起?**
78
82
  确保 Electron ≥ 40(33.x 有合成器缺陷)。自托管截图优先走原生 `capturePage`,多视图/窗口未激活时自动兜底到 CDP。
@@ -24,11 +24,11 @@
24
24
  * sees traffic.
25
25
  * @module dsh-browser/browser-electron/remote-host
26
26
  */
27
- import { spawn } from 'node:child_process';
27
+ import { execFileSync, spawn } from 'node:child_process';
28
28
  import { randomBytes } from 'node:crypto';
29
29
  import { createRequire } from 'node:module';
30
- import { existsSync, readdirSync } from 'node:fs';
31
- import { dirname, join } from 'node:path';
30
+ import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
31
+ import { basename, dirname, join } from 'node:path';
32
32
  import { createServer } from 'node:net';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  /** How long to wait for the child to signal readiness before failing. */
@@ -45,7 +45,9 @@ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
45
45
  * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
46
46
  * NEWEST version found. Older Electron releases (e.g. 33.x) have a
47
47
  * compositor defect that intermittently breaks page capture, so prefer
48
- * the newest binary available in the environment.
48
+ * the newest binary available in the environment,
49
+ * 4. last resort: the process ancestry, for hosts that run this plugin in a
50
+ * child Node process of their own Electron main process (DSH Desktop).
49
51
  * @returns the path to the Electron executable.
50
52
  */
51
53
  function resolveElectronPath() {
@@ -137,17 +139,99 @@ function resolveElectronPath() {
137
139
  dir = parent;
138
140
  }
139
141
  }
140
- if (candidates.length > 0) {
141
- candidates.sort((a, b) => compareVersions(b.version, a.version));
142
- const best = candidates[0];
143
- if (best !== undefined)
144
- return best.path;
145
- }
142
+ // 4. Last resort for Electron hosts. DSH Desktop may run the agent runtime
143
+ // (and thus this plugin) in a plain Node child of its Electron main
144
+ // process — step 0 does not fire there, and there is no electron package
145
+ // anywhere to find. The host's own binary IS an Electron runtime and is
146
+ // guaranteed present (it is running us), so walk the process ancestry
147
+ // and reuse the first Electron executable found. Zero extra install;
148
+ // this is what makes the plugin work on DSH Desktop without `add
149
+ // electron`, no matter how the host loads plugins.
150
+ const hostExe = hostElectronViaProcessTree();
151
+ if (hostExe !== undefined)
152
+ return hostExe;
146
153
  throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Options: (1) if you are on DSH Desktop, ' +
147
154
  'this should never appear — the plugin reuses the host Electron automatically; (2) install electron ' +
148
- 'into the active profile (`dsh plugin --profile <your-profile> add electron`); (3) set ELECTRON_PATH ' +
149
- 'to the electron executable. Note: Electron 44+ downloads its binary on first use, so if the package ' +
150
- 'is installed but the binary is missing, run `npx install-electron` once (needs network) and retry.');
155
+ 'into the active profile (`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the ' +
156
+ 'profile is `desktop`); (3) set ELECTRON_PATH to the electron executable. Note: Electron 44+ downloads ' +
157
+ 'its binary on first use, so if the package is installed but the binary is missing, run ' +
158
+ '`npx install-electron` once (needs network) and retry.');
159
+ }
160
+ /**
161
+ * Last-resort Electron discovery: walk this process's ancestry looking for a
162
+ * parent process whose executable is itself an Electron binary. Covers hosts
163
+ * that run the plugin in a child Node process of their own Electron main
164
+ * process (e.g. DSH Desktop): the host binary is already on disk and running,
165
+ * so reusing it needs no extra install. Filesystem-only on POSIX (/proc); on
166
+ * Windows a best-effort PowerShell CIM query is used — this path only runs
167
+ * when every other lookup failed, so its latency never touches the happy
168
+ * path, and a missing/blocked powershell simply yields nothing.
169
+ */
170
+ function hostElectronViaProcessTree() {
171
+ let pid = process.ppid;
172
+ for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
173
+ const exe = processExecutable(pid);
174
+ if (exe !== undefined && isElectronBinary(exe))
175
+ return exe;
176
+ pid = processParent(pid);
177
+ }
178
+ return undefined;
179
+ }
180
+ /** The executable path of a running process, or undefined when unknown. */
181
+ function processExecutable(pid) {
182
+ try {
183
+ if (process.platform === 'win32') {
184
+ const out = execFileSync('powershell.exe', [
185
+ '-NoProfile', '-NonInteractive', '-Command',
186
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ExecutablePath`,
187
+ ], { encoding: 'utf8', windowsHide: true, timeout: 3000 });
188
+ const line = out.trim().split(/\r?\n/)[0] ?? '';
189
+ return line.length > 0 ? line : undefined;
190
+ }
191
+ const exe = readlinkSync(`/proc/${pid}/exe`);
192
+ return exe.length > 0 ? exe : undefined;
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
197
+ }
198
+ /** The parent pid of a running process, or undefined when unknown. */
199
+ function processParent(pid) {
200
+ try {
201
+ if (process.platform === 'win32') {
202
+ const out = execFileSync('powershell.exe', [
203
+ '-NoProfile', '-NonInteractive', '-Command',
204
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`,
205
+ ], { encoding: 'utf8', windowsHide: true, timeout: 3000 });
206
+ const n = Number(out.trim().split(/\r?\n/)[0]);
207
+ return Number.isInteger(n) && n > 1 ? n : undefined;
208
+ }
209
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
210
+ // Format: pid (comm) state ppid ... — comm is parenthesized and may
211
+ // itself contain spaces/parens, so parse from the LAST ')'; ppid is the
212
+ // second field after it.
213
+ const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
214
+ const n = Number(fields[1]);
215
+ return Number.isInteger(n) && n > 1 ? n : undefined;
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ /**
222
+ * True when the given executable is an Electron binary. The name match
223
+ * covers dev binaries (electron, electron.exe); the resources marker covers
224
+ * packaged apps whose exe carries the product name (e.g. "DSH Desktop.exe") —
225
+ * every Electron distribution ships `resources/electron.asar`,
226
+ * `resources/app.asar`, or `resources/default_app.asar` beside the binary.
227
+ */
228
+ function isElectronBinary(exe) {
229
+ if (basename(exe).toLowerCase().includes('electron'))
230
+ return true;
231
+ const resources = join(dirname(exe), 'resources');
232
+ return existsSync(join(resources, 'electron.asar'))
233
+ || existsSync(join(resources, 'app.asar'))
234
+ || existsSync(join(resources, 'default_app.asar'));
151
235
  }
152
236
  /** Extract an electron version like "43.4.0" from a path containing it. */
153
237
  function versionOf(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -25,11 +25,11 @@
25
25
  * @module dsh-browser/browser-electron/remote-host
26
26
  */
27
27
 
28
- import { spawn, type ChildProcessByStdio } from 'node:child_process'
28
+ import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_process'
29
29
  import { randomBytes } from 'node:crypto'
30
30
  import { createRequire } from 'node:module'
31
- import { existsSync, readdirSync } from 'node:fs'
32
- import { dirname, join } from 'node:path'
31
+ import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
32
+ import { basename, dirname, join } from 'node:path'
33
33
  import { createServer, type Server, type Socket } from 'node:net'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import type { BrowserUserAction, ElectronBrowserViewHost, ElectronViewHandle, ScreenshotOptions } from './provider.js'
@@ -51,7 +51,9 @@ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
51
51
  * 3. every DSH install anchor + pnpm virtual store candidate, choosing the
52
52
  * NEWEST version found. Older Electron releases (e.g. 33.x) have a
53
53
  * compositor defect that intermittently breaks page capture, so prefer
54
- * the newest binary available in the environment.
54
+ * the newest binary available in the environment,
55
+ * 4. last resort: the process ancestry, for hosts that run this plugin in a
56
+ * child Node process of their own Electron main process (DSH Desktop).
55
57
  * @returns the path to the Electron executable.
56
58
  */
57
59
  function resolveElectronPath(): string {
@@ -136,20 +138,102 @@ function resolveElectronPath(): string {
136
138
  dir = parent
137
139
  }
138
140
  }
139
- if (candidates.length > 0) {
140
- candidates.sort((a, b) => compareVersions(b.version, a.version))
141
- const best = candidates[0]
142
- if (best !== undefined) return best.path
143
- }
141
+ // 4. Last resort for Electron hosts. DSH Desktop may run the agent runtime
142
+ // (and thus this plugin) in a plain Node child of its Electron main
143
+ // process — step 0 does not fire there, and there is no electron package
144
+ // anywhere to find. The host's own binary IS an Electron runtime and is
145
+ // guaranteed present (it is running us), so walk the process ancestry
146
+ // and reuse the first Electron executable found. Zero extra install;
147
+ // this is what makes the plugin work on DSH Desktop without `add
148
+ // electron`, no matter how the host loads plugins.
149
+ const hostExe = hostElectronViaProcessTree()
150
+ if (hostExe !== undefined) return hostExe
144
151
  throw new Error(
145
152
  'dsh-builtin-browser: cannot locate the Electron binary. Options: (1) if you are on DSH Desktop, ' +
146
153
  'this should never appear — the plugin reuses the host Electron automatically; (2) install electron ' +
147
- 'into the active profile (`dsh plugin --profile <your-profile> add electron`); (3) set ELECTRON_PATH ' +
148
- 'to the electron executable. Note: Electron 44+ downloads its binary on first use, so if the package ' +
149
- 'is installed but the binary is missing, run `npx install-electron` once (needs network) and retry.',
154
+ 'into the active profile (`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the ' +
155
+ 'profile is `desktop`); (3) set ELECTRON_PATH to the electron executable. Note: Electron 44+ downloads ' +
156
+ 'its binary on first use, so if the package is installed but the binary is missing, run ' +
157
+ '`npx install-electron` once (needs network) and retry.',
150
158
  )
151
159
  }
152
160
 
161
+ /**
162
+ * Last-resort Electron discovery: walk this process's ancestry looking for a
163
+ * parent process whose executable is itself an Electron binary. Covers hosts
164
+ * that run the plugin in a child Node process of their own Electron main
165
+ * process (e.g. DSH Desktop): the host binary is already on disk and running,
166
+ * so reusing it needs no extra install. Filesystem-only on POSIX (/proc); on
167
+ * Windows a best-effort PowerShell CIM query is used — this path only runs
168
+ * when every other lookup failed, so its latency never touches the happy
169
+ * path, and a missing/blocked powershell simply yields nothing.
170
+ */
171
+ function hostElectronViaProcessTree(): string | undefined {
172
+ let pid: number | undefined = process.ppid
173
+ for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
174
+ const exe = processExecutable(pid)
175
+ if (exe !== undefined && isElectronBinary(exe)) return exe
176
+ pid = processParent(pid)
177
+ }
178
+ return undefined
179
+ }
180
+
181
+ /** The executable path of a running process, or undefined when unknown. */
182
+ function processExecutable(pid: number): string | undefined {
183
+ try {
184
+ if (process.platform === 'win32') {
185
+ const out = execFileSync('powershell.exe', [
186
+ '-NoProfile', '-NonInteractive', '-Command',
187
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ExecutablePath`,
188
+ ], { encoding: 'utf8', windowsHide: true, timeout: 3000 })
189
+ const line = out.trim().split(/\r?\n/)[0] ?? ''
190
+ return line.length > 0 ? line : undefined
191
+ }
192
+ const exe = readlinkSync(`/proc/${pid}/exe`)
193
+ return exe.length > 0 ? exe : undefined
194
+ } catch {
195
+ return undefined
196
+ }
197
+ }
198
+
199
+ /** The parent pid of a running process, or undefined when unknown. */
200
+ function processParent(pid: number): number | undefined {
201
+ try {
202
+ if (process.platform === 'win32') {
203
+ const out = execFileSync('powershell.exe', [
204
+ '-NoProfile', '-NonInteractive', '-Command',
205
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`,
206
+ ], { encoding: 'utf8', windowsHide: true, timeout: 3000 })
207
+ const n = Number(out.trim().split(/\r?\n/)[0])
208
+ return Number.isInteger(n) && n > 1 ? n : undefined
209
+ }
210
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
211
+ // Format: pid (comm) state ppid ... — comm is parenthesized and may
212
+ // itself contain spaces/parens, so parse from the LAST ')'; ppid is the
213
+ // second field after it.
214
+ const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)
215
+ const n = Number(fields[1])
216
+ return Number.isInteger(n) && n > 1 ? n : undefined
217
+ } catch {
218
+ return undefined
219
+ }
220
+ }
221
+
222
+ /**
223
+ * True when the given executable is an Electron binary. The name match
224
+ * covers dev binaries (electron, electron.exe); the resources marker covers
225
+ * packaged apps whose exe carries the product name (e.g. "DSH Desktop.exe") —
226
+ * every Electron distribution ships `resources/electron.asar`,
227
+ * `resources/app.asar`, or `resources/default_app.asar` beside the binary.
228
+ */
229
+ function isElectronBinary(exe: string): boolean {
230
+ if (basename(exe).toLowerCase().includes('electron')) return true
231
+ const resources = join(dirname(exe), 'resources')
232
+ return existsSync(join(resources, 'electron.asar'))
233
+ || existsSync(join(resources, 'app.asar'))
234
+ || existsSync(join(resources, 'default_app.asar'))
235
+ }
236
+
153
237
  /** Extract an electron version like "43.4.0" from a path containing it. */
154
238
  function versionOf(path: string): string {
155
239
  const match = /electron@(\d+\.\d+\.\d+)/.exec(path)
@@ -57,10 +57,15 @@ declare module 'electron' {
57
57
  loadURL(url: string): Promise<void>
58
58
  loadFile(path: string): Promise<void>
59
59
  send(channel: string, payload: unknown): void
60
+ focus(): void
60
61
  setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' } | { action: 'allow' }): void
61
62
  getTitle(): string
62
63
  getURL(): string
63
64
  on(event: 'ipc-message', listener: (event: unknown, channel: string, ...args: unknown[]) => void): this
65
+ // Windows keyboard focus routing: clicks reach any view, but keyboard
66
+ // events go only to the focused webContents (electron#28163). `input` is
67
+ // the raw input event; only its `type` is consumed here.
68
+ on(event: 'input-event', listener: (event: unknown, input: { type: string }) => void): this
64
69
  on(event: 'page-title-updated' | 'did-navigate' | 'did-navigate-in-page' | 'dom-ready', listener: (event: unknown, ...args: unknown[]) => void): this
65
70
  }
66
71
  export interface WebContentsView {
@@ -88,8 +93,10 @@ declare module 'electron' {
88
93
  setTitle(title: string): void
89
94
  on(event: 'closed', listener: () => void): this
90
95
  on(event: 'resize', listener: () => void): this
96
+ on(event: 'focus', listener: () => void): this
91
97
  off(event: 'closed', listener: () => void): this
92
98
  off(event: 'resize', listener: () => void): this
99
+ off(event: 'focus', listener: () => void): this
93
100
  }
94
101
  export const app: {
95
102
  whenReady(): Promise<void>