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.
- package/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- 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
|
-
|
|
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
|
-
|
|
108
|
-
//
|
|
340
|
+
// This conversation's OWN panel, opened through the shell's own navigation — never by
|
|
341
|
+
// simulating input.
|
|
109
342
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// a
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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 === '
|
|
223
|
-
//
|
|
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
|
-
//
|
|
226
|
-
// sidebar's own
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
|
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
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
})
|
|
680
|
+
const onQuit = () => stop()
|
|
681
|
+
app.on('will-quit', onQuit)
|
|
682
|
+
globalThis[ACTIVE] = { server, keepalive, onQuit }
|
|
424
683
|
return server
|
|
425
684
|
}
|