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)
@@ -34,6 +35,7 @@ import { dirname, join } from 'node:path'
34
35
  * The spawn token the parent sent via stdin (first line). Must be echoed in
35
36
  * our hello. Read from stdin so the token never appears in argv (WMI /
36
37
  * Process Explorer / /proc/*).
38
+ */
37
39
 
38
40
  // Isolate this host's profile from the DSH app's default Electron userData:
39
41
  // several Electron instances sharing Roaming\Electron fight over the GPU
@@ -85,6 +87,14 @@ const TOOLBAR_HEIGHT = 64
85
87
  /** One view: the Electron object plus its CDP-backed surface. */
86
88
  interface HostView {
87
89
  readonly webContentsView: WebContentsView
90
+ /**
91
+ * Set when the view's renderer process was replaced by a navigation. The
92
+ * fresh renderer has no display surface, and Chromium silently drops CDP
93
+ * synthesized input for a view without one — so the next show/input must
94
+ * force the visible-again dance even if this view is already the visible
95
+ * one. Cleared once the surface has been re-established.
96
+ */
97
+ needsRepresent: boolean
88
98
  }
89
99
 
90
100
  /** One window: a session's views + its toolbar. */
@@ -176,7 +186,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
176
186
  <div class="strip" id="strip"></div>
177
187
  <div id="err"></div>
178
188
  <script>
179
- const bridge = window.bridge
189
+ // The body runs inside an IIFE on purpose. The preload above exposes
190
+ // window.bridge via contextBridge.exposeInMainWorld(), which installs a
191
+ // NON-CONFIGURABLE global property. A top-level const/let named bridge here
192
+ // would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
193
+ // — an early error, so the whole script is discarded: nothing gets bound, the
194
+ // address bar and nav buttons go silently dead, and the user sees no error.
195
+ // Function scope sidesteps that check (the tb name below stops the collision
196
+ // from returning by habit) and keeps these bindings off the page global.
197
+ // Keep this block free of backticks — it lives inside a TS template literal.
198
+ (() => {
199
+ const tb = window.bridge
180
200
  const addr = document.getElementById('addr')
181
201
  const strip = document.getElementById('strip')
182
202
  const errBox = document.getElementById('err')
@@ -187,7 +207,7 @@ function showErr(text) {
187
207
  clearTimeout(showErrT)
188
208
  showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
189
209
  }
190
- function post(action, payload) { bridge.post(action, payload || {}) }
210
+ function post(action, payload) { tb.post(action, payload || {}) }
191
211
  document.getElementById('back').onclick = () => post('back')
192
212
  document.getElementById('fwd').onclick = () => post('forward')
193
213
  document.getElementById('reload').onclick = () => post('reload')
@@ -202,7 +222,7 @@ addr.addEventListener('keydown', e => {
202
222
  if (v !== '') { post('navigate', { url: v }); addr.blur() }
203
223
  }
204
224
  })
205
- bridge.onTabs(payload => {
225
+ tb.onTabs(payload => {
206
226
  const tabs = (payload && payload.tabs) || []
207
227
  strip.textContent = ''
208
228
  for (const t of tabs) {
@@ -224,7 +244,8 @@ bridge.onTabs(payload => {
224
244
  const act = tabs.find(t => t.active)
225
245
  if (act && document.activeElement !== addr) addr.value = act.url || ''
226
246
  })
227
- bridge.onError(text => showErr(text))
247
+ tb.onError(text => showErr(text))
248
+ })()
228
249
  </script>
229
250
  </body>
230
251
  </html>
@@ -243,6 +264,56 @@ function ensureToolbarFiles(): void {
243
264
  writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML)
244
265
  }
245
266
 
267
+ /**
268
+ * Bound an await with a timeout. Used so a wedged renderer surfaces as an
269
+ * explicit error instead of a command that never settles.
270
+ */
271
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
272
+ return new Promise<T>((resolve, reject) => {
273
+ const timer = setTimeout(() => {
274
+ reject(new Error(`${label} timed out after ${ms}ms`))
275
+ }, ms)
276
+ promise.then(
277
+ (value) => { clearTimeout(timer); resolve(value) },
278
+ (error) => { clearTimeout(timer); reject(error) },
279
+ )
280
+ })
281
+ }
282
+
283
+ /**
284
+ * CDP commands are bounded here as well as in the parent: a view whose
285
+ * renderer stopped responding must produce a clear error, not a hang that
286
+ * only the tool layer's 10–30s timeout eventually cuts off.
287
+ */
288
+ const COMMAND_TIMEOUT_MS = 20000
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
+
298
+ /**
299
+ * A brand-new WebContentsView has no renderer process until something loads,
300
+ * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
301
+ * settle. That is what made a host restart unrecoverable: the provider
302
+ * rebuilds the session with a fresh blank view whose very first action is a
303
+ * location.href read, which hung until the tool timeout.
304
+ *
305
+ * Loading about:blank spawns the renderer, so the view answers the moment it
306
+ * exists; a real navigation replaces it right after. Bounded + best-effort —
307
+ * the caller still gets its reply if this fails.
308
+ */
309
+ const RENDERER_PRIME_TIMEOUT_MS = 3000
310
+
311
+ async function primeRenderer(view: WebContentsView): Promise<void> {
312
+ try {
313
+ await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime')
314
+ } catch { /* best effort: the first real navigation creates the renderer */ }
315
+ }
316
+
246
317
  /** Reply to the parent over the RPC socket. */
247
318
  function reply(id: number, payload: Record<string, unknown>): void {
248
319
  if (rpcSocket === undefined) {
@@ -449,7 +520,16 @@ function updateWindowTitle(win: HostWindow, viewId: string | undefined, label: s
449
520
  function showViewInWindow(win: HostWindow, viewId: string, label?: string): void {
450
521
  const entry = win.views.get(viewId)
451
522
  if (entry === undefined) return
452
- if (win.visibleViewId !== viewId) {
523
+ // A navigation replaced this view's renderer, and the new one has no
524
+ // display surface yet. Chromium drops CDP synthesized input (Input.*) for a
525
+ // view without a surface, which silently swallowed the FIRST click/keystroke
526
+ // after every navigation — and because the view was already the visible one,
527
+ // the dance below used to be skipped entirely. Force it once.
528
+ const mustRepresent = entry.needsRepresent
529
+ if (mustRepresent) {
530
+ try { entry.webContentsView.setVisible(false) } catch { /* destroyed */ }
531
+ }
532
+ if (win.visibleViewId !== viewId || mustRepresent) {
453
533
  // Hide every other view in THIS window, then show and RAISE the target
454
534
  // (topmost child wins). When the target is already visible, skip the
455
535
  // remove/re-add dance — doing it on every operation flickered.
@@ -463,6 +543,7 @@ function showViewInWindow(win: HostWindow, viewId: string, label?: string): void
463
543
  win.window.contentView.addChildView(entry.webContentsView)
464
544
  } catch { /* window closing */ }
465
545
  win.visibleViewId = viewId
546
+ entry.needsRepresent = false
466
547
  syncToolbar(win)
467
548
  }
468
549
  // Always refresh the title: it reflects the CURRENT page of the visible
@@ -549,11 +630,18 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
549
630
  // New views start hidden: only the shown one may be visible.
550
631
  view.setVisible(false)
551
632
  win.window.contentView.addChildView(view)
552
- win.views.set(viewId, { webContentsView: view })
633
+ win.views.set(viewId, { webContentsView: view, needsRepresent: false })
553
634
  // Keep the window title and the toolbar tab strip live as the page
554
635
  // changes (navigations, title updates).
555
636
  view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
556
- view.webContents.on('did-navigate', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
637
+ view.webContents.on('did-navigate', () => {
638
+ // The renderer process was just replaced: mark the view so the next
639
+ // show forces a fresh display surface (see showViewInWindow).
640
+ const current = win.views.get(viewId)
641
+ if (current !== undefined) current.needsRepresent = true
642
+ updateWindowTitle(win, viewId, undefined)
643
+ syncToolbar(win)
644
+ })
557
645
  view.webContents.on('did-navigate-in-page', () => syncToolbar(win))
558
646
  wireFocusRouting(win, view, viewId)
559
647
  const first = win.views.size === 1
@@ -564,6 +652,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
564
652
  layoutWindow(win)
565
653
  if (first) updateWindowTitle(win, viewId, undefined)
566
654
  syncToolbar(win)
655
+ // Prime the renderer BEFORE replying: the parent's next command
656
+ // (a location.href read) would otherwise hang on a view that has no
657
+ // renderer yet — the root of "host restarted and never recovers".
658
+ await primeRenderer(view)
567
659
  reply(msg.id, { ok: true })
568
660
  return
569
661
  }
@@ -623,10 +715,34 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
623
715
  if (win === undefined || entry === undefined) throw new Error(`command: unknown view ${viewId}`)
624
716
  const method = msg.method
625
717
  if (typeof method !== 'string') throw new Error('command missing method')
626
- const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {})
718
+ const result = await withTimeout(
719
+ entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}),
720
+ COMMAND_TIMEOUT_MS,
721
+ `CDP ${method}`,
722
+ )
627
723
  reply(msg.id, { ok: true, result })
628
724
  return
629
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
+ }
630
746
  case 'capture': {
631
747
  const viewId = msg.viewId
632
748
  if (viewId === undefined) throw new Error('capture missing viewId')
@@ -855,6 +971,34 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
855
971
  }
856
972
  }
857
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
+
858
1002
  /**
859
1003
  * Electron entry: connect back to the parent's RPC server (port from
860
1004
  * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
@@ -894,6 +1038,13 @@ void app.whenReady().then(() => {
894
1038
  }
895
1039
  }
896
1040
  const rl = createInterface({ input: socket })
1041
+ // Handle messages STRICTLY in order. Ops depend on each other completing:
1042
+ // a `command` for a view must not overtake the `createView` that makes it,
1043
+ // and the parent's present-barrier (`showView` then `ping`) is only
1044
+ // meaningful if the child cannot answer the ping mid-show. The previous
1045
+ // fire-and-forget loop let all of them race, which is how a fresh tab's
1046
+ // first click could be dispatched before its view was ever on screen.
1047
+ let queue: Promise<void> = Promise.resolve()
897
1048
  rl.on('line', line => {
898
1049
  const text = line.trim()
899
1050
  if (text === '') return
@@ -904,7 +1055,10 @@ void app.whenReady().then(() => {
904
1055
  return // non-protocol noise
905
1056
  }
906
1057
  if (typeof msg.id !== 'number' || typeof msg.op !== 'string') return
907
- void handle(msg.op, msg).catch(() => { /* reply already sent inside handle */ })
1058
+ // Bind the narrowed op now: the check above does not survive into the
1059
+ // deferred callback (msg is a mutable binding).
1060
+ const op = msg.op
1061
+ queue = queue.then(() => handle(op, msg)).catch(() => { /* reply already sent inside handle */ })
908
1062
  })
909
1063
  socket.on('error', error => {
910
1064
  process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)