dsh-builtin-browser 0.1.21 → 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.
@@ -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)
@@ -32,19 +33,19 @@ import { dirname, join } from 'node:path';
32
33
  * The spawn token the parent sent via stdin (first line). Must be echoed in
33
34
  * our hello. Read from stdin so the token never appears in argv (WMI /
34
35
  * Process Explorer / /proc/*).
35
-
36
+ */
36
37
  // Isolate this host's profile from the DSH app's default Electron userData:
37
38
  // several Electron instances sharing Roaming\Electron fight over the GPU
38
39
  // cache/session locks, which can leave the window without a display surface
39
40
  // (capturePage then fails). A dedicated userData also persists cookies across
40
41
  // host restarts (on top of browser_auth). Must run before app is ready.
41
42
  try {
42
- const base = process.env.DSH_HOME ?? app.getPath('appData')
43
- app.setPath('userData', join(base, 'dsh-builtin-browser-host'))
44
- } catch (error) {
45
- process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`)
43
+ const base = process.env.DSH_HOME ?? app.getPath('appData');
44
+ app.setPath('userData', join(base, 'dsh-builtin-browser-host'));
45
+ }
46
+ catch (error) {
47
+ process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`);
46
48
  }
47
-
48
49
  /**
49
50
  * Read the spawn token. The parent sends it two ways and the child prefers
50
51
  * the first that yields a value:
@@ -141,7 +142,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
141
142
  <div class="strip" id="strip"></div>
142
143
  <div id="err"></div>
143
144
  <script>
144
- const bridge = window.bridge
145
+ // The body runs inside an IIFE on purpose. The preload above exposes
146
+ // window.bridge via contextBridge.exposeInMainWorld(), which installs a
147
+ // NON-CONFIGURABLE global property. A top-level const/let named bridge here
148
+ // would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
149
+ // — an early error, so the whole script is discarded: nothing gets bound, the
150
+ // address bar and nav buttons go silently dead, and the user sees no error.
151
+ // Function scope sidesteps that check (the tb name below stops the collision
152
+ // from returning by habit) and keeps these bindings off the page global.
153
+ // Keep this block free of backticks — it lives inside a TS template literal.
154
+ (() => {
155
+ const tb = window.bridge
145
156
  const addr = document.getElementById('addr')
146
157
  const strip = document.getElementById('strip')
147
158
  const errBox = document.getElementById('err')
@@ -152,7 +163,7 @@ function showErr(text) {
152
163
  clearTimeout(showErrT)
153
164
  showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
154
165
  }
155
- function post(action, payload) { bridge.post(action, payload || {}) }
166
+ function post(action, payload) { tb.post(action, payload || {}) }
156
167
  document.getElementById('back').onclick = () => post('back')
157
168
  document.getElementById('fwd').onclick = () => post('forward')
158
169
  document.getElementById('reload').onclick = () => post('reload')
@@ -167,7 +178,7 @@ addr.addEventListener('keydown', e => {
167
178
  if (v !== '') { post('navigate', { url: v }); addr.blur() }
168
179
  }
169
180
  })
170
- bridge.onTabs(payload => {
181
+ tb.onTabs(payload => {
171
182
  const tabs = (payload && payload.tabs) || []
172
183
  strip.textContent = ''
173
184
  for (const t of tabs) {
@@ -189,7 +200,8 @@ bridge.onTabs(payload => {
189
200
  const act = tabs.find(t => t.active)
190
201
  if (act && document.activeElement !== addr) addr.value = act.url || ''
191
202
  })
192
- bridge.onError(text => showErr(text))
203
+ tb.onError(text => showErr(text))
204
+ })()
193
205
  </script>
194
206
  </body>
195
207
  </html>
@@ -206,6 +218,49 @@ function ensureToolbarFiles() {
206
218
  writeFileSync(join(TOOLBAR_DIR, 'preload.js'), TOOLBAR_PRELOAD);
207
219
  writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML);
208
220
  }
221
+ /**
222
+ * Bound an await with a timeout. Used so a wedged renderer surfaces as an
223
+ * explicit error instead of a command that never settles.
224
+ */
225
+ function withTimeout(promise, ms, label) {
226
+ return new Promise((resolve, reject) => {
227
+ const timer = setTimeout(() => {
228
+ reject(new Error(`${label} timed out after ${ms}ms`));
229
+ }, ms);
230
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
231
+ });
232
+ }
233
+ /**
234
+ * CDP commands are bounded here as well as in the parent: a view whose
235
+ * renderer stopped responding must produce a clear error, not a hang that
236
+ * only the tool layer's 10–30s timeout eventually cuts off.
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;
246
+ /**
247
+ * A brand-new WebContentsView has no renderer process until something loads,
248
+ * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
249
+ * settle. That is what made a host restart unrecoverable: the provider
250
+ * rebuilds the session with a fresh blank view whose very first action is a
251
+ * location.href read, which hung until the tool timeout.
252
+ *
253
+ * Loading about:blank spawns the renderer, so the view answers the moment it
254
+ * exists; a real navigation replaces it right after. Bounded + best-effort —
255
+ * the caller still gets its reply if this fails.
256
+ */
257
+ const RENDERER_PRIME_TIMEOUT_MS = 3000;
258
+ async function primeRenderer(view) {
259
+ try {
260
+ await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime');
261
+ }
262
+ catch { /* best effort: the first real navigation creates the renderer */ }
263
+ }
209
264
  /** Reply to the parent over the RPC socket. */
210
265
  function reply(id, payload) {
211
266
  if (rpcSocket === undefined) {
@@ -434,7 +489,19 @@ function showViewInWindow(win, viewId, label) {
434
489
  const entry = win.views.get(viewId);
435
490
  if (entry === undefined)
436
491
  return;
437
- if (win.visibleViewId !== viewId) {
492
+ // A navigation replaced this view's renderer, and the new one has no
493
+ // display surface yet. Chromium drops CDP synthesized input (Input.*) for a
494
+ // view without a surface, which silently swallowed the FIRST click/keystroke
495
+ // after every navigation — and because the view was already the visible one,
496
+ // the dance below used to be skipped entirely. Force it once.
497
+ const mustRepresent = entry.needsRepresent;
498
+ if (mustRepresent) {
499
+ try {
500
+ entry.webContentsView.setVisible(false);
501
+ }
502
+ catch { /* destroyed */ }
503
+ }
504
+ if (win.visibleViewId !== viewId || mustRepresent) {
438
505
  // Hide every other view in THIS window, then show and RAISE the target
439
506
  // (topmost child wins). When the target is already visible, skip the
440
507
  // remove/re-add dance — doing it on every operation flickered.
@@ -453,6 +520,7 @@ function showViewInWindow(win, viewId, label) {
453
520
  }
454
521
  catch { /* window closing */ }
455
522
  win.visibleViewId = viewId;
523
+ entry.needsRepresent = false;
456
524
  syncToolbar(win);
457
525
  }
458
526
  // Always refresh the title: it reflects the CURRENT page of the visible
@@ -551,11 +619,19 @@ async function handle(op, msg) {
551
619
  // New views start hidden: only the shown one may be visible.
552
620
  view.setVisible(false);
553
621
  win.window.contentView.addChildView(view);
554
- win.views.set(viewId, { webContentsView: view });
622
+ win.views.set(viewId, { webContentsView: view, needsRepresent: false });
555
623
  // Keep the window title and the toolbar tab strip live as the page
556
624
  // changes (navigations, title updates).
557
625
  view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win); });
558
- view.webContents.on('did-navigate', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win); });
626
+ view.webContents.on('did-navigate', () => {
627
+ // The renderer process was just replaced: mark the view so the next
628
+ // show forces a fresh display surface (see showViewInWindow).
629
+ const current = win.views.get(viewId);
630
+ if (current !== undefined)
631
+ current.needsRepresent = true;
632
+ updateWindowTitle(win, viewId, undefined);
633
+ syncToolbar(win);
634
+ });
559
635
  view.webContents.on('did-navigate-in-page', () => syncToolbar(win));
560
636
  wireFocusRouting(win, view, viewId);
561
637
  const first = win.views.size === 1;
@@ -567,6 +643,10 @@ async function handle(op, msg) {
567
643
  if (first)
568
644
  updateWindowTitle(win, viewId, undefined);
569
645
  syncToolbar(win);
646
+ // Prime the renderer BEFORE replying: the parent's next command
647
+ // (a location.href read) would otherwise hang on a view that has no
648
+ // renderer yet — the root of "host restarted and never recovers".
649
+ await primeRenderer(view);
570
650
  reply(msg.id, { ok: true });
571
651
  return;
572
652
  }
@@ -649,10 +729,33 @@ async function handle(op, msg) {
649
729
  const method = msg.method;
650
730
  if (typeof method !== 'string')
651
731
  throw new Error('command missing method');
652
- const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
732
+ const result = await withTimeout(entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}), COMMAND_TIMEOUT_MS, `CDP ${method}`);
653
733
  reply(msg.id, { ok: true, result });
654
734
  return;
655
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
+ }
656
759
  case 'capture': {
657
760
  const viewId = msg.viewId;
658
761
  if (viewId === undefined)
@@ -925,6 +1028,35 @@ async function handle(op, msg) {
925
1028
  reply(msg.id, { ok: false, err: String(error) });
926
1029
  }
927
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();
928
1060
  /**
929
1061
  * Electron entry: connect back to the parent's RPC server (port from
930
1062
  * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
@@ -965,6 +1097,13 @@ void app.whenReady().then(() => {
965
1097
  }
966
1098
  }
967
1099
  const rl = createInterface({ input: socket });
1100
+ // Handle messages STRICTLY in order. Ops depend on each other completing:
1101
+ // a `command` for a view must not overtake the `createView` that makes it,
1102
+ // and the parent's present-barrier (`showView` then `ping`) is only
1103
+ // meaningful if the child cannot answer the ping mid-show. The previous
1104
+ // fire-and-forget loop let all of them race, which is how a fresh tab's
1105
+ // first click could be dispatched before its view was ever on screen.
1106
+ let queue = Promise.resolve();
968
1107
  rl.on('line', line => {
969
1108
  const text = line.trim();
970
1109
  if (text === '')
@@ -978,7 +1117,10 @@ void app.whenReady().then(() => {
978
1117
  }
979
1118
  if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
980
1119
  return;
981
- void handle(msg.op, msg).catch(() => { });
1120
+ // Bind the narrowed op now: the check above does not survive into the
1121
+ // deferred callback (msg is a mutable binding).
1122
+ const op = msg.op;
1123
+ queue = queue.then(() => handle(op, msg)).catch(() => { });
982
1124
  });
983
1125
  socket.on('error', error => {
984
1126
  process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
@@ -37,6 +37,20 @@ export interface ElectronBrowserViewHost {
37
37
  * tell which task's page is currently visible.
38
38
  */
39
39
  showView?(handle: ElectronViewHandle, label?: string): void;
40
+ /**
41
+ * Optional: (re)present a view and WAIT until the host confirms it. Chromium
42
+ * silently drops CDP-synthesized input (`Input.*`) for a view that has no
43
+ * display surface, which made clicks/typing report success while the page
44
+ * received nothing (and made the first input after a navigation vanish,
45
+ * because the replaced renderer has no surface yet). Unlike
46
+ * {@link showView}, this round-trips an RPC barrier that is ordered AFTER
47
+ * the show on the same socket, so its reply means the view is on screen.
48
+ * Rejects when the view cannot be presented; a host without this method is
49
+ * assumed to present every view (headless/probe hosts).
50
+ * @param handle - the handle to present.
51
+ * @param label - human-readable session/task label, as in {@link showView}.
52
+ */
53
+ presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
40
54
  /**
41
55
  * Optional cheap usability probe (no network): whether the host can back
42
56
  * views at all right now. The self-hosted host checks for a usable Electron
@@ -112,6 +126,12 @@ export interface ElectronViewHandle {
112
126
  * @returns the CDP `result` object.
113
127
  */
114
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>;
115
135
  }
116
136
  /** Provider config: navigation admission defaults and snapshot caps. */
117
137
  export interface ElectronBrowserProviderConfig {
@@ -137,14 +157,16 @@ export interface CdpNavigateParams {
137
157
  readonly url: string;
138
158
  }
139
159
  /**
140
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
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.
141
163
  */
142
164
  export interface CdpMouseParams {
143
- readonly type: 'mousePressed' | 'mouseReleased';
165
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
144
166
  readonly x: number;
145
167
  readonly y: number;
146
- readonly button: 'left';
147
- readonly clickCount: number;
168
+ readonly button?: 'left';
169
+ readonly clickCount?: number;
148
170
  }
149
171
  /** CDP method/params for `Input.insertText`. */
150
172
  export interface CdpInsertTextParams {
@@ -226,6 +248,55 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
226
248
  navigate(session: BrowserSessionId, request: {
227
249
  readonly url: string;
228
250
  }, signal?: AbortSignal): Promise<void>;
251
+ /**
252
+ * A per-document stamp: `performance.timeOrigin` is unique per document load,
253
+ * so it tells a same-URL reload and an A→B→A redirect apart from the document
254
+ * that was current before the navigation. Empty string when unreadable.
255
+ */
256
+ private documentStamp;
257
+ /**
258
+ * One cheap in-page reading of the document's identity and parse state.
259
+ *
260
+ * Returns null when the page did NOT answer (mid-commit, execution context
261
+ * destroyed) — the caller keeps waiting. A page that answered with an
262
+ * unexpected shape (an override or a non-conforming host; the production
263
+ * expression always yields a string) is reported as `unknown` rather than as
264
+ * "no answer": blocking the navigation for the whole budget on a page that
265
+ * demonstrably responded has no upside.
266
+ */
267
+ private documentProbe;
268
+ /**
269
+ * Page.navigate resolves at navigation COMMIT, not at load: the new document
270
+ * is already current (so location.href and document.title are the new page's)
271
+ * while its DOM is still being parsed. browser_open snapshots immediately
272
+ * after navigating, which is why it could report the right title with zero
273
+ * interactive elements — and why a separate browser_snapshot right after
274
+ * always found them.
275
+ *
276
+ * Wait — bounded, best-effort — for the new document to settle: readyState
277
+ * must reach interactive/complete AND the document identity must have
278
+ * changed. A page that never settles must not fail a navigation that already
279
+ * succeeded, so a timeout here is swallowed.
280
+ */
281
+ private settleDocument;
282
+ /**
283
+ * Make sure the active tab's view can actually receive synthesized input.
284
+ * Chromium drops `Input.*` events for a view with no display surface, so this
285
+ * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
286
+ * rather than reporting a success the page never saw.
287
+ */
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;
229
300
  /** Execute JS in the active tab's page context. */
230
301
  execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
231
302
  /**
@@ -290,6 +361,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
290
361
  * (polling until it appears or the budget runs out) and then runs `body`
291
362
  * with `el` in scope. Shared by the target-based tools: click/type (②),
292
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`.
293
366
  */
294
367
  private buildTargetScript;
295
368
  /**
@@ -320,13 +393,29 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
320
393
  */
321
394
  scrape(session: BrowserSessionId, request: BrowserScrapeRequest, signal?: AbortSignal): Promise<BrowserScrapeResult>;
322
395
  /**
323
- * Download a URL to a local file, keeping the session's cookies/login.
324
- * Requires the self-hosted host (which implements view-level download); the
325
- * desktop shell's embedded views delegate downloads to the real browser UI.
326
- * Admission: only HTTP(S) targets (the in-page fetch cannot meaningfully
327
- * fetch anything else), and the save path must be absolute — confined to
328
- * `downloadDir` when one is configured, so a prompt-injected agent cannot
329
- * write arbitrary machine paths.
396
+ * Admit a caller-supplied save path for a file the browser writes to disk.
397
+ * ONE gate for both `browser_download` and `browser_screenshot`: the path
398
+ * must be absolute, must resolve inside `downloadDir`, and must not already
399
+ * exist. Without it a prompt-injected agent could write — or silently
400
+ * replace — any file the DSH process can reach, which also escapes the file
401
+ * sandbox every other tool in the set runs under.
402
+ * @param savePath - the caller's target path.
403
+ * @param kind - the operation, used in the error code and message.
404
+ * @returns the resolved absolute target path.
405
+ * @throws BrowserError when the path is relative, outside the directory, or occupied.
406
+ */
407
+ private admitSavePath;
408
+ /**
409
+ * Download an HTTP(S) URL with the session's cookies to a local file.
410
+ * Admission is the shared {@link admitSavePath} gate; only the self-hosted
411
+ * host implements it (the desktop shell's embedded views delegate downloads
412
+ * to the real browser UI). Both admitted paths and screenshots are confined
413
+ * to `downloadDir`, so a prompt-injected agent cannot write arbitrary
414
+ * machine paths.
415
+ * @param session - the session whose cookies are used.
416
+ * @param request - the URL plus the absolute target path.
417
+ * @param signal - optional cancellation.
418
+ * @returns the path the file was written to.
330
419
  */
331
420
  download(session: BrowserSessionId, request: {
332
421
  readonly url: string;
@@ -346,7 +435,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
346
435
  readonly dataUrl: string;
347
436
  readonly path?: string;
348
437
  }>;
349
- /** Build the data URL and optionally write the image to disk. */
438
+ /**
439
+ * Build the data URL and optionally write the image to disk. The caller's
440
+ * path goes through the SAME {@link admitSavePath} gate as a download — it
441
+ * must be absolute, resolve inside `downloadDir`, and not be an existing
442
+ * file — so a screenshot cannot be used to write to, or silently replace,
443
+ * files anywhere the DSH process happens to have permission (issue #13).
444
+ */
350
445
  private saveScreenshot;
351
446
  /** Append one operation to the session's history. */
352
447
  private record;