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.
- package/README.en.md +22 -10
- package/README.md +22 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser/runtime.js +9 -1
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +157 -15
- package/lib/browser-electron/provider.d.ts +107 -12
- package/lib/browser-electron/provider.js +340 -50
- package/lib/browser-electron/remote-host.d.ts +46 -6
- package/lib/browser-electron/remote-host.js +147 -33
- package/lib/tool-browser/index.js +2 -2
- package/package.json +8 -6
- package/src/browser/runtime.ts +15 -1
- package/src/browser-electron/host-main.ts +163 -9
- package/src/browser-electron/provider.ts +385 -55
- package/src/browser-electron/remote-host.ts +149 -31
- package/src/tool-browser/index.ts +2 -2
|
@@ -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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () => {
|
|
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
|
|
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
|
-
|
|
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`)
|