dsh-builtin-browser 0.1.22 → 0.1.23
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 +5 -1
- package/README.md +5 -1
- package/lib/browser/runtime.js +9 -1
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +60 -0
- package/lib/browser-electron/provider.d.ts +25 -4
- package/lib/browser-electron/provider.js +84 -12
- package/lib/browser-electron/remote-host.d.ts +21 -6
- package/lib/browser-electron/remote-host.js +50 -18
- package/package.json +8 -6
- package/src/browser/runtime.ts +15 -1
- package/src/browser-electron/host-main.ts +57 -0
- package/src/browser-electron/provider.ts +101 -17
- package/src/browser-electron/remote-host.ts +47 -16
package/README.en.md
CHANGED
|
@@ -310,8 +310,12 @@ Code layout:
|
|
|
310
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
311
|
|
|
312
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
|
+
| Round 18 | 2026-09-20 | **Windows on-device trio + probe self-healing + locate verdicts** (measured on a real self-hosted `dsh web` host; defects ①–④ were all the "CDP answered success, the page received nothing" kind): ① Chromium's `CalculateNativeWinOcclusion` marks the plugin window HIDDEN while another window covers it — the page stops producing frames and **every** synthesized mouse/key event is dropped by the renderer (`CanReceiveInput()` false) while CDP replies `{}` → the child appends `disable-features=CalculateNativeWinOcclusion` before `app.whenReady()` (win32 only); ② `click()` had no leading `mouseMoved`, so the first click on a fresh view was routed away and lost → now move→press→release; ③ a fresh view holds no web focus, so the FIRST `browser_key` of a session vanished → new host `focus` op (optional `focus?()` on the view handle), `key()` focuses best-effort before dispatch and waits 80ms only when focus had to move (focus lands asynchronously; a key dispatched in the same turn is still dropped); ④ `available()` cached a FAILED Electron probe for the host's lifetime while provider selection runs once per process, so an Electron that arrived after DSH started was never adopted → successes stay cached, failures re-probe after a cooldown (`DSH_BROWSER_PROBE_RETRY_MS`, default 30s), and `resolveProvider()` now distinguishes "no provider registered" from "registered but reports itself unavailable" with the matching remedy; ⑤ **a failed locate was masked by the outer timeout** — the in-page locate script polls for its whole budget and answers only afterwards, while the outer wait used the SAME budget, so `browser: click timed out after 10000ms` won the race and the in-page verdict never got out; a css/xpath **parse error** also reported as "not found yet", polling a selector that can never become valid. Now a parse error is terminal and names itself (`invalid CSS selector "…" / invalid XPath …`), the outer wait gives the in-page answer 2 s of transport grace, and a miss reports the strategy the provider assumed (`by` defaults to `"by":"css"`) plus the time spent; `scrape`'s item selector fails the same way at once. Same call: before `click timed out after 10000ms`, after `element not found: {"value":"Learn more","by":"css"} (looked for 10000ms)`. 7 new regression tests (**47/47 pass**), 17/17 end-to-end steps against the real host, plus 4/4 locate-verdict steps |
|
|
313
314
|
|
|
314
|
-
|
|
315
|
+
| 19 | 2026-10-01 | **DSH 0.2 compatibility**: DSH moved to the 0.2 line (`@deepseek-ai/dsh@0.2.0-rc.2`, with `dsh-llm`/`dsh-tools`/`dsh-system-prompt` following to `0.2.0-rc.2`), while our declaration `>=0.1.1-rc.1 <0.2.0` shut 0.2 out → verified the plugin's (narrow) runtime dependency surface against a **real 0.2.0-rc.2 host** (`cordis` Context/Service, `dsh-tools` defineTool, `dsh-llm` HarnessError, `schemastery`) and found no breaking change: session, navigation, snapshot and the screenshot trio (outside-path refused / legal write / overwrite refused) all pass → peer ranges for the three dsh packages widened to `>=0.1.1-rc.2 <0.3.0`, `dsh.compatibility.dsh` widened to `>=0.1.1-rc.1 <0.3.0`, and `dshReleases` gained `0.2.0-rc.1`/`0.2.0-rc.2` = compatible |
|
|
316
|
+
| **0.1.23** | 2026-10-01 | **Release**: round 18 (PR #15: the Windows synthesized-input trio + Electron probe self-healing + locate verdicts) and round 19 (DSH 0.2 compatibility) ship as **0.1.23** (build clean, **47/47 tests pass**, tag `v0.1.23`) |
|
|
317
|
+
|
|
318
|
+
> The npm badge at the top is the authority on the registry's latest version: `0.1.23` is committed and tagged; if the badge still reads `0.1.22`, that version is not published yet.
|
|
315
319
|
|
|
316
320
|
## Acknowledgements
|
|
317
321
|
|
package/README.md
CHANGED
|
@@ -310,8 +310,12 @@ npm run build
|
|
|
310
310
|
| 第十七轮 | 2026-09-16 | **截图 savePath 收敛 + 下载目录本地化**(issue #13):`browser_screenshot` 原先直接 `writeFileSync`,可写进程可达的任意路径并**静默覆盖**已有文件(等于绕过只读沙箱的写保护)→ 抽出唯一下载/截图共用准入门 `admitSavePath`(绝对路径 + `downloadDir` 内 + **不覆盖已存在文件**),截图补父目录自动创建;默认下载目录不再写死 `~/Downloads`,按序探测 `downloadDir` → `XDG_DOWNLOAD_DIR` → `~/Downloads`/`~/下载`/`~/下載` → 回退(中文桌面免配置) |
|
|
311
311
|
|
|
312
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
|
+
| 第十八轮 | 2026-09-20 | **Windows 真机三连 + 探测自愈 + 定位判词**(真实 `dsh web` 自托管宿主实测,缺陷①~④同为「CDP 报成功、页面没收到」):① Windows 的 `CalculateNativeWinOcclusion` 把被遮挡的插件窗口判定为 HIDDEN → 整站停帧,且每条合成鼠标/键盘事件被渲染端静默丢弃(CanReceiveInput=false)而 CDP 回 `{}` → 子进程 ready 前追加 `disable-features=CalculateNativeWinOcclusion`(仅 win32);② `click()` 缺前置 `mouseMoved`,新视图第一次点击落空 → 改为 move→press→release;③ 新视图从未持有 web focus,`browser_key` 第一次调用无效 → 宿主新增 `focus` op(view handle 可选 `focus?()`),`key()` 派发前 best-effort 聚焦,并在焦点需要移动时等 80ms(焦点落地异步,同轮派发的键仍会被丢);④ `available()` 把**失败**探测按宿主生命周期永久缓存,而 provider 选择每进程一次 → Electron 晚于 DSH 到位就永远接不上 → 成功仍缓存、失败 30s 冷却后重探(`DSH_BROWSER_PROBE_RETRY_MS`),`resolveProvider()` 报错区分「一个都没注册」与「注册了但自报不可用」并附处置;⑤ **定位失败被外层超时掩盖** —— 页内定位脚本会把预算轮询到底才回答,外层却用同一个预算,于是 `browser: click timed out after 10000ms` 抢走了页内早已写好的判词;而 css/xpath **解析失败**(选择器根本不合法)也被写成「还没找到」,对着永远不可能匹配的东西把预算轮完 → 解析失败即刻终止并报 `invalid CSS selector "…" / invalid XPath …`;外层给页内判词 2s 传输余量;未命中判词补上提供方实际采用的策略(`by` 缺省即 `"by":"css"`)与实际耗时;`scrape` 的 item 选择器同样即刻失败(同一个漏写 `by` 的调用:修前 `click timed out after 10000ms`,修后 `element not found: {"value":"Learn more","by":"css"} (looked for 10000ms)`);新增 7 条回归测试(**47/47 全绿**),真实宿主端到端 **17/17** + 定位判词 **4/4** |
|
|
313
314
|
|
|
314
|
-
|
|
315
|
+
| 第十九轮 | 2026-10-01 | **DSH 0.2 兼容性**:DSH 进入 0.2 线(`@deepseek-ai/dsh@0.2.0-rc.2`,peer 包 `dsh-llm`/`dsh-tools`/`dsh-system-prompt` 同步到 `0.2.0-rc.2`),而原声明 `>=0.1.1-rc.1 <0.2.0` 把 0.2 挡在门外 → 在**真实 0.2.0-rc.2 宿主**上实测插件的运行时依赖面(`cordis` 的 Context/Service、`dsh-tools` 的 defineTool、`dsh-llm` 的 HarnessError、`schemastery`),确认无破坏性变更:会话、导航、快照、截图三态(越界拒绝/合法写入/覆盖拒绝)全部通过 → `peerDependencies` 三个 dsh 包范围改为 `>=0.1.1-rc.2 <0.3.0`,`dsh.compatibility.dsh` 放宽为 `>=0.1.1-rc.1 <0.3.0`,`dshReleases` 增加 `0.2.0-rc.1`/`0.2.0-rc.2` = compatible |
|
|
316
|
+
| **0.1.23** | 2026-10-01 | **发布**:第十八轮(PR #15:Windows 合成输入三连 + Electron 探测自愈 + 定位判词)与第十九轮(DSH 0.2 兼容)随 **0.1.23** 发布(构建零错误、**47 项测试全绿**,tag `v0.1.23`) |
|
|
317
|
+
|
|
318
|
+
> registry 上的最新版本以顶部 npm 徽章为准:本次 `0.1.23` 已入库并打 tag;若徽章仍显示 `0.1.22`,说明该版本尚未 publish。
|
|
315
319
|
|
|
316
320
|
## 特别感谢
|
|
317
321
|
|
package/lib/browser/runtime.js
CHANGED
|
@@ -70,7 +70,15 @@ export class BrowserRuntime extends Service {
|
|
|
70
70
|
const usable = [...providers.values()].filter(provider => provider.available());
|
|
71
71
|
const [single] = usable;
|
|
72
72
|
if (single === undefined) {
|
|
73
|
-
|
|
73
|
+
// Two very different causes used to read identically here — nothing is
|
|
74
|
+
// registered, or something is registered but cannot run — and only the
|
|
75
|
+
// second one has a fix the user can apply without reinstalling.
|
|
76
|
+
const registered = [...providers.values()].map(provider => provider.id);
|
|
77
|
+
if (registered.length === 0) {
|
|
78
|
+
throw new BrowserError('no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile', 'BROWSER_PROVIDER_UNAVAILABLE');
|
|
79
|
+
}
|
|
80
|
+
throw new BrowserError(`browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
|
|
81
|
+
+ 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH', 'BROWSER_PROVIDER_UNAVAILABLE');
|
|
74
82
|
}
|
|
75
83
|
if (usable.length > 1) {
|
|
76
84
|
const ids = usable.map(provider => provider.id).join(', ');
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
14
14
|
* { id, op: 'groupView', viewId, windowId, label? } |
|
|
15
15
|
* { id, op: 'command', viewId, method, params } |
|
|
16
|
+
* { id, op: 'focus', viewId } |
|
|
16
17
|
* { id, op: 'userActionError', windowId, message }
|
|
17
18
|
* -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
|
|
18
19
|
* parent's stdin token; the parent refuses the connection otherwise)
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
14
14
|
* { id, op: 'groupView', viewId, windowId, label? } |
|
|
15
15
|
* { id, op: 'command', viewId, method, params } |
|
|
16
|
+
* { id, op: 'focus', viewId } |
|
|
16
17
|
* { id, op: 'userActionError', windowId, message }
|
|
17
18
|
* -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
|
|
18
19
|
* parent's stdin token; the parent refuses the connection otherwise)
|
|
@@ -235,6 +236,13 @@ function withTimeout(promise, ms, label) {
|
|
|
235
236
|
* only the tool layer's 10–30s timeout eventually cuts off.
|
|
236
237
|
*/
|
|
237
238
|
const COMMAND_TIMEOUT_MS = 20000;
|
|
239
|
+
/**
|
|
240
|
+
* How long to let a `focus` request land before answering it. Focus moves
|
|
241
|
+
* asynchronously and a key dispatched in the same turn as the request is still
|
|
242
|
+
* dropped, so the caller needs this settle — but only on the call where focus
|
|
243
|
+
* actually had to move.
|
|
244
|
+
*/
|
|
245
|
+
const FOCUS_SETTLE_MS = 80;
|
|
238
246
|
/**
|
|
239
247
|
* A brand-new WebContentsView has no renderer process until something loads,
|
|
240
248
|
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
@@ -725,6 +733,29 @@ async function handle(op, msg) {
|
|
|
725
733
|
reply(msg.id, { ok: true, result });
|
|
726
734
|
return;
|
|
727
735
|
}
|
|
736
|
+
case 'focus': {
|
|
737
|
+
const viewId = msg.viewId;
|
|
738
|
+
if (viewId === undefined)
|
|
739
|
+
throw new Error('focus missing viewId');
|
|
740
|
+
const win = windowOfView(viewId);
|
|
741
|
+
const entry = win?.views.get(viewId);
|
|
742
|
+
if (win === undefined || entry === undefined)
|
|
743
|
+
throw new Error(`focus: unknown view ${viewId}`);
|
|
744
|
+
// Keys go to the view that holds web focus. A view that was never
|
|
745
|
+
// clicked holds none, so the FIRST browser_key of a session was dropped
|
|
746
|
+
// by the renderer while CDP still answered the dispatch with success —
|
|
747
|
+
// and `document.hasFocus()` inside the page even read true.
|
|
748
|
+
const contents = entry.webContentsView.webContents;
|
|
749
|
+
const hadWebFocus = contents.isFocused();
|
|
750
|
+
contents.focus();
|
|
751
|
+
// Focus lands asynchronously, and a key dispatched in the same turn as
|
|
752
|
+
// the request is still dropped, so hold the reply until the renderer can
|
|
753
|
+
// take input. Only on the call where focus actually had to move.
|
|
754
|
+
if (!hadWebFocus)
|
|
755
|
+
await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS));
|
|
756
|
+
reply(msg.id, { ok: true, focused: contents.isFocused() });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
728
759
|
case 'capture': {
|
|
729
760
|
const viewId = msg.viewId;
|
|
730
761
|
if (viewId === undefined)
|
|
@@ -997,6 +1028,35 @@ async function handle(op, msg) {
|
|
|
997
1028
|
reply(msg.id, { ok: false, err: String(error) });
|
|
998
1029
|
}
|
|
999
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
|
|
1033
|
+
* window is covered by another window and, when it is, moves the WebContents to
|
|
1034
|
+
* `Visibility::HIDDEN`. That is the wrong model for this host: the shared
|
|
1035
|
+
* browser window normally sits *behind* whatever the human is working in, and
|
|
1036
|
+
* that is exactly the state the agent drives it from. A hidden WebContents
|
|
1037
|
+
* produces no frames at all (`requestAnimationFrame` never fires) and
|
|
1038
|
+
* `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
|
|
1039
|
+
* `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
|
|
1040
|
+
* floor while the CDP call itself still answers `{}` — clicks and keys that
|
|
1041
|
+
* silently do nothing, with no error anywhere. Keep the window's visibility
|
|
1042
|
+
* state application-controlled instead; the parent already shows/raises views
|
|
1043
|
+
* explicitly, and `webContentsView.setVisible()` remains the single source of
|
|
1044
|
+
* truth for whether a view renders.
|
|
1045
|
+
*/
|
|
1046
|
+
function keepWindowsNonOccluding() {
|
|
1047
|
+
if (process.platform !== 'win32')
|
|
1048
|
+
return;
|
|
1049
|
+
const DISABLED = 'CalculateNativeWinOcclusion';
|
|
1050
|
+
const current = app.commandLine.getSwitchValue('disable-features') ?? '';
|
|
1051
|
+
const features = current
|
|
1052
|
+
.split(',')
|
|
1053
|
+
.map(part => part.trim())
|
|
1054
|
+
.filter(part => part !== '');
|
|
1055
|
+
if (!features.includes(DISABLED))
|
|
1056
|
+
features.push(DISABLED);
|
|
1057
|
+
app.commandLine.appendSwitch('disable-features', features.join(','));
|
|
1058
|
+
}
|
|
1059
|
+
keepWindowsNonOccluding();
|
|
1000
1060
|
/**
|
|
1001
1061
|
* Electron entry: connect back to the parent's RPC server (port from
|
|
1002
1062
|
* `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
|
|
@@ -126,6 +126,12 @@ export interface ElectronViewHandle {
|
|
|
126
126
|
* @returns the CDP `result` object.
|
|
127
127
|
*/
|
|
128
128
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
129
|
+
/**
|
|
130
|
+
* Give the backing view web focus so keyboard input reaches its page.
|
|
131
|
+
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
132
|
+
* then behaves exactly as before.
|
|
133
|
+
*/
|
|
134
|
+
focus?(): Promise<void>;
|
|
129
135
|
}
|
|
130
136
|
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
131
137
|
export interface ElectronBrowserProviderConfig {
|
|
@@ -151,14 +157,16 @@ export interface CdpNavigateParams {
|
|
|
151
157
|
readonly url: string;
|
|
152
158
|
}
|
|
153
159
|
/**
|
|
154
|
-
* CDP method/params for `Input.dispatchMouseEvent
|
|
160
|
+
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
161
|
+
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
162
|
+
* release halves; a move carries only the position.
|
|
155
163
|
*/
|
|
156
164
|
export interface CdpMouseParams {
|
|
157
|
-
readonly type: 'mousePressed' | 'mouseReleased';
|
|
165
|
+
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
|
|
158
166
|
readonly x: number;
|
|
159
167
|
readonly y: number;
|
|
160
|
-
readonly button
|
|
161
|
-
readonly clickCount
|
|
168
|
+
readonly button?: 'left';
|
|
169
|
+
readonly clickCount?: number;
|
|
162
170
|
}
|
|
163
171
|
/** CDP method/params for `Input.insertText`. */
|
|
164
172
|
export interface CdpInsertTextParams {
|
|
@@ -278,6 +286,17 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
278
286
|
* rather than reporting a success the page never saw.
|
|
279
287
|
*/
|
|
280
288
|
private present;
|
|
289
|
+
/**
|
|
290
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
291
|
+
*
|
|
292
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
293
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
294
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
295
|
+
* session vanished inside the renderer while the command still answered
|
|
296
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
297
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
298
|
+
*/
|
|
299
|
+
private focusView;
|
|
281
300
|
/** Execute JS in the active tab's page context. */
|
|
282
301
|
execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
|
|
283
302
|
/**
|
|
@@ -342,6 +361,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
342
361
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
343
362
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
344
363
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
364
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
365
|
+
* polled until the budget expires — see the comment in `match`.
|
|
345
366
|
*/
|
|
346
367
|
private buildTargetScript;
|
|
347
368
|
/**
|
|
@@ -419,6 +419,27 @@ export class ElectronBrowserProvider {
|
|
|
419
419
|
throw new BrowserError(`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`, 'BROWSER_VIEW_NOT_PRESENTED', { cause: error });
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
424
|
+
*
|
|
425
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
426
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
427
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
428
|
+
* session vanished inside the renderer while the command still answered
|
|
429
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
430
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
431
|
+
*/
|
|
432
|
+
async focusView(handle) {
|
|
433
|
+
if (handle.focus === undefined)
|
|
434
|
+
return;
|
|
435
|
+
try {
|
|
436
|
+
await handle.focus();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
440
|
+
// press; the dispatch below reports a real channel failure itself.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
422
443
|
/** Execute JS in the active tab's page context. */
|
|
423
444
|
async execute(session, request, signal) {
|
|
424
445
|
const s = this.session(session);
|
|
@@ -915,24 +936,48 @@ export class ElectronBrowserProvider {
|
|
|
915
936
|
// Input.* is only delivered to a view with a current display surface; the
|
|
916
937
|
// barrier also re-presents after a navigation replaced the renderer.
|
|
917
938
|
await this.present(s, signal);
|
|
918
|
-
const press = (type) => handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
|
|
919
939
|
const timeoutMs = 30_000;
|
|
940
|
+
const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
|
|
941
|
+
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
942
|
+
// hover target rather than hit-testing the coordinates, and a real pointer
|
|
943
|
+
// click is always preceded by movement. A view that has never received a
|
|
944
|
+
// mouse event has no hover target yet, so the press of the FIRST click on a
|
|
945
|
+
// fresh tab went nowhere while CDP still answered success; the second click
|
|
946
|
+
// landed because the first had quietly established the target. Move the
|
|
947
|
+
// pointer first so a click is self-contained.
|
|
920
948
|
try {
|
|
921
|
-
await
|
|
949
|
+
await send({ type: 'mouseMoved', x, y }, 'move');
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
953
|
+
}
|
|
954
|
+
const release = () => {
|
|
955
|
+
void handle
|
|
956
|
+
.sendCommand('Input.dispatchMouseEvent', {
|
|
957
|
+
type: 'mouseReleased',
|
|
958
|
+
x,
|
|
959
|
+
y,
|
|
960
|
+
button: 'left',
|
|
961
|
+
clickCount: 1,
|
|
962
|
+
})
|
|
963
|
+
.catch(() => { });
|
|
964
|
+
};
|
|
965
|
+
try {
|
|
966
|
+
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
|
|
922
967
|
}
|
|
923
968
|
catch (error) {
|
|
924
969
|
// The press may still land late; release best-effort so the button is
|
|
925
970
|
// never left in a stuck pressed state.
|
|
926
|
-
|
|
971
|
+
release();
|
|
927
972
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
928
973
|
}
|
|
929
974
|
try {
|
|
930
|
-
await
|
|
975
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
|
|
931
976
|
}
|
|
932
977
|
catch (error) {
|
|
933
978
|
// The press already landed; retry the release so the button is not
|
|
934
979
|
// left pressed before surfacing the failure.
|
|
935
|
-
|
|
980
|
+
release();
|
|
936
981
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
937
982
|
}
|
|
938
983
|
this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true);
|
|
@@ -1061,6 +1106,7 @@ export class ElectronBrowserProvider {
|
|
|
1061
1106
|
const timeoutMs = 15_000;
|
|
1062
1107
|
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1063
1108
|
await this.present(s, signal);
|
|
1109
|
+
await this.focusView(handle);
|
|
1064
1110
|
const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
|
|
1065
1111
|
try {
|
|
1066
1112
|
await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
|
|
@@ -1231,10 +1277,16 @@ export class ElectronBrowserProvider {
|
|
|
1231
1277
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
1232
1278
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
1233
1279
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
1280
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
1281
|
+
* polled until the budget expires — see the comment in `match`.
|
|
1234
1282
|
*/
|
|
1235
1283
|
buildTargetScript(spec, timeoutMs, body) {
|
|
1284
|
+
// `by` defaults to css in-page; report the strategy that WAS used, or a
|
|
1285
|
+
// miss says only `{"value":"Learn more"}` and the caller cannot tell that a
|
|
1286
|
+
// plain label was queried as a CSS selector.
|
|
1287
|
+
const resolvedSpec = { ...spec, by: spec.by ?? 'css' };
|
|
1236
1288
|
return `(async () => {
|
|
1237
|
-
const spec = ${JSON.stringify(
|
|
1289
|
+
const spec = ${JSON.stringify(resolvedSpec)}
|
|
1238
1290
|
const timeoutMs = ${String(timeoutMs)}
|
|
1239
1291
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
1240
1292
|
const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
|
|
@@ -1246,7 +1298,13 @@ export class ElectronBrowserProvider {
|
|
|
1246
1298
|
const index = typeof spec.index === 'number' ? spec.index : 0
|
|
1247
1299
|
let els = []
|
|
1248
1300
|
if (by === 'css') {
|
|
1249
|
-
|
|
1301
|
+
// A selector that does not PARSE can never match on a later poll, so
|
|
1302
|
+
// it must not look like a miss: the old "return null" kept the caller
|
|
1303
|
+
// polling until the whole budget was gone, and "element not found
|
|
1304
|
+
// after 10s" reads like a slow page while the real cause is a
|
|
1305
|
+
// malformed selector (a label or plain text passed where one belongs).
|
|
1306
|
+
try { els = Array.from(document.querySelectorAll(value)) }
|
|
1307
|
+
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1250
1308
|
} else if (by === 'xpath') {
|
|
1251
1309
|
try {
|
|
1252
1310
|
const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
@@ -1254,7 +1312,7 @@ export class ElectronBrowserProvider {
|
|
|
1254
1312
|
const n = snap.snapshotItem(i)
|
|
1255
1313
|
if (n instanceof Element) els.push(n)
|
|
1256
1314
|
}
|
|
1257
|
-
} catch {
|
|
1315
|
+
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1258
1316
|
} else {
|
|
1259
1317
|
const all = Array.from(document.querySelectorAll('body *'))
|
|
1260
1318
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
@@ -1268,9 +1326,10 @@ export class ElectronBrowserProvider {
|
|
|
1268
1326
|
const deadline = Date.now() + timeoutMs
|
|
1269
1327
|
let el = null
|
|
1270
1328
|
for (;;) {
|
|
1271
|
-
|
|
1329
|
+
// Only a parse error escapes the poll: see the comment in match() above.
|
|
1330
|
+
try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
|
|
1272
1331
|
if (el !== null) break
|
|
1273
|
-
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
|
|
1332
|
+
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1274
1333
|
await sleep(100)
|
|
1275
1334
|
}
|
|
1276
1335
|
${body}
|
|
@@ -1282,7 +1341,12 @@ export class ElectronBrowserProvider {
|
|
|
1282
1341
|
* evaluation failure or an in-page `{ ok: false, error }` verdict.
|
|
1283
1342
|
*/
|
|
1284
1343
|
async runTargetScript(tab, script, timeoutMs, signal, errorCode, errorLabel) {
|
|
1285
|
-
|
|
1344
|
+
// The script polls for its whole `timeoutMs` and only then answers, so an
|
|
1345
|
+
// outer budget of the same size fires at the same instant and wins: the
|
|
1346
|
+
// caller saw `click timed out after 10000ms` while the page had already
|
|
1347
|
+
// decided WHY (selector not found / malformed). Give the answer a chance to
|
|
1348
|
+
// get back before the generic timeout replaces it.
|
|
1349
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1286
1350
|
if (!result.ok)
|
|
1287
1351
|
throw new BrowserError(`${errorLabel} failed: ${result.exception}`, errorCode);
|
|
1288
1352
|
const value = result.value;
|
|
@@ -1474,7 +1538,9 @@ export class ElectronBrowserProvider {
|
|
|
1474
1538
|
const deadline = Date.now() + timeoutMs
|
|
1475
1539
|
let items = []
|
|
1476
1540
|
for (;;) {
|
|
1477
|
-
|
|
1541
|
+
// Immediate, like match() above: an unparseable selector can never match.
|
|
1542
|
+
try { items = Array.from(document.querySelectorAll(itemSel)) }
|
|
1543
|
+
catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
|
|
1478
1544
|
if (items.length > 0) break
|
|
1479
1545
|
if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
|
|
1480
1546
|
await sleep(100)
|
|
@@ -1913,6 +1979,12 @@ const SETTLE_TIMEOUT_MS = 5_000;
|
|
|
1913
1979
|
const SETTLE_GRACE_MS = 150;
|
|
1914
1980
|
/** Poll interval for the document-settle loop. */
|
|
1915
1981
|
const SETTLE_POLL_MS = 25;
|
|
1982
|
+
/**
|
|
1983
|
+
* Extra time the transport gets over an in-page locate script's own budget: the
|
|
1984
|
+
* script polls for `timeoutMs` and answers only afterwards, so the outer wait
|
|
1985
|
+
* must outlast it or its generic timeout hides the in-page reason.
|
|
1986
|
+
*/
|
|
1987
|
+
const TARGET_SCRIPT_GRACE_MS = 2_000;
|
|
1916
1988
|
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
1917
1989
|
function stripTabPrefix(id) {
|
|
1918
1990
|
return id.startsWith('tab:') ? id.slice(4) : id;
|
|
@@ -99,14 +99,20 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
99
99
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
100
100
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
101
101
|
private readonly spawnExecutable?;
|
|
102
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
103
|
+
* resolveElectronPath(). */
|
|
104
|
+
private readonly probe;
|
|
102
105
|
private client;
|
|
103
106
|
private server;
|
|
104
107
|
private pendingSocket;
|
|
105
108
|
private readonly views;
|
|
106
109
|
private readyPromise;
|
|
107
110
|
private disposed;
|
|
108
|
-
/**
|
|
111
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
112
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
109
113
|
private electronAvailable;
|
|
114
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
115
|
+
private nextProbeAt;
|
|
110
116
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
111
117
|
* a restarted child still places views in the right windows. */
|
|
112
118
|
private readonly groups;
|
|
@@ -115,13 +121,22 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
115
121
|
constructor(hostMainPath: string,
|
|
116
122
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
117
123
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
118
|
-
spawnExecutable?: string | undefined
|
|
124
|
+
spawnExecutable?: string | undefined,
|
|
125
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
126
|
+
* resolveElectronPath(). */
|
|
127
|
+
probe?: () => void);
|
|
119
128
|
/**
|
|
120
129
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
121
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
130
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
131
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
132
|
+
* instead of a confusing spawn failure on first use.
|
|
133
|
+
*
|
|
134
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
135
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
136
|
+
* long after DSH started, and provider selection happens once per process —
|
|
137
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
138
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
139
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
125
140
|
*/
|
|
126
141
|
available(): boolean;
|
|
127
142
|
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
@@ -624,6 +624,10 @@ class RemoteView {
|
|
|
624
624
|
params: params ?? {},
|
|
625
625
|
});
|
|
626
626
|
}
|
|
627
|
+
/** Give this view web focus in the child, so keyboard input reaches the page. */
|
|
628
|
+
focus() {
|
|
629
|
+
return this.client.call('focus', { viewId: this.id });
|
|
630
|
+
}
|
|
627
631
|
/** Ask the child to download a URL to a local file (keeps cookies/login). */
|
|
628
632
|
async download(url, savePath) {
|
|
629
633
|
// The child writes the file itself (temp + rename); only a small
|
|
@@ -644,6 +648,14 @@ class RemoteView {
|
|
|
644
648
|
return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
|
|
645
649
|
}
|
|
646
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* How long a FAILED Electron-binary probe is remembered before the binary
|
|
653
|
+
* search runs again (`available()`). A success is kept for the host's lifetime.
|
|
654
|
+
*/
|
|
655
|
+
const PROBE_RETRY_MS = (() => {
|
|
656
|
+
const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS);
|
|
657
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30000;
|
|
658
|
+
})();
|
|
647
659
|
/**
|
|
648
660
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
649
661
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -652,14 +664,18 @@ class RemoteView {
|
|
|
652
664
|
export class RemoteElectronViewHost {
|
|
653
665
|
hostMainPath;
|
|
654
666
|
spawnExecutable;
|
|
667
|
+
probe;
|
|
655
668
|
client;
|
|
656
669
|
server;
|
|
657
670
|
pendingSocket;
|
|
658
671
|
views = new Map();
|
|
659
672
|
readyPromise;
|
|
660
673
|
disposed = false;
|
|
661
|
-
/**
|
|
674
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
675
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
662
676
|
electronAvailable;
|
|
677
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
678
|
+
nextProbeAt = 0;
|
|
663
679
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
664
680
|
* a restarted child still places views in the right windows. */
|
|
665
681
|
groups = new Map();
|
|
@@ -668,30 +684,43 @@ export class RemoteElectronViewHost {
|
|
|
668
684
|
constructor(hostMainPath,
|
|
669
685
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
670
686
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
671
|
-
spawnExecutable
|
|
687
|
+
spawnExecutable,
|
|
688
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
689
|
+
* resolveElectronPath(). */
|
|
690
|
+
probe = resolveElectronPath) {
|
|
672
691
|
this.hostMainPath = hostMainPath;
|
|
673
692
|
this.spawnExecutable = spawnExecutable;
|
|
693
|
+
this.probe = probe;
|
|
674
694
|
}
|
|
675
695
|
/**
|
|
676
696
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
677
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
697
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
698
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
699
|
+
* instead of a confusing spawn failure on first use.
|
|
700
|
+
*
|
|
701
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
702
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
703
|
+
* long after DSH started, and provider selection happens once per process —
|
|
704
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
705
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
706
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
681
707
|
*/
|
|
682
708
|
available() {
|
|
683
|
-
if (this.electronAvailable ===
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
709
|
+
if (this.electronAvailable === true)
|
|
710
|
+
return true;
|
|
711
|
+
if (Date.now() < this.nextProbeAt)
|
|
712
|
+
return false;
|
|
713
|
+
this.nextProbeAt = Date.now() + PROBE_RETRY_MS;
|
|
714
|
+
try {
|
|
715
|
+
this.probe();
|
|
716
|
+
this.electronAvailable = true;
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
this.electronAvailable = false;
|
|
720
|
+
// The provider will be reported unavailable, so the detailed
|
|
721
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
722
|
+
// otherwise never reach the user — surface it on stderr.
|
|
723
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
695
724
|
}
|
|
696
725
|
return this.electronAvailable;
|
|
697
726
|
}
|
|
@@ -978,6 +1007,9 @@ class DeferredRemoteView {
|
|
|
978
1007
|
async capture(opts) {
|
|
979
1008
|
return this.withRecovery(view => view.capture(opts));
|
|
980
1009
|
}
|
|
1010
|
+
async focus() {
|
|
1011
|
+
return this.withRecovery(view => view.focus());
|
|
1012
|
+
}
|
|
981
1013
|
async flushAuth() {
|
|
982
1014
|
return this.withRecovery(view => view.flushAuth());
|
|
983
1015
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
68
|
-
"@deepseek-ai/dsh-llm": "
|
|
69
|
-
"@deepseek-ai/dsh-tools": "
|
|
70
|
-
"@deepseek-ai/dsh-system-prompt": "
|
|
68
|
+
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0",
|
|
69
|
+
"@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.3.0",
|
|
70
|
+
"@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 <0.3.0",
|
|
71
71
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"patch": "./cordis.patch.yml"
|
|
80
80
|
},
|
|
81
81
|
"compatibility": {
|
|
82
|
-
"dsh": ">=0.1.1-rc.1 <0.
|
|
82
|
+
"dsh": ">=0.1.1-rc.1 <0.3.0",
|
|
83
83
|
"profiles": [
|
|
84
84
|
"web",
|
|
85
85
|
"desktop"
|
|
@@ -87,7 +87,9 @@
|
|
|
87
87
|
"dshReleases": {
|
|
88
88
|
"0.1.0-rc.8": "unknown",
|
|
89
89
|
"0.1.1-rc.1": "compatible",
|
|
90
|
-
"0.1.1-rc.2": "compatible"
|
|
90
|
+
"0.1.1-rc.2": "compatible",
|
|
91
|
+
"0.2.0-rc.1": "compatible",
|
|
92
|
+
"0.2.0-rc.2": "compatible"
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
}
|
package/src/browser/runtime.ts
CHANGED
|
@@ -177,7 +177,21 @@ export class BrowserRuntime extends Service {
|
|
|
177
177
|
const usable = [...providers.values()].filter(provider => provider.available())
|
|
178
178
|
const [single] = usable
|
|
179
179
|
if (single === undefined) {
|
|
180
|
-
|
|
180
|
+
// Two very different causes used to read identically here — nothing is
|
|
181
|
+
// registered, or something is registered but cannot run — and only the
|
|
182
|
+
// second one has a fix the user can apply without reinstalling.
|
|
183
|
+
const registered = [...providers.values()].map(provider => provider.id)
|
|
184
|
+
if (registered.length === 0) {
|
|
185
|
+
throw new BrowserError(
|
|
186
|
+
'no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile',
|
|
187
|
+
'BROWSER_PROVIDER_UNAVAILABLE',
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
throw new BrowserError(
|
|
191
|
+
`browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
|
|
192
|
+
+ 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH',
|
|
193
|
+
'BROWSER_PROVIDER_UNAVAILABLE',
|
|
194
|
+
)
|
|
181
195
|
}
|
|
182
196
|
if (usable.length > 1) {
|
|
183
197
|
const ids = usable.map(provider => provider.id).join(', ')
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
14
14
|
* { id, op: 'groupView', viewId, windowId, label? } |
|
|
15
15
|
* { id, op: 'command', viewId, method, params } |
|
|
16
|
+
* { id, op: 'focus', viewId } |
|
|
16
17
|
* { id, op: 'userActionError', windowId, message }
|
|
17
18
|
* -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
|
|
18
19
|
* parent's stdin token; the parent refuses the connection otherwise)
|
|
@@ -286,6 +287,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
|
|
286
287
|
*/
|
|
287
288
|
const COMMAND_TIMEOUT_MS = 20000
|
|
288
289
|
|
|
290
|
+
/**
|
|
291
|
+
* How long to let a `focus` request land before answering it. Focus moves
|
|
292
|
+
* asynchronously and a key dispatched in the same turn as the request is still
|
|
293
|
+
* dropped, so the caller needs this settle — but only on the call where focus
|
|
294
|
+
* actually had to move.
|
|
295
|
+
*/
|
|
296
|
+
const FOCUS_SETTLE_MS = 80
|
|
297
|
+
|
|
289
298
|
/**
|
|
290
299
|
* A brand-new WebContentsView has no renderer process until something loads,
|
|
291
300
|
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
@@ -714,6 +723,26 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
714
723
|
reply(msg.id, { ok: true, result })
|
|
715
724
|
return
|
|
716
725
|
}
|
|
726
|
+
case 'focus': {
|
|
727
|
+
const viewId = msg.viewId
|
|
728
|
+
if (viewId === undefined) throw new Error('focus missing viewId')
|
|
729
|
+
const win = windowOfView(viewId)
|
|
730
|
+
const entry = win?.views.get(viewId)
|
|
731
|
+
if (win === undefined || entry === undefined) throw new Error(`focus: unknown view ${viewId}`)
|
|
732
|
+
// Keys go to the view that holds web focus. A view that was never
|
|
733
|
+
// clicked holds none, so the FIRST browser_key of a session was dropped
|
|
734
|
+
// by the renderer while CDP still answered the dispatch with success —
|
|
735
|
+
// and `document.hasFocus()` inside the page even read true.
|
|
736
|
+
const contents = entry.webContentsView.webContents
|
|
737
|
+
const hadWebFocus = contents.isFocused()
|
|
738
|
+
contents.focus()
|
|
739
|
+
// Focus lands asynchronously, and a key dispatched in the same turn as
|
|
740
|
+
// the request is still dropped, so hold the reply until the renderer can
|
|
741
|
+
// take input. Only on the call where focus actually had to move.
|
|
742
|
+
if (!hadWebFocus) await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS))
|
|
743
|
+
reply(msg.id, { ok: true, focused: contents.isFocused() })
|
|
744
|
+
return
|
|
745
|
+
}
|
|
717
746
|
case 'capture': {
|
|
718
747
|
const viewId = msg.viewId
|
|
719
748
|
if (viewId === undefined) throw new Error('capture missing viewId')
|
|
@@ -942,6 +971,34 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
942
971
|
}
|
|
943
972
|
}
|
|
944
973
|
|
|
974
|
+
/**
|
|
975
|
+
* Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
|
|
976
|
+
* window is covered by another window and, when it is, moves the WebContents to
|
|
977
|
+
* `Visibility::HIDDEN`. That is the wrong model for this host: the shared
|
|
978
|
+
* browser window normally sits *behind* whatever the human is working in, and
|
|
979
|
+
* that is exactly the state the agent drives it from. A hidden WebContents
|
|
980
|
+
* produces no frames at all (`requestAnimationFrame` never fires) and
|
|
981
|
+
* `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
|
|
982
|
+
* `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
|
|
983
|
+
* floor while the CDP call itself still answers `{}` — clicks and keys that
|
|
984
|
+
* silently do nothing, with no error anywhere. Keep the window's visibility
|
|
985
|
+
* state application-controlled instead; the parent already shows/raises views
|
|
986
|
+
* explicitly, and `webContentsView.setVisible()` remains the single source of
|
|
987
|
+
* truth for whether a view renders.
|
|
988
|
+
*/
|
|
989
|
+
function keepWindowsNonOccluding(): void {
|
|
990
|
+
if (process.platform !== 'win32') return
|
|
991
|
+
const DISABLED = 'CalculateNativeWinOcclusion'
|
|
992
|
+
const current = app.commandLine.getSwitchValue('disable-features') ?? ''
|
|
993
|
+
const features = current
|
|
994
|
+
.split(',')
|
|
995
|
+
.map(part => part.trim())
|
|
996
|
+
.filter(part => part !== '')
|
|
997
|
+
if (!features.includes(DISABLED)) features.push(DISABLED)
|
|
998
|
+
app.commandLine.appendSwitch('disable-features', features.join(','))
|
|
999
|
+
}
|
|
1000
|
+
keepWindowsNonOccluding()
|
|
1001
|
+
|
|
945
1002
|
/**
|
|
946
1003
|
* Electron entry: connect back to the parent's RPC server (port from
|
|
947
1004
|
* `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
|
|
@@ -207,6 +207,12 @@ export interface ElectronViewHandle {
|
|
|
207
207
|
* @returns the CDP `result` object.
|
|
208
208
|
*/
|
|
209
209
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
210
|
+
/**
|
|
211
|
+
* Give the backing view web focus so keyboard input reaches its page.
|
|
212
|
+
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
213
|
+
* then behaves exactly as before.
|
|
214
|
+
*/
|
|
215
|
+
focus?(): Promise<void>
|
|
210
216
|
}
|
|
211
217
|
|
|
212
218
|
/** One tab inside a session: its view plus a stable id. */
|
|
@@ -254,14 +260,16 @@ export interface CdpNavigateParams {
|
|
|
254
260
|
}
|
|
255
261
|
|
|
256
262
|
/**
|
|
257
|
-
* CDP method/params for `Input.dispatchMouseEvent
|
|
263
|
+
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
264
|
+
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
265
|
+
* release halves; a move carries only the position.
|
|
258
266
|
*/
|
|
259
267
|
export interface CdpMouseParams {
|
|
260
|
-
readonly type: 'mousePressed' | 'mouseReleased'
|
|
268
|
+
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
|
|
261
269
|
readonly x: number
|
|
262
270
|
readonly y: number
|
|
263
|
-
readonly button
|
|
264
|
-
readonly clickCount
|
|
271
|
+
readonly button?: 'left'
|
|
272
|
+
readonly clickCount?: number
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
/** CDP method/params for `Input.insertText`. */
|
|
@@ -664,6 +672,26 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
664
672
|
}
|
|
665
673
|
}
|
|
666
674
|
|
|
675
|
+
/**
|
|
676
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
677
|
+
*
|
|
678
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
679
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
680
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
681
|
+
* session vanished inside the renderer while the command still answered
|
|
682
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
683
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
684
|
+
*/
|
|
685
|
+
private async focusView(handle: ElectronViewHandle): Promise<void> {
|
|
686
|
+
if (handle.focus === undefined) return
|
|
687
|
+
try {
|
|
688
|
+
await handle.focus()
|
|
689
|
+
} catch {
|
|
690
|
+
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
691
|
+
// press; the dispatch below reports a real channel failure itself.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
667
695
|
/** Execute JS in the active tab's page context. */
|
|
668
696
|
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
669
697
|
const s = this.session(session)
|
|
@@ -1196,23 +1224,51 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1196
1224
|
// Input.* is only delivered to a view with a current display surface; the
|
|
1197
1225
|
// barrier also re-presents after a navigation replaced the renderer.
|
|
1198
1226
|
await this.present(s, signal)
|
|
1199
|
-
const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
|
|
1200
|
-
handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
|
|
1201
1227
|
const timeoutMs = 30_000
|
|
1228
|
+
const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
|
|
1229
|
+
withTimeout(
|
|
1230
|
+
handle.sendCommand('Input.dispatchMouseEvent', params),
|
|
1231
|
+
timeoutMs,
|
|
1232
|
+
signal,
|
|
1233
|
+
`browser: click ${label} timed out after ${timeoutMs}ms`,
|
|
1234
|
+
)
|
|
1235
|
+
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
1236
|
+
// hover target rather than hit-testing the coordinates, and a real pointer
|
|
1237
|
+
// click is always preceded by movement. A view that has never received a
|
|
1238
|
+
// mouse event has no hover target yet, so the press of the FIRST click on a
|
|
1239
|
+
// fresh tab went nowhere while CDP still answered success; the second click
|
|
1240
|
+
// landed because the first had quietly established the target. Move the
|
|
1241
|
+
// pointer first so a click is self-contained.
|
|
1202
1242
|
try {
|
|
1203
|
-
await
|
|
1243
|
+
await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1246
|
+
}
|
|
1247
|
+
const release = (): void => {
|
|
1248
|
+
void handle
|
|
1249
|
+
.sendCommand('Input.dispatchMouseEvent', {
|
|
1250
|
+
type: 'mouseReleased',
|
|
1251
|
+
x,
|
|
1252
|
+
y,
|
|
1253
|
+
button: 'left',
|
|
1254
|
+
clickCount: 1,
|
|
1255
|
+
} satisfies CdpMouseParams)
|
|
1256
|
+
.catch(() => {})
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
|
|
1204
1260
|
} catch (error) {
|
|
1205
1261
|
// The press may still land late; release best-effort so the button is
|
|
1206
1262
|
// never left in a stuck pressed state.
|
|
1207
|
-
|
|
1263
|
+
release()
|
|
1208
1264
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1209
1265
|
}
|
|
1210
1266
|
try {
|
|
1211
|
-
await
|
|
1267
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
|
|
1212
1268
|
} catch (error) {
|
|
1213
1269
|
// The press already landed; retry the release so the button is not
|
|
1214
1270
|
// left pressed before surfacing the failure.
|
|
1215
|
-
|
|
1271
|
+
release()
|
|
1216
1272
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1217
1273
|
}
|
|
1218
1274
|
this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
|
|
@@ -1375,6 +1431,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1375
1431
|
const timeoutMs = 15_000
|
|
1376
1432
|
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1377
1433
|
await this.present(s, signal)
|
|
1434
|
+
await this.focusView(handle)
|
|
1378
1435
|
const release = (): Promise<Record<string, unknown>> =>
|
|
1379
1436
|
handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
|
|
1380
1437
|
try {
|
|
@@ -1552,10 +1609,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1552
1609
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
1553
1610
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
1554
1611
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
1612
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
1613
|
+
* polled until the budget expires — see the comment in `match`.
|
|
1555
1614
|
*/
|
|
1556
1615
|
private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
|
|
1616
|
+
// `by` defaults to css in-page; report the strategy that WAS used, or a
|
|
1617
|
+
// miss says only `{"value":"Learn more"}` and the caller cannot tell that a
|
|
1618
|
+
// plain label was queried as a CSS selector.
|
|
1619
|
+
const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
|
|
1557
1620
|
return `(async () => {
|
|
1558
|
-
const spec = ${JSON.stringify(
|
|
1621
|
+
const spec = ${JSON.stringify(resolvedSpec)}
|
|
1559
1622
|
const timeoutMs = ${String(timeoutMs)}
|
|
1560
1623
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
1561
1624
|
const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
|
|
@@ -1567,7 +1630,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1567
1630
|
const index = typeof spec.index === 'number' ? spec.index : 0
|
|
1568
1631
|
let els = []
|
|
1569
1632
|
if (by === 'css') {
|
|
1570
|
-
|
|
1633
|
+
// A selector that does not PARSE can never match on a later poll, so
|
|
1634
|
+
// it must not look like a miss: the old "return null" kept the caller
|
|
1635
|
+
// polling until the whole budget was gone, and "element not found
|
|
1636
|
+
// after 10s" reads like a slow page while the real cause is a
|
|
1637
|
+
// malformed selector (a label or plain text passed where one belongs).
|
|
1638
|
+
try { els = Array.from(document.querySelectorAll(value)) }
|
|
1639
|
+
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1571
1640
|
} else if (by === 'xpath') {
|
|
1572
1641
|
try {
|
|
1573
1642
|
const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
@@ -1575,7 +1644,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1575
1644
|
const n = snap.snapshotItem(i)
|
|
1576
1645
|
if (n instanceof Element) els.push(n)
|
|
1577
1646
|
}
|
|
1578
|
-
} catch {
|
|
1647
|
+
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1579
1648
|
} else {
|
|
1580
1649
|
const all = Array.from(document.querySelectorAll('body *'))
|
|
1581
1650
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
@@ -1589,9 +1658,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1589
1658
|
const deadline = Date.now() + timeoutMs
|
|
1590
1659
|
let el = null
|
|
1591
1660
|
for (;;) {
|
|
1592
|
-
|
|
1661
|
+
// Only a parse error escapes the poll: see the comment in match() above.
|
|
1662
|
+
try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
|
|
1593
1663
|
if (el !== null) break
|
|
1594
|
-
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
|
|
1664
|
+
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1595
1665
|
await sleep(100)
|
|
1596
1666
|
}
|
|
1597
1667
|
${body}
|
|
@@ -1611,9 +1681,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1611
1681
|
errorCode: string,
|
|
1612
1682
|
errorLabel: string,
|
|
1613
1683
|
): Promise<Record<string, unknown>> {
|
|
1684
|
+
// The script polls for its whole `timeoutMs` and only then answers, so an
|
|
1685
|
+
// outer budget of the same size fires at the same instant and wins: the
|
|
1686
|
+
// caller saw `click timed out after 10000ms` while the page had already
|
|
1687
|
+
// decided WHY (selector not found / malformed). Give the answer a chance to
|
|
1688
|
+
// get back before the generic timeout replaces it.
|
|
1614
1689
|
const result = await withTimeout(
|
|
1615
1690
|
handleSendEvaluate(tab.handle, script),
|
|
1616
|
-
timeoutMs,
|
|
1691
|
+
timeoutMs + TARGET_SCRIPT_GRACE_MS,
|
|
1617
1692
|
signal,
|
|
1618
1693
|
`${errorLabel} timed out after ${timeoutMs}ms`,
|
|
1619
1694
|
() => terminatePage(tab.handle),
|
|
@@ -1813,7 +1888,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1813
1888
|
const deadline = Date.now() + timeoutMs
|
|
1814
1889
|
let items = []
|
|
1815
1890
|
for (;;) {
|
|
1816
|
-
|
|
1891
|
+
// Immediate, like match() above: an unparseable selector can never match.
|
|
1892
|
+
try { items = Array.from(document.querySelectorAll(itemSel)) }
|
|
1893
|
+
catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
|
|
1817
1894
|
if (items.length > 0) break
|
|
1818
1895
|
if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
|
|
1819
1896
|
await sleep(100)
|
|
@@ -2295,6 +2372,13 @@ const SETTLE_GRACE_MS = 150
|
|
|
2295
2372
|
/** Poll interval for the document-settle loop. */
|
|
2296
2373
|
const SETTLE_POLL_MS = 25
|
|
2297
2374
|
|
|
2375
|
+
/**
|
|
2376
|
+
* Extra time the transport gets over an in-page locate script's own budget: the
|
|
2377
|
+
* script polls for `timeoutMs` and answers only afterwards, so the outer wait
|
|
2378
|
+
* must outlast it or its generic timeout hides the in-page reason.
|
|
2379
|
+
*/
|
|
2380
|
+
const TARGET_SCRIPT_GRACE_MS = 2_000
|
|
2381
|
+
|
|
2298
2382
|
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
2299
2383
|
function stripTabPrefix(id: string): string {
|
|
2300
2384
|
return id.startsWith('tab:') ? id.slice(4) : id
|
|
@@ -657,6 +657,11 @@ class RemoteView implements ElectronViewHandle {
|
|
|
657
657
|
})
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
+
/** Give this view web focus in the child, so keyboard input reaches the page. */
|
|
661
|
+
focus(): Promise<void> {
|
|
662
|
+
return this.client.call<void>('focus', { viewId: this.id })
|
|
663
|
+
}
|
|
664
|
+
|
|
660
665
|
/** Ask the child to download a URL to a local file (keeps cookies/login). */
|
|
661
666
|
async download(url: string, savePath: string): Promise<void> {
|
|
662
667
|
// The child writes the file itself (temp + rename); only a small
|
|
@@ -681,6 +686,15 @@ class RemoteView implements ElectronViewHandle {
|
|
|
681
686
|
}
|
|
682
687
|
}
|
|
683
688
|
|
|
689
|
+
/**
|
|
690
|
+
* How long a FAILED Electron-binary probe is remembered before the binary
|
|
691
|
+
* search runs again (`available()`). A success is kept for the host's lifetime.
|
|
692
|
+
*/
|
|
693
|
+
const PROBE_RETRY_MS = (() => {
|
|
694
|
+
const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS)
|
|
695
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30000
|
|
696
|
+
})()
|
|
697
|
+
|
|
684
698
|
/**
|
|
685
699
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
686
700
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -693,8 +707,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
693
707
|
private readonly views = new Map<string, ElectronViewHandle>()
|
|
694
708
|
private readyPromise: Promise<void> | undefined
|
|
695
709
|
private disposed = false
|
|
696
|
-
/**
|
|
710
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
711
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
697
712
|
private electronAvailable: boolean | undefined
|
|
713
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
714
|
+
private nextProbeAt = 0
|
|
698
715
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
699
716
|
* a restarted child still places views in the right windows. */
|
|
700
717
|
private readonly groups = new Map<string, { windowId: string; label?: string }>()
|
|
@@ -706,27 +723,37 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
706
723
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
707
724
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
708
725
|
private readonly spawnExecutable?: string,
|
|
726
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
727
|
+
* resolveElectronPath(). */
|
|
728
|
+
private readonly probe: () => void = resolveElectronPath,
|
|
709
729
|
) {}
|
|
710
730
|
|
|
711
731
|
/**
|
|
712
732
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
713
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
733
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
734
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
735
|
+
* instead of a confusing spawn failure on first use.
|
|
736
|
+
*
|
|
737
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
738
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
739
|
+
* long after DSH started, and provider selection happens once per process —
|
|
740
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
741
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
742
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
717
743
|
*/
|
|
718
744
|
available(): boolean {
|
|
719
|
-
if (this.electronAvailable ===
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
745
|
+
if (this.electronAvailable === true) return true
|
|
746
|
+
if (Date.now() < this.nextProbeAt) return false
|
|
747
|
+
this.nextProbeAt = Date.now() + PROBE_RETRY_MS
|
|
748
|
+
try {
|
|
749
|
+
this.probe()
|
|
750
|
+
this.electronAvailable = true
|
|
751
|
+
} catch (error) {
|
|
752
|
+
this.electronAvailable = false
|
|
753
|
+
// The provider will be reported unavailable, so the detailed
|
|
754
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
755
|
+
// otherwise never reach the user — surface it on stderr.
|
|
756
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
730
757
|
}
|
|
731
758
|
return this.electronAvailable
|
|
732
759
|
}
|
|
@@ -1027,6 +1054,10 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1027
1054
|
return this.withRecovery(view => view.capture(opts))
|
|
1028
1055
|
}
|
|
1029
1056
|
|
|
1057
|
+
async focus(): Promise<void> {
|
|
1058
|
+
return this.withRecovery(view => view.focus())
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1030
1061
|
async flushAuth(): Promise<ExportedCookie[]> {
|
|
1031
1062
|
return this.withRecovery(view => view.flushAuth())
|
|
1032
1063
|
}
|