dsh-builtin-browser 0.4.2 → 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 +63 -20
- package/README.md +60 -20
- package/client.js +418 -24
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +414 -163
- package/docs/architecture.md +4 -4
- package/docs/tool-reference.md +3 -3
- package/docs/user-guide.md +50 -2
- package/lib/browser/types.d.ts +9 -2
- package/lib/browser-electron/bridge-connection.d.ts +18 -21
- package/lib/browser-electron/bridge-connection.js +42 -1
- 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.js +73 -6
- package/lib/browser-electron/history-store.d.ts +11 -2
- package/lib/browser-electron/history-store.js +27 -5
- package/lib/browser-electron/host-main.js +11 -1
- package/lib/browser-electron/provider.d.ts +62 -2
- package/lib/browser-electron/provider.js +28 -12
- package/lib/browser-electron/remote-host.js +19 -6
- 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 +6 -6
- package/lib/browser-electron/settings-route.js +48 -8
- package/lib/browser-electron/settings-store.js +57 -16
- package/lib/browser-electron/system-browser.d.ts +31 -9
- package/lib/browser-electron/system-browser.js +116 -27
- 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 +26 -60
- package/package.json +6 -2
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/bridge-connection.ts +37 -1
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +75 -7
- package/src/browser-electron/history-store.ts +32 -7
- package/src/browser-electron/host-main.ts +10 -1
- package/src/browser-electron/provider.ts +91 -18
- package/src/browser-electron/remote-host.ts +19 -6
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +54 -13
- package/src/browser-electron/settings-store.ts +56 -17
- package/src/browser-electron/system-browser.ts +121 -28
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +31 -60
package/client.js
CHANGED
|
@@ -19,7 +19,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
19
19
|
|
|
20
20
|
const react = require("react")
|
|
21
21
|
const h = react.createElement
|
|
22
|
-
const { useCallback, useEffect, useState } = react
|
|
22
|
+
const { useCallback, useEffect, useRef, useState } = react
|
|
23
23
|
|
|
24
24
|
const name = "dsh-builtin-browser"
|
|
25
25
|
const inject = ["slots", "locale"]
|
|
@@ -58,7 +58,8 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
58
58
|
"credentials.allowRead": "允许 Agent 读取 cookies / 导出登录状态",
|
|
59
59
|
"browser.title": "浏览器载体",
|
|
60
60
|
"browser.channel": "使用哪个浏览器",
|
|
61
|
-
"browser.
|
|
61
|
+
"browser.sidebar": "侧栏浏览器(在 DSH 窗口右侧打开,不额外拉窗口)",
|
|
62
|
+
"browser.bundled": "内置 Electron(随插件提供,无需安装)",
|
|
62
63
|
"browser.chrome": "本机 Chrome",
|
|
63
64
|
"browser.edge": "本机 Edge",
|
|
64
65
|
"browser.auto": "自动(优先 Chrome,其次 Edge)",
|
|
@@ -105,7 +106,8 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
105
106
|
"credentials.allowRead": "Allow the agent to read cookies / export login state",
|
|
106
107
|
"browser.title": "Browser",
|
|
107
108
|
"browser.channel": "Which browser to use",
|
|
108
|
-
|
|
109
|
+
"browser.sidebar": "Sidebar browser (opens in the DSH window right pane; no extra window)",
|
|
110
|
+
"browser.bundled": "Bundled Electron (ships with the plugin, nothing to install)",
|
|
109
111
|
"browser.chrome": "Installed Chrome",
|
|
110
112
|
"browser.edge": "Installed Edge",
|
|
111
113
|
"browser.auto": "Automatic (Chrome first, then Edge)",
|
|
@@ -137,26 +139,219 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
137
139
|
return data
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The panel's own stylesheet.
|
|
144
|
+
*
|
|
145
|
+
* Inline styles cannot express `:hover`, and the rows need one — an interactive row with no
|
|
146
|
+
* feedback reads as decoration. The colours come from DSH's own design tokens
|
|
147
|
+
* (`--dsw-alias-*`, the same ones the host's settings rows use) so this follows the active
|
|
148
|
+
* theme instead of guessing at it; each has a literal fallback because a token may be absent
|
|
149
|
+
* under a third-party theme, and an unstyled row is worse than a slightly-off one.
|
|
150
|
+
*/
|
|
151
|
+
const CSS = `
|
|
152
|
+
.dsh-bb-row {
|
|
153
|
+
border-radius: 8px;
|
|
154
|
+
padding: 7px 9px;
|
|
155
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
156
|
+
transition: background-color .12s ease;
|
|
157
|
+
}
|
|
158
|
+
/* No resting background: a permanent grey block behind every row reads as a set of buttons
|
|
159
|
+
rather than a list of settings. The highlight belongs to the pointer, not to the row. */
|
|
160
|
+
.dsh-bb-row:hover {
|
|
161
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.15));
|
|
162
|
+
}
|
|
163
|
+
.dsh-bb-row:active {
|
|
164
|
+
background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.22));
|
|
165
|
+
}
|
|
166
|
+
.dsh-bb-row:focus-within {
|
|
167
|
+
border-color: var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
168
|
+
}
|
|
169
|
+
/* The dropdown is drawn here rather than by the OS.
|
|
170
|
+
A native <select> paints its popup with system colours on Windows: the highlight overrides
|
|
171
|
+
whatever the option sets (white text on a white bar), and the list carries a border no CSS
|
|
172
|
+
can remove. Only the collapsed control is styleable, so a panel that matches its theme
|
|
173
|
+
everywhere else ends up with one control that does not. Everything below is ours: the
|
|
174
|
+
surface, the highlight, and the absence of that line. */
|
|
175
|
+
.dsh-bb-dd {
|
|
176
|
+
position: relative;
|
|
177
|
+
display: inline-block;
|
|
178
|
+
margin-top: 4px;
|
|
179
|
+
}
|
|
180
|
+
.dsh-bb-dd-btn {
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
gap: 8px;
|
|
184
|
+
max-width: 100%;
|
|
185
|
+
padding: 4px 8px 4px 9px;
|
|
186
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
187
|
+
border-radius: 6px;
|
|
188
|
+
background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12));
|
|
189
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
190
|
+
font: inherit;
|
|
191
|
+
text-align: left;
|
|
192
|
+
cursor: pointer;
|
|
193
|
+
}
|
|
194
|
+
.dsh-bb-dd-btn:hover:not(:disabled) {
|
|
195
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
|
|
196
|
+
}
|
|
197
|
+
.dsh-bb-dd-btn:disabled { opacity: .55; cursor: default; }
|
|
198
|
+
.dsh-bb-dd-caret { opacity: .6; font-size: 10px; line-height: 1; }
|
|
199
|
+
.dsh-bb-dd-list {
|
|
200
|
+
position: absolute;
|
|
201
|
+
z-index: 20;
|
|
202
|
+
top: calc(100% + 4px);
|
|
203
|
+
left: 0;
|
|
204
|
+
min-width: 100%;
|
|
205
|
+
max-width: 340px;
|
|
206
|
+
padding: 4px;
|
|
207
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
208
|
+
border-radius: 8px;
|
|
209
|
+
background: var(--dsw-alias-bg-layer-2, #2f2f2f);
|
|
210
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.28);
|
|
211
|
+
}
|
|
212
|
+
.dsh-bb-dd-opt {
|
|
213
|
+
display: block;
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 6px 9px;
|
|
216
|
+
border: none;
|
|
217
|
+
border-radius: 6px;
|
|
218
|
+
background: transparent;
|
|
219
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
220
|
+
font: inherit;
|
|
221
|
+
text-align: left;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
/* The same ellipse the rows use, so the panel reads as one surface — and it is ours to draw,
|
|
225
|
+
which is the whole reason this control exists. */
|
|
226
|
+
.dsh-bb-dd-opt:hover,
|
|
227
|
+
.dsh-bb-dd-opt[data-active="true"] {
|
|
228
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
|
|
229
|
+
}
|
|
230
|
+
.dsh-bb-dd-opt[aria-selected="true"] { font-weight: 600; }
|
|
231
|
+
`
|
|
232
|
+
|
|
233
|
+
/** Install the stylesheet once, however many times the panel is mounted. */
|
|
234
|
+
function installStyles() {
|
|
235
|
+
if (typeof document === "undefined") return
|
|
236
|
+
if (document.getElementById("dsh-builtin-browser-styles") !== null) return
|
|
237
|
+
const tag = document.createElement("style")
|
|
238
|
+
tag.id = "dsh-builtin-browser-styles"
|
|
239
|
+
tag.textContent = CSS
|
|
240
|
+
document.head.appendChild(tag)
|
|
241
|
+
}
|
|
242
|
+
|
|
140
243
|
const styles = {
|
|
141
244
|
wrap: { display: "flex", flexDirection: "column", gap: "14px", padding: "4px 2px", fontSize: "13px" },
|
|
142
245
|
lead: { opacity: 0.75, lineHeight: 1.6, margin: 0 },
|
|
143
246
|
group: { border: "1px solid rgba(128,128,128,.28)", borderRadius: "8px", padding: "10px 12px" },
|
|
144
247
|
groupTitle: { margin: "0 0 8px", fontSize: "13px", fontWeight: 600 },
|
|
145
|
-
row: { display: "flex", gap: "10px", alignItems: "flex-start"
|
|
248
|
+
row: { display: "flex", gap: "10px", alignItems: "flex-start" },
|
|
146
249
|
box: { marginTop: "2px" },
|
|
147
250
|
label: { display: "flex", flexDirection: "column", gap: "2px", cursor: "pointer" },
|
|
148
251
|
hint: { opacity: 0.68, lineHeight: 1.5 },
|
|
149
|
-
select: { marginTop: "4px", padding: "4px 6px", borderRadius: "6px", background: "transparent", color: "inherit", border: "1px solid rgba(128,128,128,.4)" },
|
|
150
252
|
status: { opacity: 0.7, minHeight: "18px" },
|
|
151
253
|
error: { color: "#e5534b" },
|
|
152
254
|
pathLine: { opacity: 0.6, wordBreak: "break-all", lineHeight: 1.5 },
|
|
153
255
|
button: { padding: "3px 10px", borderRadius: "6px", border: "1px solid rgba(128,128,128,.4)", background: "transparent", color: "inherit", cursor: "pointer" },
|
|
154
256
|
}
|
|
155
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A dropdown drawn by this panel rather than by the operating system.
|
|
260
|
+
*
|
|
261
|
+
* Replaces a native `<select>`, whose popup Windows paints with system colours: the hover
|
|
262
|
+
* highlight overrode the option's background (white text on a white bar) and the list carried
|
|
263
|
+
* a border no CSS could remove. Both are unfixable from outside the control, and this panel's
|
|
264
|
+
* two dropdowns were the only places it did not match its own theme.
|
|
265
|
+
*
|
|
266
|
+
* Keyboard and assistive-technology behaviour is reimplemented rather than inherited, which is
|
|
267
|
+
* the cost of owning the rendering: Up/Down move, Enter/Space commit, Escape closes and
|
|
268
|
+
* returns focus, Home/End jump, clicking outside closes.
|
|
269
|
+
*/
|
|
270
|
+
function Dropdown(props) {
|
|
271
|
+
const [open, setOpen] = useState(false)
|
|
272
|
+
// Index the keyboard has moved to; -1 means "the selected value, unless the user moved".
|
|
273
|
+
const [active, setActive] = useState(-1)
|
|
274
|
+
const rootRef = useRef(null)
|
|
275
|
+
const buttonRef = useRef(null)
|
|
276
|
+
|
|
277
|
+
const selected = props.options.findIndex(option => option.value === props.value)
|
|
278
|
+
const current = props.options[selected] ?? props.options[0]
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!open) return undefined
|
|
282
|
+
const away = event => {
|
|
283
|
+
if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false)
|
|
284
|
+
}
|
|
285
|
+
document.addEventListener("mousedown", away)
|
|
286
|
+
return () => document.removeEventListener("mousedown", away)
|
|
287
|
+
}, [open])
|
|
288
|
+
|
|
289
|
+
const commit = index => {
|
|
290
|
+
const option = props.options[index]
|
|
291
|
+
if (option === undefined) return
|
|
292
|
+
setOpen(false)
|
|
293
|
+
setActive(-1)
|
|
294
|
+
buttonRef.current?.focus()
|
|
295
|
+
if (option.value !== props.value) props.onChange(option.value)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const onKeyDown = event => {
|
|
299
|
+
if (event.key === "Escape") {
|
|
300
|
+
if (open) { event.preventDefault(); setOpen(false); buttonRef.current?.focus() }
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (!open && (event.key === "Enter" || event.key === " " || event.key === "ArrowDown")) {
|
|
304
|
+
event.preventDefault()
|
|
305
|
+
setOpen(true)
|
|
306
|
+
setActive(selected >= 0 ? selected : 0)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (!open) return
|
|
310
|
+
const last = props.options.length - 1
|
|
311
|
+
if (event.key === "ArrowDown") { event.preventDefault(); setActive(a => Math.min(last, (a < 0 ? selected : a) + 1)) }
|
|
312
|
+
else if (event.key === "ArrowUp") { event.preventDefault(); setActive(a => Math.max(0, (a < 0 ? selected : a) - 1)) }
|
|
313
|
+
else if (event.key === "Home") { event.preventDefault(); setActive(0) }
|
|
314
|
+
else if (event.key === "End") { event.preventDefault(); setActive(last) }
|
|
315
|
+
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); commit(active < 0 ? selected : active) }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return h("div", { key: "s", className: "dsh-bb-dd", ref: rootRef }, [
|
|
319
|
+
h("button", {
|
|
320
|
+
key: "b",
|
|
321
|
+
type: "button",
|
|
322
|
+
ref: buttonRef,
|
|
323
|
+
className: "dsh-bb-dd-btn",
|
|
324
|
+
disabled: props.disabled,
|
|
325
|
+
"aria-haspopup": "listbox",
|
|
326
|
+
"aria-expanded": open ? "true" : "false",
|
|
327
|
+
onClick: () => { setOpen(o => !o); setActive(-1) },
|
|
328
|
+
onKeyDown,
|
|
329
|
+
}, [
|
|
330
|
+
h("span", { key: "v" }, current === undefined ? "" : current.label),
|
|
331
|
+
h("span", { key: "c", className: "dsh-bb-dd-caret", "aria-hidden": "true" }, "\\u25be"),
|
|
332
|
+
]),
|
|
333
|
+
open ? h("div", {
|
|
334
|
+
key: "l",
|
|
335
|
+
className: "dsh-bb-dd-list",
|
|
336
|
+
role: "listbox",
|
|
337
|
+
"aria-label": props.label,
|
|
338
|
+
}, props.options.map((option, index) => h("button", {
|
|
339
|
+
key: option.value,
|
|
340
|
+
type: "button",
|
|
341
|
+
className: "dsh-bb-dd-opt",
|
|
342
|
+
role: "option",
|
|
343
|
+
"aria-selected": option.value === props.value ? "true" : "false",
|
|
344
|
+
"data-active": index === active ? "true" : "false",
|
|
345
|
+
onMouseEnter: () => setActive(index),
|
|
346
|
+
onClick: () => commit(index),
|
|
347
|
+
}, option.label))) : null,
|
|
348
|
+
])
|
|
349
|
+
}
|
|
350
|
+
|
|
156
351
|
/** One boolean switch bound to a settings path. */
|
|
157
352
|
function Toggle(props) {
|
|
158
353
|
const t = translate
|
|
159
|
-
return h("div", { style: styles.row }, [
|
|
354
|
+
return h("div", { style: styles.row, className: "dsh-bb-row" }, [
|
|
160
355
|
h("input", {
|
|
161
356
|
key: "i",
|
|
162
357
|
type: "checkbox",
|
|
@@ -277,40 +472,43 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
277
472
|
]),
|
|
278
473
|
|
|
279
474
|
section("vision.title", [
|
|
280
|
-
h("div", { key: "row", style: styles.row }, [
|
|
475
|
+
h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
|
|
281
476
|
h("label", { key: "l", style: styles.label }, [
|
|
282
477
|
h("span", { key: "t" }, t("vision.strategy")),
|
|
283
|
-
h(
|
|
478
|
+
h(Dropdown, {
|
|
284
479
|
key: "s",
|
|
285
|
-
|
|
480
|
+
label: t("vision.strategy"),
|
|
286
481
|
value: settings.vision.strategy,
|
|
287
482
|
disabled: busy,
|
|
288
|
-
onChange:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
483
|
+
onChange: value => patch({ vision: { strategy: value } }),
|
|
484
|
+
options: [
|
|
485
|
+
{ value: "auto", label: t("vision.auto") },
|
|
486
|
+
{ value: "nonVisual", label: t("vision.nonVisual") },
|
|
487
|
+
],
|
|
488
|
+
}),
|
|
293
489
|
h("span", { key: "h", style: styles.hint }, t("vision.hint")),
|
|
294
490
|
]),
|
|
295
491
|
]),
|
|
296
492
|
]),
|
|
297
493
|
|
|
298
494
|
section("browser.title", [
|
|
299
|
-
h("div", { key: "row", style: styles.row }, [
|
|
495
|
+
h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
|
|
300
496
|
h("label", { key: "l", style: styles.label }, [
|
|
301
497
|
h("span", { key: "t" }, t("browser.channel")),
|
|
302
|
-
h(
|
|
498
|
+
h(Dropdown, {
|
|
303
499
|
key: "s",
|
|
304
|
-
|
|
500
|
+
label: t("browser.channel"),
|
|
305
501
|
value: settings.browser.channel,
|
|
306
502
|
disabled: busy,
|
|
307
|
-
onChange:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
503
|
+
onChange: value => patch({ browser: { channel: value } }),
|
|
504
|
+
options: [
|
|
505
|
+
{ value: "sidebar", label: t("browser.sidebar") },
|
|
506
|
+
{ value: "bundled", label: t("browser.bundled") },
|
|
507
|
+
{ value: "auto", label: t("browser.auto") },
|
|
508
|
+
{ value: "chrome", label: t("browser.chrome") },
|
|
509
|
+
{ value: "edge", label: t("browser.edge") },
|
|
510
|
+
],
|
|
511
|
+
}),
|
|
314
512
|
h("span", { key: "h", style: styles.hint }, t("browser.hint")),
|
|
315
513
|
]),
|
|
316
514
|
]),
|
|
@@ -365,7 +563,203 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
365
563
|
])
|
|
366
564
|
}
|
|
367
565
|
|
|
566
|
+
// ===========================================================================
|
|
567
|
+
// Conversation panel service: the one place a sidebar panel is opened.
|
|
568
|
+
//
|
|
569
|
+
// The shell mounts one right sidebar per conversation, and DSH's own sidebar
|
|
570
|
+
// browser keeps that conversation's tabs and its webview guest inside it.
|
|
571
|
+
// `ctx.sidebarRight.openTab` acts on the conversation the shell is SHOWING
|
|
572
|
+
// (`mounted`), so every path that simulated a shortcut or clicked a card put
|
|
573
|
+
// the page into whichever conversation the human happened to be reading —
|
|
574
|
+
// that is the cross-conversation bleed, and it is a property of the path, not
|
|
575
|
+
// an accident of it.
|
|
576
|
+
//
|
|
577
|
+
// `openTabIn(sessionId, …)` is the same navigation aimed at a NAMED
|
|
578
|
+
// conversation, and it works for one that is not on screen. This service is
|
|
579
|
+
// the only caller; the plugin's host half reaches it by evaluating
|
|
580
|
+
// `globalThis.__dshBuiltinBrowser.openPanel(...)` inside the shell window, so
|
|
581
|
+
// no input event, no key dispatch and no element click is involved.
|
|
582
|
+
//
|
|
583
|
+
// Guest identity is read from the panel's own `<webview>` and only inside the
|
|
584
|
+
// subtree found for THAT conversation — never from the first match in the
|
|
585
|
+
// document, which belongs to somebody else's panel.
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
|
|
588
|
+
/** The conversation a sidebar element belongs to, read off its React fiber. */
|
|
589
|
+
function sessionIdOfElement(element) {
|
|
590
|
+
const key = Object.keys(element).find(
|
|
591
|
+
name => name.startsWith("__reactFiber$") || name.startsWith("__reactInternalInstance$"),
|
|
592
|
+
)
|
|
593
|
+
if (key === undefined) return null
|
|
594
|
+
let fiber = element[key]
|
|
595
|
+
let depth = 0
|
|
596
|
+
while (fiber !== null && fiber !== undefined && depth < 60) {
|
|
597
|
+
const props = fiber.memoizedProps
|
|
598
|
+
if (props !== null && typeof props === "object" && typeof props.sessionId === "string") return props.sessionId
|
|
599
|
+
fiber = fiber.return
|
|
600
|
+
depth += 1
|
|
601
|
+
}
|
|
602
|
+
return null
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* This conversation's own sidebar strip, or null when the shell has not mounted it.
|
|
607
|
+
*
|
|
608
|
+
* Its presence is the precondition `openTabIn` needs: it is minted together with the
|
|
609
|
+
* conversation's surface store, and a call for a conversation whose store was never
|
|
610
|
+
* minted silently does nothing. Detecting it here turns that silence into a reason
|
|
611
|
+
* the caller can report, instead of a page that never appears.
|
|
612
|
+
*/
|
|
613
|
+
function tabStripFor(sessionId) {
|
|
614
|
+
for (const strip of Array.from(document.querySelectorAll("[class*=_tabStrip]"))) {
|
|
615
|
+
if (sessionIdOfElement(strip) === sessionId) return strip
|
|
616
|
+
}
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Every live `<webview>` guest inside this conversation's panel subtree. */
|
|
621
|
+
function guestsInPanel(sessionId) {
|
|
622
|
+
const strip = tabStripFor(sessionId)
|
|
623
|
+
if (strip === null) return []
|
|
624
|
+
// Walk up to the first ancestor that owns a guest: the strip itself is outside the
|
|
625
|
+
// body region, so the search starts low and stops as soon as it has one. It never
|
|
626
|
+
// crosses out of this conversation's branch, which is what keeps it scoped.
|
|
627
|
+
let node = strip
|
|
628
|
+
while (node !== null && node !== undefined && node !== document.body) {
|
|
629
|
+
const found = Array.from(node.querySelectorAll("webview")).filter(view => {
|
|
630
|
+
try { return typeof view.getWebContentsId === "function" && view.getWebContentsId() > 0 }
|
|
631
|
+
catch (error) { return false }
|
|
632
|
+
})
|
|
633
|
+
if (found.length > 0) return found
|
|
634
|
+
node = node.parentElement
|
|
635
|
+
}
|
|
636
|
+
return []
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Read this conversation's current guest, preferring the one actually drawn. */
|
|
640
|
+
function guestStateFor(sessionId) {
|
|
641
|
+
const strip = tabStripFor(sessionId)
|
|
642
|
+
if (strip === null) return { panel: false, guestId: null, url: "" }
|
|
643
|
+
const guests = guestsInPanel(sessionId)
|
|
644
|
+
if (guests.length === 0) return { panel: true, guestId: null, url: "" }
|
|
645
|
+
// A sidebar keeps every tab mounted (`keepMounted`), so a background tab's guest is
|
|
646
|
+
// in the tree too. Prefer the one with a drawn box; fall back to the newest.
|
|
647
|
+
const drawn = guests.find(view => {
|
|
648
|
+
const rect = view.getBoundingClientRect()
|
|
649
|
+
return rect.width > 0 && rect.height > 0
|
|
650
|
+
})
|
|
651
|
+
const chosen = drawn === undefined ? guests[guests.length - 1] : drawn
|
|
652
|
+
let url = ""
|
|
653
|
+
try { url = String(chosen.getURL ? chosen.getURL() : "") } catch (error) { url = "" }
|
|
654
|
+
try { return { panel: true, guestId: chosen.getWebContentsId(), url } }
|
|
655
|
+
catch (error) { return { panel: true, guestId: null, url: "" } }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Publish the service every host-side panel request goes through.
|
|
660
|
+
*
|
|
661
|
+
* The binding is separate from the publication so the API exists from the first
|
|
662
|
+
* apply — a request arriving before `sidebarRight` is ready gets a named reason
|
|
663
|
+
* rather than a missing global, which is indistinguishable from a bridge that
|
|
664
|
+
* never loaded.
|
|
665
|
+
*/
|
|
666
|
+
function installPanelService(ctx) {
|
|
667
|
+
let sidebarRight = null
|
|
668
|
+
// `inject` is how a client plugin waits for a service to appear. A host that does not offer it
|
|
669
|
+
// — an older composition, or a harness that mounted this bundle for its settings panel alone —
|
|
670
|
+
// must still get the panel, so the wait is skipped and the published API names the missing
|
|
671
|
+
// service when it is called. Failing the whole apply here would take the settings panel down
|
|
672
|
+
// with it, which is exactly the shape of regression this file has been bitten by before.
|
|
673
|
+
if (typeof ctx.inject === "function") {
|
|
674
|
+
ctx.inject(["sidebarRight"], (scope) => {
|
|
675
|
+
sidebarRight = scope.sidebarRight
|
|
676
|
+
scope.effect(() => () => {
|
|
677
|
+
if (sidebarRight === scope.sidebarRight) sidebarRight = null
|
|
678
|
+
}, "dsh-builtin-browser: conversation panel binding")
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
const api = {
|
|
682
|
+
/** @returns what happened: an existing guest, a newly opened tab, or a named refusal. */
|
|
683
|
+
openPanel(sessionId, url) {
|
|
684
|
+
const wanted = typeof sessionId === "string" ? sessionId : ""
|
|
685
|
+
if (!/^session-/.test(wanted)) {
|
|
686
|
+
return { ok: false, reason: "not a conversation id: " + JSON.stringify(sessionId), panel: false, guestId: null }
|
|
687
|
+
}
|
|
688
|
+
if (sidebarRight === null || typeof sidebarRight.openTabIn !== "function") {
|
|
689
|
+
return { ok: false, reason: "the shell's sidebar service is not available", panel: false, guestId: null }
|
|
690
|
+
}
|
|
691
|
+
const state = guestStateFor(wanted)
|
|
692
|
+
if (state.guestId !== null) {
|
|
693
|
+
return { ok: true, created: false, panel: true, guestId: state.guestId, url: state.url }
|
|
694
|
+
}
|
|
695
|
+
// A mounted panel is NOT required to place a tab.
|
|
696
|
+
//
|
|
697
|
+
// `openTabIn` acts on the conversation's own surface store, and the sidebar-right service
|
|
698
|
+
// keeps one for every conversation the shell has shown — "reaches any session's store by
|
|
699
|
+
// id … on screen or not". What a DISPLAYED conversation adds is the DOM to read the guest
|
|
700
|
+
// back out of, which is a separate question. So the tab is placed either way, and `tabsIn`
|
|
701
|
+
// (a public method) is what says whether it actually landed: `openTabIn` SILENTLY ignores a
|
|
702
|
+
// conversation whose store was never minted, and a silent no-op must never be reported as
|
|
703
|
+
// success.
|
|
704
|
+
let before = 0
|
|
705
|
+
try { before = sidebarRight.tabsIn(wanted).length } catch (error) { before = 0 }
|
|
706
|
+
try {
|
|
707
|
+
sidebarRight.openTabIn(wanted, "browser", url ? { params: { url: String(url) } } : {})
|
|
708
|
+
} catch (error) {
|
|
709
|
+
return { ok: false, reason: String((error && error.message) || error), panel: state.panel, guestId: null }
|
|
710
|
+
}
|
|
711
|
+
let placed = 0
|
|
712
|
+
try { placed = sidebarRight.tabsIn(wanted).length } catch (error) { placed = before }
|
|
713
|
+
// `openTabIn` DEDUPLICATES, so "the count grew" is the wrong test for "is this
|
|
714
|
+
// conversation's surface mounted".
|
|
715
|
+
//
|
|
716
|
+
// Placing a page kind the conversation already holds leaves the count unchanged — the shell
|
|
717
|
+
// reveals the tab it has. Testing for growth therefore reported a conversation that already
|
|
718
|
+
// held the page as UNMOUNTED, and refused to drive a page that was right there: that is the
|
|
719
|
+
// "it will not reopen what it already opened" behaviour, and it bites hardest on the second
|
|
720
|
+
// open of an address, which is exactly when a human expects a fast answer.
|
|
721
|
+
//
|
|
722
|
+
// A count that started at zero and stayed there is the real signature of a surface the
|
|
723
|
+
// shell has never minted, because that is the only case where `openTabIn` is a silent no-op.
|
|
724
|
+
if (placed <= before && before === 0) {
|
|
725
|
+
// Name what was FOUND, not just what was missing: "no panel" has two very different
|
|
726
|
+
// causes — the shell has mounted no sidebar for this conversation (nothing this side can
|
|
727
|
+
// do), or it writes its session id somewhere this lookup does not read (fixable) — and a
|
|
728
|
+
// refusal that says only "not mounted" cannot tell them apart.
|
|
729
|
+
const strips = Array.from(document.querySelectorAll("[class*=_tabStrip]"))
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
reason: "this conversation's sidebar is not mounted, so a panel cannot be opened in it",
|
|
733
|
+
panel: state.panel,
|
|
734
|
+
guestId: null,
|
|
735
|
+
found: {
|
|
736
|
+
wanted: wanted,
|
|
737
|
+
strips: strips.length,
|
|
738
|
+
owners: strips.slice(0, 8).map(strip => sessionIdOfElement(strip)),
|
|
739
|
+
reactKeys: strips.length === 0 ? [] : Object.keys(strips[0]).filter(key => key.startsWith("__react")).slice(0, 4),
|
|
740
|
+
},
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { ok: true, created: placed > before, panel: state.panel, guestId: null, tabs: placed }
|
|
744
|
+
},
|
|
745
|
+
/** Read-only: this conversation's current guest, if it has one. */
|
|
746
|
+
panelGuest(sessionId) {
|
|
747
|
+
return guestStateFor(typeof sessionId === "string" ? sessionId : "")
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
ctx.effect(() => {
|
|
751
|
+
globalThis.__dshBuiltinBrowser = api
|
|
752
|
+
return () => {
|
|
753
|
+
if (globalThis.__dshBuiltinBrowser === api) delete globalThis.__dshBuiltinBrowser
|
|
754
|
+
}
|
|
755
|
+
}, "dsh-builtin-browser: conversation panel service")
|
|
756
|
+
}
|
|
757
|
+
|
|
368
758
|
function apply(ctx) {
|
|
759
|
+
// Before anything renders: the rows need a hover state, and inline styles cannot express
|
|
760
|
+
// one. Idempotent, so a re-apply does not stack copies.
|
|
761
|
+
installStyles()
|
|
762
|
+
installPanelService(ctx)
|
|
369
763
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-builtin-browser: dictionaries")
|
|
370
764
|
const t = ctx.locale.bind(NS)
|
|
371
765
|
translate = t
|
|
@@ -106,11 +106,31 @@ ${MARKER} pages.
|
|
|
106
106
|
// Installed by apps/desktop/bridge/install.mjs — re-run it after a desktop upgrade.
|
|
107
107
|
// A bridge failure is never fatal to the shell.
|
|
108
108
|
app.whenReady().then(async () => {
|
|
109
|
+
const bridgeUrl = new URL("./plugin-browser-bridge.js", import.meta.url);
|
|
110
|
+
const loadBridge = async (why) => {
|
|
111
|
+
try {
|
|
112
|
+
// A plain import is cached for the process lifetime, so an edited bridge
|
|
113
|
+
// would need a shell restart. The stamp makes every load read the current
|
|
114
|
+
// bytes; start() itself stops the previous instance, so this reloads in place.
|
|
115
|
+
const bridge = await import(bridgeUrl.href + "?v=" + Date.now());
|
|
116
|
+
bridge.start();
|
|
117
|
+
console.log("[dsh-browser-bridge] started" + (why ? " (" + why + ")" : ""));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error("[dsh-browser-bridge] start failed:", error);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await loadBridge();
|
|
123
|
+
// Watch the module so an edit reloads it in place instead of forcing a shell
|
|
124
|
+
// restart. Debounced; a failure here is never fatal to the shell.
|
|
109
125
|
try {
|
|
110
|
-
const
|
|
111
|
-
|
|
126
|
+
const { watch } = await import("node:fs");
|
|
127
|
+
let timer = null;
|
|
128
|
+
watch(bridgeUrl, { persistent: false }, () => {
|
|
129
|
+
if (timer) clearTimeout(timer);
|
|
130
|
+
timer = setTimeout(() => void loadBridge("file changed"), 300);
|
|
131
|
+
});
|
|
112
132
|
} catch (error) {
|
|
113
|
-
console.error("[dsh-browser-bridge]
|
|
133
|
+
console.error("[dsh-browser-bridge] watch failed:", error);
|
|
114
134
|
}
|
|
115
135
|
});
|
|
116
136
|
`
|
|
@@ -273,9 +293,49 @@ if (revert) {
|
|
|
273
293
|
reverted = true
|
|
274
294
|
}
|
|
275
295
|
if (existsSync(backupPath)) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
// Refuse when the backup no longer matches the tree it would overwrite.
|
|
297
|
+
//
|
|
298
|
+
// The backup is taken once and then kept, so after a desktop upgrade it holds the PREVIOUS
|
|
299
|
+
// build's main.js. Restoring it would silently roll the app back to that build's code — and
|
|
300
|
+
// it would also discard any patch another tool applied in the meantime, which is not a
|
|
301
|
+
// hypothetical here: `dsh-purge` patches this same file, so a blind revert erases its work
|
|
302
|
+
// while reporting success. A matching current file is the only case where the restore is
|
|
303
|
+
// unambiguously right; anything else needs a human to decide.
|
|
304
|
+
let backedUp
|
|
305
|
+
let current
|
|
306
|
+
try {
|
|
307
|
+
backedUp = readFileSync(backupPath)
|
|
308
|
+
current = readFileSync(mainPath)
|
|
309
|
+
} catch {
|
|
310
|
+
backedUp = undefined
|
|
311
|
+
current = undefined
|
|
312
|
+
}
|
|
313
|
+
const identical = backedUp !== undefined && current !== undefined && backedUp.equals(current)
|
|
314
|
+
const stillHasOurMarker = current !== undefined && current.includes(MARKER)
|
|
315
|
+
// A backup taken BEFORE the file was patched by anything else is not a clean restore
|
|
316
|
+
// point: reverting to it discards those patches. The marker test alone does not catch
|
|
317
|
+
// this, because the current file legitimately carries our marker — that is exactly what
|
|
318
|
+
// this script put there — while the backup predates a patch by another tool.
|
|
319
|
+
// `dsh-purge` patches this same file, so on this machine that is not hypothetical.
|
|
320
|
+
const backupHasOurMarker = backedUp !== undefined && backedUp.includes(MARKER)
|
|
321
|
+
if (!identical && stillHasOurMarker && !backupHasOurMarker) {
|
|
322
|
+
console.error(`WARNING: ${backupPath} predates the bridge patch, so it is not a clean`)
|
|
323
|
+
console.error(` restore point for ${mainPath}. Reverting would also discard anything`)
|
|
324
|
+
console.error(' patched in between (dsh-purge patches this same file). Refreshing the')
|
|
325
|
+
console.error(' backup from the current file instead, so a later --revert is safe.')
|
|
326
|
+
writeFileSync(backupPath, current ?? Buffer.alloc(0))
|
|
327
|
+
reverted = true
|
|
328
|
+
} else if (!identical && !stillHasOurMarker) {
|
|
329
|
+
console.error(`WARNING: ${mainPath} has changed since the backup was taken, and it does`)
|
|
330
|
+
console.error(' not contain the bridge import either — something else has edited it')
|
|
331
|
+
console.error(' (a desktop upgrade, or another patch tool). Restoring the backup would')
|
|
332
|
+
console.error(` discard that. The backup is at ${backupPath}; restore it by hand if that`)
|
|
333
|
+
console.error(' is what you want. Skipping this file.')
|
|
334
|
+
} else {
|
|
335
|
+
copyFileSync(backupPath, mainPath)
|
|
336
|
+
console.log(`restored ${mainPath} from ${backupPath}`)
|
|
337
|
+
reverted = true
|
|
338
|
+
}
|
|
279
339
|
}
|
|
280
340
|
if (existsSync(cliShimBackup)) {
|
|
281
341
|
copyFileSync(cliShimBackup, cliShimPath)
|