dsh-builtin-browser 0.4.1 → 0.4.5

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -48,6 +48,140 @@ function endpointFile() {
48
48
  /** One CDP command against a guest, serialized per guest. */
49
49
  const queues = new Map()
50
50
 
51
+ /**
52
+ * Which owner holds which sidebar guest.
53
+ *
54
+ * The sidebar is a single surface shared by every DSH session, and each session runs in its own
55
+ * plugin process with its own in-memory view map. A session could therefore only see its OWN
56
+ * claims, and would adopt any tab it found — including one another session had opened. The
57
+ * symptom was a URL opened in one session appearing in another's sidebar, with the first
58
+ * session reporting failure.
59
+ *
60
+ * The ledger lives here because this process is the one that owns the sidebar, so it is the only
61
+ * place where "who holds what" can be a single shared fact. Owners are per-process ids supplied
62
+ * by the plugin; an entry is dropped when its guest disappears so a closed tab can be reused.
63
+ */
64
+ const claims = new Map()
65
+
66
+ /**
67
+ * When this module was loaded, and the protocol generation it speaks.
68
+ *
69
+ * The bridge is imported once by `main.js` at boot, so editing the file changes nothing until the
70
+ * host process restarts. That makes "did my restart actually load the new bridge?" a real question
71
+ * — and a run that cannot answer it will blame the new code for the old code's behaviour. The
72
+ * acceptance script compares this against the file's mtime and says so before testing anything.
73
+ */
74
+ const LOADED_AT = new Date().toISOString()
75
+ const PROTOCOL = 2
76
+
77
+ /**
78
+ * The DOM root of THIS conversation's sidebar, as JavaScript to embed in a page script.
79
+ *
80
+ * The shell mounts one sidebar per conversation. Only one is on screen, but every conversation's
81
+ * panel is addressable — each carries its conversation id on the React fiber of its tab strip
82
+ * (measured on the running app: two containers, two ids). So an operation belongs to the panel of
83
+ * the conversation that asked, and NOT to whatever happens to be on screen.
84
+ *
85
+ * That distinction is the whole bug: driving "whatever is on screen" meant an operation issued
86
+ * while the human read another conversation typed into THAT conversation's address bar and
87
+ * navigated its page. Scoping every query to this root is what makes sessions unable to interfere
88
+ * with each other — there is no path from here to somebody else's panel.
89
+ *
90
+ * @param sessionId - the conversation this process serves (the request's `owner`).
91
+ * @returns an expression evaluating to the panel element, or null when there is none.
92
+ */
93
+ function panelRootExpression(sessionId) {
94
+ return `(() => {
95
+ const sessionOf = (el) => {
96
+ const key = Object.keys(el).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
97
+ if (key === undefined) return null;
98
+ let fiber = el[key];
99
+ let depth = 0;
100
+ while (fiber !== null && fiber !== undefined && depth < 60) {
101
+ const props = fiber.memoizedProps;
102
+ if (props !== null && typeof props === 'object' && typeof props.sessionId === 'string') return props.sessionId;
103
+ fiber = fiber.return;
104
+ depth += 1;
105
+ }
106
+ return null;
107
+ };
108
+ const wanted = ${JSON.stringify(String(sessionId))};
109
+ const mine = Array.from(document.querySelectorAll('[class*=_tabStrip]')).find(el => sessionOf(el) === wanted);
110
+ if (mine === undefined) return null;
111
+ return mine.closest('[class*=_tabHost]') ?? mine.parentElement ?? mine;
112
+ })()`
113
+ }
114
+
115
+ /**
116
+ * Run a page script against THIS conversation's panel.
117
+ *
118
+ * Rather than editing ten DOM queries to use a different root — and leaving the eleventh to be
119
+ * forgotten — the document-level lookups are redirected for the duration of the script. Every
120
+ * existing query then answers from the right panel without knowing it, and a script that would
121
+ * have reached somebody else's panel finds nothing instead.
122
+ *
123
+ * The redirection is synchronous and restored in a `finally`, so React's own event handlers (which
124
+ * capture their references) are unaffected.
125
+ *
126
+ * @param shellId - the shell window's webContents id.
127
+ * @param expression - the page script to run.
128
+ * @param sessionId - the conversation this process serves.
129
+ * @returns the CDP answer, or a synthetic NO_PANEL verdict.
130
+ */
131
+ async function evaluateInPanel(shellId, sessionId, method, params) {
132
+ // Only a real conversation id can be matched against the shell's panels. A host that could not
133
+ // supply one keeps the previous, unscoped behaviour: losing the scoping beats losing every DOM
134
+ // path, and the ownership guard treats such a host the same way.
135
+ if (typeof sessionId !== 'string' || !sessionId.startsWith('session-')) {
136
+ return sendCdp(shellId, method, params)
137
+ }
138
+ if (method !== 'Runtime.evaluate' || typeof params?.expression !== 'string') {
139
+ return sendCdp(shellId, method, params)
140
+ }
141
+ const body = params.expression
142
+ const script = `(() => {
143
+ const panel = ${panelRootExpression(sessionId)};
144
+ if (panel === null) return JSON.stringify({ noPanel: true });
145
+ const realQuery = document.querySelector.bind(document);
146
+ const realQueryAll = document.querySelectorAll.bind(document);
147
+ document.querySelector = (selector) => panel.querySelector(selector);
148
+ document.querySelectorAll = (selector) => panel.querySelectorAll(selector);
149
+ try {
150
+ return (${body});
151
+ } finally {
152
+ document.querySelector = realQuery;
153
+ document.querySelectorAll = realQueryAll;
154
+ }
155
+ })()`
156
+ const shell = guestById(shellId)
157
+ if (shell === undefined || shell.isDestroyed()) return { result: { value: 'NO_SHELL' } }
158
+ // `executeJavaScript`, for the same measured reason as `evaluatePanelService`: the debugger
159
+ // attachment to the shell WINDOW hangs while the same channel to a guest page answers at once.
160
+ // These calls are the same kind — one synchronous page script whose answer is a string — so none
161
+ // of them needs the protocol. The reply keeps the CDP shape because every caller reads
162
+ // `answer.result.value`.
163
+ let value
164
+ try {
165
+ value = await shell.executeJavaScript(script, true)
166
+ } catch (error) {
167
+ return { result: { value: '' } }
168
+ }
169
+ // `typeof` below already tolerates an absent value; the fallback keeps the shape explicit for
170
+ // the scanner in tests/bridge-reply-shape.test.mjs, which exists because a bare read of a reply
171
+ // once made a probe answer `undefined` forever.
172
+ const text = typeof value === 'string' ? value : ''
173
+ if (text.includes('"noPanel":true')) {
174
+ return { result: { value: 'NO_PANEL' } }
175
+ }
176
+ return { result: { value: text } }
177
+ }
178
+
179
+ /** Forget claims whose guest no longer exists, so a closed tab's id is not held forever. */
180
+ function pruneClaims() {
181
+ const live = new Set(webContents.getAllWebContents().filter(c => c.getType() === 'webview').map(c => c.id))
182
+ for (const id of [...claims.keys()]) if (!live.has(id)) claims.delete(id)
183
+ }
184
+
51
185
  function guestById(id) {
52
186
  for (const contents of webContents.getAllWebContents()) {
53
187
  if (contents.id === id) return contents
@@ -90,166 +224,254 @@ async function sendCdp(id, method, params) {
90
224
  return await next
91
225
  }
92
226
 
227
+ /**
228
+ * Ask the shell's renderer for THIS conversation's browser panel, and report its guest.
229
+ *
230
+ * The renderer is the only side that knows which sidebar belongs to which conversation: the
231
+ * shell mounts one per conversation and keeps the DSH session id on each. The plugin's own
232
+ * client half carries that knowledge and publishes it as `globalThis.__dshBuiltinBrowser`; it
233
+ * calls the shell's `openTabIn(sessionId, …)`, which navigates BY CONVERSATION ID rather than
234
+ * by what the shell happens to be displaying.
235
+ *
236
+ * Nothing here inspects the DOM, dispatches a key, or clicks an element — it evaluates one call
237
+ * on a global the plugin itself published, and that global does the scoping internally.
238
+ *
239
+ * @param shellId - the shell window's webContents id.
240
+ * @param sessionId - the conversation that asked (the request's `owner`).
241
+ * @param url - address to load when a panel has to be opened; empty to only read.
242
+ * @returns the renderer's verdict, normalized so a malformed answer is a named failure.
243
+ */
244
+ async function evaluatePanelService(shellId, sessionId, url) {
245
+ const expression = `(() => {
246
+ const api = globalThis.__dshBuiltinBrowser;
247
+ if (api === undefined || api === null || typeof api.openPanel !== 'function') {
248
+ return JSON.stringify({ ok: false, reason: 'the plugin client half is not loaded in this window' });
249
+ }
250
+ try {
251
+ return JSON.stringify(api.openPanel(${JSON.stringify(String(sessionId))}, ${JSON.stringify(String(url ?? ''))}));
252
+ } catch (error) {
253
+ return JSON.stringify({ ok: false, reason: String((error && error.message) || error) });
254
+ }
255
+ })()`
256
+ // `executeJavaScript`, NOT the debugger/CDP path.
257
+ //
258
+ // Measured on the running shell: the debugger attachment to the shell WINDOW is unreliable — its
259
+ // commands hang, repeatedly and for minutes, while the same channel to a guest page answers at
260
+ // once. Nothing here needs the protocol: it is one synchronous call on a global the plugin itself
261
+ // published. `executeJavaScript` reaches the page's main world directly, with no attachment to go
262
+ // stale and no reply to be lost. The guest path keeps CDP, because driving a PAGE genuinely needs
263
+ // it (`Runtime.evaluate`, `Page.*`, `Input.*`).
264
+ const shell = guestById(shellId)
265
+ if (shell === undefined || shell.isDestroyed()) {
266
+ return { ok: false, reason: 'the shell window is gone', panel: false, guestId: null, url: '' }
267
+ }
268
+ let value
269
+ try {
270
+ value = await shell.executeJavaScript(expression, true)
271
+ } catch (error) {
272
+ return {
273
+ ok: false,
274
+ reason: 'the shell refused the panel request: ' + String((error && error.message) || error),
275
+ panel: false,
276
+ guestId: null,
277
+ url: '',
278
+ }
279
+ }
280
+ if (typeof value !== 'string' || value === '') {
281
+ return { ok: false, reason: 'the shell did not answer the panel request', panel: false, guestId: null, url: '' }
282
+ }
283
+ try {
284
+ const parsed = JSON.parse(value)
285
+ return {
286
+ ok: parsed?.ok === true,
287
+ reason: parsed?.reason === undefined ? undefined : String(parsed.reason),
288
+ panel: parsed?.panel === true,
289
+ created: parsed?.created === true,
290
+ guestId: typeof parsed?.guestId === 'number' && Number.isFinite(parsed.guestId) ? parsed.guestId : null,
291
+ url: typeof parsed?.url === 'string' ? parsed.url : '',
292
+ // A refusal carries what the renderer found, so the reason is actionable rather than a guess.
293
+ found: parsed?.found === undefined ? undefined : parsed.found,
294
+ }
295
+ } catch (error) {
296
+ return { ok: false, reason: 'the shell answered a shape this bridge does not understand', panel: false, guestId: null, url: '' }
297
+ }
298
+ }
299
+
300
+ /** Whether a guest is already showing the requested address (so no navigation is needed). */
301
+ function guestShows(id, url) {
302
+ if (url === '') return true
303
+ const guest = guestById(id)
304
+ if (guest === undefined) return false
305
+ try {
306
+ const current = guest.getURL()
307
+ return current !== '' && (current === url || current.startsWith(url))
308
+ } catch (error) {
309
+ return false
310
+ }
311
+ }
312
+
93
313
  /** Handle one JSON request line; every answer is a JSON line too. */
94
314
  async function handle(request) {
95
315
  const op = String(request?.op ?? '')
316
+ // Who is asking. Declared once, for every op, because the first attempt at this put `owner` in
317
+ // the allocation path only and the reuse path — which every later call takes — stayed blind,
318
+ // so one session was handed another's page and counted another's tabs as its own.
319
+ const owner = typeof request?.owner === 'string' && request.owner !== '' ? request.owner : 'anonymous'
320
+ // Which build is answering. The acceptance script asks this first, because everything else it
321
+ // measures is meaningless if the host is still running the previous bridge.
322
+ if (op === 'version') {
323
+ return { ok: true, loadedAt: LOADED_AT, protocol: PROTOCOL }
324
+ }
96
325
  if (op === 'list') {
97
326
  const all = webContents.getAllWebContents().map(describe)
98
- return { ok: true, guests: all, sidebar: all.filter(g => g.type === 'webview') }
327
+ // A guest claimed by another owner is not this caller's to see or to drive. Its id, url and
328
+ // title are that session's business; handing them over is how a session came to list pages it
329
+ // never opened. The shell window is always reported: it is what every op drives.
330
+ const mineOrFree = all.filter(g => g.type !== 'webview' || !claims.has(g.id) || claims.get(g.id) === owner)
331
+ return { ok: true, guests: mineOrFree, sidebar: mineOrFree.filter(g => g.type === 'webview') }
99
332
  }
100
333
  if (op === 'cdp') {
101
334
  const id = Number(request.id)
102
335
  if (!Number.isFinite(id)) throw new Error('cdp requires a numeric id')
103
336
  const result = await sendCdp(id, String(request.method ?? ''), request.params)
104
- return { ok: true, result }
337
+ return { ok: true, result, bridgeRequestId: request.bridgeRequestId }
105
338
  }
106
339
  if (op === 'ensureSidebar') {
107
- const url = request.url === undefined ? '' : String(request.url)
108
- // Materialize a sidebar guest, optionally navigating it.
340
+ // This conversation's OWN panel, opened through the shell's own navigation — never by
341
+ // simulating input.
109
342
  //
110
- // The sidebar browser creates its guest lazily: an un-navigated sidebar is
111
- // only an address bar and owns no webContents at all, so "there is no guest"
112
- // is the normal state rather than a failure. When one already exists this is
113
- // a no-op; otherwise the shell's own UI is driven to open the right sidebar
114
- // and submit an address — the same thing a human would do, and the only
115
- // supported way to make the sidebar own a page.
116
- const existing = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
117
- if (existing.length > 0) {
118
- const first = existing[0]
119
- if (request.url !== undefined && String(request.url) !== '') {
120
- await sendCdp(first.id, 'Page.navigate', { url: String(request.url) })
121
- }
122
- return { ok: true, created: false, id: first.id }
123
- }
343
+ // Every earlier version of this op drove the sidebar the way a human does: Ctrl+T, then a
344
+ // click on the 「浏览器」guide card, then typing into the address bar. All three land on the
345
+ // conversation the shell is DISPLAYING, because the shell routes keys and clicks to what it
346
+ // shows. So a page asked for while the human read another conversation was opened into THAT
347
+ // one, waiting for the screen to come back cost ten seconds, and when it never came back the
348
+ // retry loop left a tab per round. Three separate symptoms — "it opened in the wrong place",
349
+ // "it says it failed", "it opened a dozen browsers" — are one property of that path.
350
+ //
351
+ // The renderer can name the conversation instead. `openTabIn(sessionId, …)` is the shell's
352
+ // own navigation aimed at ONE conversation, and it does not read what is on screen at all —
353
+ // so this op no longer has an opinion about which conversation is displayed, and there is no
354
+ // path from here to somebody else's sidebar. DOM is not touched: the call is evaluated on the
355
+ // global the plugin's own client half published.
356
+ const url = request.url === undefined ? '' : String(request.url)
124
357
  const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
125
358
  if (shell === undefined) throw new Error('no shell window to drive')
126
- // 1. Open the sidebar and put the caret in its address field IF that field is
127
- // reachable. It is not always: the sidebar may show another panel, or the
128
- // browser tab may be present but not active, in which case no address input
129
- // exists in the document at all. That must not be fatal — the restore route
130
- // below needs no address bar, and treating "no input" as a hard error is
131
- // exactly what made the first call after a restart fail.
132
- const prepared = await sendCdp(shell.id, 'Runtime.evaluate', {
133
- expression: shellPrepareSidebar(),
134
- returnByValue: true,
135
- })
136
- const verdict = String(prepared?.result?.value ?? '')
137
- // 2. Materialize a guest. Two routes, cheapest first:
138
- // a. the sidebar's own "restore last page" affordance — it navigates
139
- // without depending on how the address field handles focus or input;
140
- // b. otherwise type into the address field and submit with REAL key input
141
- // (synthetic events do not move React's controlled input, and the field
142
- // also loses focus to re-renders, so both halves are needed).
143
- let submitVerdict = 'n/a'
144
- const restored = await sendCdp(shell.id, 'Runtime.evaluate', {
145
- // Only real controls count, and only their OWN text does. Matching any
146
- // node's `textContent` made a container that merely mentioned restoring a
147
- // page report RESTORED — which skipped the address route below entirely
148
- // and left the guest unmaterialized (measured on DSH 0.2.0-rc.2, where the
149
- // sidebar shows no restore control at all).
150
- expression: `(() => {
151
- const nodes = Array.from(document.querySelectorAll('button,a,[role=button],div[role=link]'));
152
- const hit = nodes.find(n => {
153
- const own = (n.textContent || '').trim();
154
- if (own.length > 24) return false;
155
- return /^(恢复页面|恢复上次|恢复上次页面|上次打开)$/.test(own)
156
- || /恢复|restore/i.test(n.getAttribute('aria-label') || n.getAttribute('title') || '');
157
- });
158
- if (hit === undefined) return 'NO_RESTORE';
159
- hit.click();
160
- return 'RESTORED';
161
- })()`,
162
- returnByValue: true,
163
- })
164
- if (restored?.result?.value !== 'RESTORED') {
165
- if (url !== '') {
166
- // Set the value the way React accepts it, then SUBMIT THE FORM.
167
- //
168
- // A dispatched Enter is not enough: the sidebar browser's toolbar is a
169
- // `<form>` whose submission is wired to an explicit control (aria-label
170
- // "前往" / "Go"), and implicit submission does not fire there — measured
171
- // on DSH 0.2.0-rc.2, where Enter left the field filled and the guest
172
- // uncreated. `requestSubmit()` goes through the same path the button
173
- // does, so it works with React's onSubmit and needs no localized label.
174
- const typed = await sendCdp(shell.id, 'Runtime.evaluate', {
175
- expression: `(() => {
176
- const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
177
- if (inputs.length === 0) return 'NO_ADDRESS_BAR';
178
- const input = inputs[inputs.length - 1];
179
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
180
- setter.call(input, ${JSON.stringify(String(request.url ?? ''))});
181
- input.dispatchEvent(new Event('input', { bubbles: true }));
182
- input.focus();
183
- const form = input.form ?? input.closest('form');
184
- if (form) { form.requestSubmit(); return 'SUBMITTED_FORM'; }
185
- const button = input.parentElement
186
- ? input.parentElement.querySelector('button[type=submit],button')
187
- : null;
188
- if (button) { button.click(); return 'CLICKED_SUBMIT'; }
189
- return 'TYPED_ONLY';
190
- })()`,
191
- returnByValue: true,
192
- })
193
- submitVerdict = String(typed?.result?.value ?? 'n/a')
194
- // Enter stays as the last resort for sidebars without a form.
195
- if (submitVerdict === 'TYPED_ONLY') {
196
- for (const type of ['keyDown', 'keyUp']) {
197
- await sendCdp(shell.id, 'Input.dispatchKeyEvent', {
198
- type,
199
- key: 'Enter',
200
- code: 'Enter',
201
- windowsVirtualKeyCode: 13,
202
- nativeVirtualKeyCode: 13,
203
- })
204
- }
205
- }
359
+ if (!/^session-/.test(owner)) {
360
+ throw new Error('this host supplied no conversation id, so its own panel cannot be identified')
361
+ }
362
+
363
+ const ask = async (withUrl) => {
364
+ const answer = await evaluatePanelService(shell.id, owner, withUrl ? url : '')
365
+ if (answer.ok !== true) {
366
+ // Carry the renderer's own findings into the failure: it is the side that can see why the
367
+ // lookup missed, and a bare "not mounted" leaves the next reader guessing between a real
368
+ // absence and a lookup that reads the wrong place.
369
+ const detail = answer.found === undefined ? '' : ' — renderer found ' + JSON.stringify(answer.found)
370
+ throw new Error(String(answer.reason) + detail)
206
371
  }
372
+ return answer
207
373
  }
208
- // The guest attaches asynchronously once the renderer creates the webview.
209
- for (let attempt = 0; attempt < 24; attempt++) {
210
- await new Promise(resolve => setTimeout(resolve, 500))
211
- const created = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
212
- if (created.length > 0) {
213
- // Now that a guest exists, navigation is a plain CDP call: no UI involved.
214
- if (url !== '') {
215
- await sendCdp(created[0].id, 'Page.navigate', { url }).catch(() => undefined)
216
- }
217
- return { ok: true, created: true, id: created[0].id, via: restored?.result?.value === 'RESTORED' ? 'restore' : 'address' }
374
+
375
+ // Already has a page: reuse it. This is the common case after the first open of a session.
376
+ const first = await ask(url !== '')
377
+ if (first.guestId !== null) {
378
+ claims.set(first.guestId, owner)
379
+ if (!guestShows(first.guestId, url)) {
380
+ await sendCdp(first.guestId, 'Page.navigate', { url }).catch(() => undefined)
218
381
  }
382
+ return { ok: true, created: false, id: first.guestId, via: 'panel', owner }
219
383
  }
220
- throw new Error(`the sidebar did not create a browser guest (prepare=${String(verdict)}, restore=${String(restored?.result?.value)}, submit=${submitVerdict})`)
384
+
385
+ // A tab was placed, but this conversation is not the one the shell is displaying.
386
+ //
387
+ // The shell renders the sidebar of the conversation it SHOWS. A background conversation's panel
388
+ // exists in its own surface store — which is exactly why the tab could be placed at all — but
389
+ // it has no DOM, so no webview attaches and no amount of waiting would produce one. Waiting
390
+ // would turn a true statement into a timeout, and the page is genuinely there: it appears the
391
+ // moment the conversation is shown.
392
+ if (first.panel === false) {
393
+ throw new Error(
394
+ 'a panel was placed in this conversation\'s sidebar, but its page can only attach while the '
395
+ + 'conversation is the one displayed — show it and the page appears',
396
+ )
397
+ }
398
+
399
+ // A guest attaches asynchronously once the renderer creates the webview. Poll INSIDE this
400
+ // conversation's panel, so a guest appearing in any other conversation cannot satisfy the wait
401
+ // — the scoping is what makes the wait safe.
402
+ const deadline = Date.now() + (url === '' ? 4_000 : 20_000)
403
+ while (Date.now() < deadline) {
404
+ await new Promise(resolve => setTimeout(resolve, 250))
405
+ const polled = await ask(false)
406
+ if (polled.guestId === null) continue
407
+ claims.set(polled.guestId, owner)
408
+ if (!guestShows(polled.guestId, url)) {
409
+ await sendCdp(polled.guestId, 'Page.navigate', { url }).catch(() => undefined)
410
+ }
411
+ return { ok: true, created: true, id: polled.guestId, via: 'panel', owner }
412
+ }
413
+ throw new Error(
414
+ 'this conversation\'s browser panel produced no page within its budget '
415
+ + '(the shell placed no guest in this conversation\'s own sidebar)',
416
+ )
221
417
  }
222
- if (op === 'ensureTabs') {
223
- // Grow the sidebar's browser tab strip to `count` tabs and report every guest.
418
+ if (op === 'showTab') {
419
+ // Bring one browser guest to the front — the "switch to this tab" half of opening a page.
420
+ //
421
+ // Without this the desktop bridge had no way to satisfy `showView`, so its host
422
+ // implementation was an empty method and a newly opened page stayed behind whatever the
423
+ // human was looking at. Reported as issue #23; the other two carriers already did this
424
+ // (the self-hosted window re-adds the view, the system browser calls Page.bringToFront).
224
425
  //
225
- // Tabs are the renderer's, so this cannot create them directly — it drives the
226
- // sidebar's own "new tab" control, the same affordance a human uses, and then
227
- // reports the guest ids. Whatever already exists is reused, so repeated calls
228
- // are cheap and a human-opened tab is never orphaned.
229
- const want = Math.max(1, Math.min(8, Number(request.count ?? 1)))
426
+ // The tab belongs to the renderer, so the same reasoning as `ensureTabs` applies: drive the
427
+ // sidebar's own control. A tab is matched by the title it shows, which is what the sidebar
428
+ // renders from the guest — the guest's id is not exposed in the DOM.
429
+ const viewId = Number(request.viewId)
430
+ if (!Number.isFinite(viewId)) throw new Error('showTab needs a viewId')
431
+ const guest = webContents.fromId(viewId)
432
+ if (guest === undefined) throw new Error(`showTab: no guest with id ${viewId}`)
433
+ // Only a guest this caller holds may be brought forward. Without the check, one session could
434
+ // switch the sidebar to another session's page — moving the human's view out from under it.
435
+ pruneClaims()
436
+ if (claims.has(viewId) && claims.get(viewId) !== owner) {
437
+ throw new Error(`showTab: guest ${viewId} belongs to another session`)
438
+ }
439
+ claims.set(viewId, owner)
230
440
  const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
231
441
  if (shell === undefined) throw new Error('no shell window to drive')
232
- let lastVerdict = ''
233
- for (let attempt = 0; attempt < 30; attempt++) {
234
- const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
235
- if (guests.length >= want) return { ok: true, ids: guests.map(contents => contents.id), verdict: lastVerdict }
236
- const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
237
- expression: `(() => {
238
- const nodes = Array.from(document.querySelectorAll('button,[role=button]'));
239
- const hit = nodes.find(b => /新标签页|新建标签|new tab/i.test((b.getAttribute('aria-label') || '') + (b.textContent || '') + (b.getAttribute('title') || '')));
240
- if (hit === undefined) return 'NO_NEW_TAB_CONTROL';
241
- hit.click();
242
- return 'CLICKED';
243
- })()`,
244
- returnByValue: true,
245
- })
246
- lastVerdict = String(clicked?.result?.value ?? '')
247
- if (lastVerdict === 'NO_NEW_TAB_CONTROL') {
248
- throw new Error('the sidebar exposes no new-tab control (is the browser tab open?)')
249
- }
250
- await new Promise(resolve => setTimeout(resolve, 600))
442
+ const wantTitle = String(guest.getTitle() ?? '').trim()
443
+ const clicked = await evaluateInPanel(shell.id, owner, 'Runtime.evaluate', {
444
+ expression: `(() => {
445
+ const scan = (root, out) => {
446
+ for (const node of root.querySelectorAll('button,[role=button],[role=tab],a')) {
447
+ out.push(node);
448
+ if (node.shadowRoot) scan(node.shadowRoot, out);
449
+ }
450
+ return out;
451
+ };
452
+ const nodes = scan(document, []);
453
+ for (const frame of Array.from(document.querySelectorAll('iframe'))) {
454
+ try { if (frame.contentDocument) scan(frame.contentDocument, nodes) } catch { /* cross-origin */ }
455
+ }
456
+ const label = (n) => ((n.getAttribute('aria-label') || '') + ' ' + (n.textContent || '') + ' ' + (n.getAttribute('title') || '')).trim();
457
+ const want = ${JSON.stringify(wantTitle)};
458
+ // Exact title first, then the tab-like control whose label contains it: a strip may
459
+ // decorate the label with a close button, and a prefix match would hit the wrong tab
460
+ // when one title contains another.
461
+ let hit = nodes.find(n => want !== '' && label(n) === want);
462
+ if (hit === undefined) hit = nodes.find(n => want !== '' && label(n).includes(want) && /tab|page|标签/i.test(n.getAttribute('role') || n.className || ''));
463
+ if (hit === undefined) hit = nodes.find(n => want !== '' && label(n).includes(want));
464
+ if (hit === undefined) return 'NO_TAB_FOR_TITLE';
465
+ hit.click();
466
+ return 'CLICKED';
467
+ })()`,
468
+ returnByValue: true,
469
+ })
470
+ const verdict = String(clicked?.result?.value ?? '')
471
+ if (verdict !== 'CLICKED') {
472
+ throw new Error(`could not bring the page to the front (${verdict}; wanted "${wantTitle}")`)
251
473
  }
252
- throw new Error(`the sidebar stopped at fewer than ${want} tabs`)
474
+ return { ok: true, verdict }
253
475
  }
254
476
  if (op === 'closeSidebarBrowser') {
255
477
  // Release the sidebar's browser pages by closing the tabs that host them.
@@ -264,8 +486,19 @@ async function handle(request) {
264
486
  // ending one session must not tear down another's page. With no titles given the
265
487
  // call means "release everything", which is only appropriate when the caller
266
488
  // knows it owns them all.
489
+ //
490
+ // The comment above said that; the code below did not enforce it. It closed every webview
491
+ // whose title matched — and with no filter, EVERY webview — regardless of which session held
492
+ // it. So one session's `browser_reset_session` could destroy another session's page, which is
493
+ // the worst version of the bleed: not merely seeing someone else's tab, but closing it.
494
+ //
495
+ // Ownership is now the outer bound: only guests this owner holds are candidates at all, and
496
+ // the title filter narrows further within them.
497
+ pruneClaims()
267
498
  const wanted = Array.isArray(request.titles) ? request.titles.map(title => String(title).slice(0, 24)).filter(title => title !== '') : undefined
268
- const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
499
+ const guests = webContents.getAllWebContents()
500
+ .filter(contents => contents.getType() === 'webview')
501
+ .filter(contents => claims.get(contents.id) === owner)
269
502
  const titles = []
270
503
  for (const guest of guests) {
271
504
  try {
@@ -281,7 +514,7 @@ async function handle(request) {
281
514
  const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
282
515
  if (shell === undefined) throw new Error('no shell window to drive')
283
516
  if (titles.length === 0 && wanted !== undefined) return { ok: true, closed: 0 }
284
- const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
517
+ const clicked = await evaluateInPanel(shell.id, owner, 'Runtime.evaluate', {
285
518
  expression: `(() => {
286
519
  const wanted = ${JSON.stringify(titles.map(title => title.slice(0, 24)))};
287
520
  let closed = 0;
@@ -304,7 +537,7 @@ async function handle(request) {
304
537
  // guest keeps running — collapsing is presentation, exactly as §3 asks.
305
538
  const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
306
539
  if (shell === undefined) throw new Error('no shell window to drive')
307
- const folded = await sendCdp(shell.id, 'Runtime.evaluate', {
540
+ const folded = await evaluateInPanel(shell.id, owner, 'Runtime.evaluate', {
308
541
  expression: `(() => {
309
542
  const button = Array.from(document.querySelectorAll('button')).find(b => /收起右侧边栏/.test((b.getAttribute('aria-label') || '') + (b.textContent || '')));
310
543
  if (button === undefined) return 'NOT_OPEN';
@@ -313,32 +546,11 @@ async function handle(request) {
313
546
  })()`,
314
547
  returnByValue: true,
315
548
  })
316
- return { ok: true, result: folded?.result?.value }
549
+ return { ok: true, result: folded?.result?.value, bridgeRequestId: request.bridgeRequestId }
317
550
  }
318
551
  throw new Error(`unknown op ${JSON.stringify(op)}`)
319
552
  }
320
553
 
321
- /**
322
- * In-page script that opens the right sidebar and focuses its address field.
323
- *
324
- * Only preparation happens here; the address itself is typed with real input
325
- * (see `ensureSidebar`), because a controlled React input ignores text assigned
326
- * through synthetic events.
327
- * @returns a self-contained expression evaluating to a short verdict string.
328
- */
329
- function shellPrepareSidebar() {
330
- return `(() => {
331
- const labelOf = el => ((el.getAttribute && el.getAttribute('aria-label')) || el.textContent || '').trim();
332
- let opened = false;
333
- for (const button of Array.from(document.querySelectorAll('button'))) {
334
- if (/打开右侧边栏|右侧边栏/.test(labelOf(button))) { button.click(); opened = true; break; }
335
- }
336
- const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
337
- if (inputs.length === 0) return 'NO_ADDRESS_BAR:' + JSON.stringify(Array.from(document.querySelectorAll('input')).map(i => i.placeholder).slice(0, 6));
338
- inputs[inputs.length - 1].focus();
339
- return opened ? 'OPENED_AND_FOCUSED' : 'FOCUSED';
340
- })()`
341
- }
342
554
 
343
555
  /**
344
556
  * Publish the endpoint, and keep it published.
@@ -369,8 +581,48 @@ function publishEndpoint(port, token) {
369
581
  }
370
582
  }
371
583
 
584
+ /**
585
+ * The running bridge instance, keyed on globalThis so it survives a reload.
586
+ *
587
+ * A cache-busted re-import evaluates a FRESH module instance with its own
588
+ * module-level state. A plain module-scoped variable would therefore leave the
589
+ * previous bridge running: two keepalives would alternate writing the endpoint
590
+ * file and hand clients a port that is already closed. Symbol.for keeps the key
591
+ * identical across every instance of this module.
592
+ */
593
+ const ACTIVE = Symbol.for("dsh-builtin-browser.bridge.active")
594
+
595
+ /**
596
+ * Stop the running bridge: clears its keepalive timer, closes its listener and
597
+ * drops its quit hook. Idempotent and never throws, so the shell can call it
598
+ * before re-importing an edited bridge.
599
+ */
600
+ export function stop() {
601
+ const current = globalThis[ACTIVE]
602
+ globalThis[ACTIVE] = undefined
603
+ if (!current) return
604
+ try { clearInterval(current.keepalive) } catch { /* ignore */ }
605
+ try { current.server.close() } catch { /* already closed */ }
606
+ try { app.off('will-quit', current.onQuit) } catch { /* ignore */ }
607
+ }
608
+
372
609
  /** Start the bridge: loopback listener + published endpoint file + token. */
373
610
  export function start() {
611
+ stop()
612
+ // Drop any debugger attachment this process is still holding.
613
+ //
614
+ // An attachment belongs to the webContents, not to this module, so it outlives both the listener
615
+ // and the reload `main.js` performs when the file changes. A stale one is the worst kind of
616
+ // broken: `isAttached()` answers true, so the next bridge skips its own attach, and every command
617
+ // sent into it hangs — no error, no reply, nothing to see. A bridge that has JUST started has no
618
+ // attachments of its own, which makes whatever is attached right now a leftover: safe to drop,
619
+ // and exact. (If DevTools happens to be open on the shell it loses that session; the shell itself
620
+ // is unaffected, and that is the cheaper cost.)
621
+ try {
622
+ for (const contents of webContents.getAllWebContents()) {
623
+ try { if (contents.debugger.isAttached()) contents.debugger.detach() } catch { /* gone */ }
624
+ }
625
+ } catch { /* no webContents at all; nothing to detach */ }
374
626
  const token = randomBytes(24).toString('hex')
375
627
  const server = createServer(socket => {
376
628
  let buffer = ''
@@ -394,8 +646,16 @@ export function start() {
394
646
  if (typeof request.op !== 'string' || request.op === '') continue
395
647
  }
396
648
  void handle(request)
397
- .then(answer => socket.write(JSON.stringify(answer) + '\n'))
398
- .catch(error => socket.write(JSON.stringify({ ok: false, error: String(error?.message ?? error) }) + '\n'))
649
+ // The request id is stamped HERE, at the single point where every answer is written,
650
+ // rather than on the handful of returns that happened to remember it.
651
+ //
652
+ // The parent matches replies to requests by this id; when it is missing the parent
653
+ // marks the whole connection id-less and serialisable, which drops every reply back
654
+ // to arrival order and makes it reject in-flight requests when a second call
655
+ // arrives. Only two of this bridge's nine reply paths carried the field, so most
656
+ // operations permanently degraded the connection they used.
657
+ .then(answer => socket.write(JSON.stringify({ ...answer, bridgeRequestId: request.bridgeRequestId }) + '\n'))
658
+ .catch(error => socket.write(JSON.stringify({ ok: false, error: String(error?.message ?? error), bridgeRequestId: request.bridgeRequestId }) + '\n'))
399
659
  }
400
660
  })
401
661
  socket.on('error', () => { /* client went away */ })
@@ -417,9 +677,8 @@ export function start() {
417
677
  if (port !== 0) publishEndpoint(port, token)
418
678
  }, 15_000)
419
679
  keepalive.unref?.()
420
- app.on('will-quit', () => {
421
- clearInterval(keepalive)
422
- try { server.close() } catch { /* already closed */ }
423
- })
680
+ const onQuit = () => stop()
681
+ app.on('will-quit', onQuit)
682
+ globalThis[ACTIVE] = { server, keepalive, onQuit }
424
683
  return server
425
684
  }