dsh-inner-browser-use 0.11.44 → 0.18.0

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/client.js CHANGED
@@ -1,177 +1,12 @@
1
1
  /**
2
- * dsh-inner-browser-use — client half.
2
+ * dsh-inner-browser-use — the client half.
3
3
  *
4
- * Registers a sidebar tab whose page is a real browser guest: the Desktop main
5
- * process leases it, so it is the same inner browser the product ships, not a
6
- * window this plugin opens. The tab holds the only end that can touch that
7
- * page, so it is also the Client end of the tool wire: it keeps a downlink
8
- * stream open, runs the operation each frame asks for inside the page, and
9
- * posts the answer back up.
10
- *
11
- * Operations the Host can ask for: navigate, view, click, type, script. // ui-copy-ignore
12
- */
13
-
14
- /**
15
- * Whether a call for this chat may bring the right column forward. Only the chat
16
- * the shell says is on screen, or a page already showing, may; anyone else is
17
- * answered without touching the panel.
18
- * @param {string} sessionId - chat the call belongs to.
19
- * @param {{current: string, routed: string, onScreen: Set<string>}} view - who is on screen.
20
- * @returns {boolean} true when the panel may open for it.
21
- */
22
- function revealAllowed(sessionId, view) {
23
- if (sessionId === '') return true
24
- if (view.current !== '' && sessionId === view.current) return true
25
- if (view.routed !== '' && sessionId === view.routed) return true
26
- return view.onScreen.has(sessionId)
27
- }
28
-
29
- /**
30
- * One bridge route, named once: both halves of this page answer on the same path.
31
- */
32
- /** The page each chat last asked for; a reload leaves its guest at about:blank. @type {Map<string, string>} */
33
- const lastUrl = new Map()
34
- const pageKey = (session) => 'dshib:url:' + String(session ?? '')
35
- /** The page this chat was last told to open, remembered past a remount. */
36
- function rememberedPage(session) {
37
- const key = pageKey(session)
38
- try {
39
- const kept = window.localStorage.getItem(key)
40
- if (kept !== null && kept !== '') return kept
41
- } catch {
42
- /* storage is not always reachable; the in-memory map is enough */
43
- }
44
- return lastUrl.get(String(session ?? '')) ?? ''
45
- }
46
- function rememberPage(session, url) {
47
- lastUrl.set(String(session ?? ''), url)
48
- try {
49
- window.localStorage.setItem(pageKey(session), url)
50
- } catch {
51
- /* see rememberedPage */
52
- }
53
- }
54
-
55
- const BRIDGE_ROUTE = '/dsh-inner-browser-use'
56
-
57
- /**
58
- * Wait for `work`, but not forever.
59
- * @param {Promise<any>} work - what to wait for.
60
- * @param {number} ms - how long it may take.
61
- * @param {string} label - what timed out, for the log.
62
- * @returns {Promise<any>} the answer.
63
- */
64
- function within(work, ms, label) {
65
- return new Promise((resolve, reject) => {
66
- const timer = setTimeout(() => reject(new Error(label + ' timed out')), ms)
67
- work.then((value) => { clearTimeout(timer); resolve(value) }, (error) => { clearTimeout(timer); reject(error) })
68
- })
69
- }
70
-
71
- /**
72
- * The origin the guest is on, asked again if the page was mid-load.
73
- * @param {any} el - the guest webview.
74
- * @returns {Promise<string>} origin, or empty when the page has none yet.
75
- */
76
- async function pageOrigin(el) {
77
- for (const pause of [0, 500]) {
78
- if (pause > 0) await new Promise((resolve) => setTimeout(resolve, pause))
79
- try {
80
- const origin = await within(el.executeJavaScript('location.origin', true), 4000, 'origin')
81
- if (typeof origin === 'string') return origin
82
- } catch {
83
- /* a page that is still parsing is asked again */
84
- }
85
- }
86
- return ''
87
- }
88
-
89
- /**
90
- * The cookies the guest itself can read, keyed to the origin it is on.
91
- * @param {any} el - the guest webview.
92
- * @returns {Promise<Record<string, unknown>[]>} cookies worth keeping.
93
- */
94
- async function cookiesOf(el) {
95
- const raw = await within(el.executeJavaScript('document.cookie', true), 4000, 'cookie read')
96
- const origin = await pageOrigin(el)
97
- if (typeof raw !== 'string' || raw === '' || typeof origin !== 'string') return []
98
- if (origin === '' || origin === 'null' || origin.startsWith('about:')) return []
99
- const out = []
100
- for (const part of raw.split(';')) {
101
- const at = part.indexOf('=')
102
- if (at <= 0) continue
103
- out.push({ name: part.slice(0, at).trim(), value: part.slice(at + 1).trim(), url: origin })
104
- }
105
- return out
106
- }
107
-
108
- /**
109
- * Keep the cookies the guest holds now, so the next run starts signed in.
110
- * @param {any} el - the guest webview.
111
- * @param {string} key - workspace key the guest was leased with.
112
- * @returns {Promise<number>} how many cookies were kept.
4
+ * It adds one tab: a picture of a headless Chrome, mirrored into the column.
5
+ * A click, wheel or key on that picture is forwarded to that browser as real
6
+ * input, and the black arrow shows where the agent last acted.
113
7
  */
114
- async function cookiesSave(el, key, probe) {
115
- if (key === '') return 0
116
- try {
117
- const cookies = await cookiesOf(el)
118
- if (cookies.length === 0) return 0
119
- /* One workspace holds the pages of several sites: this page replaces what
120
- it holds itself, and leaves every other site's sign-in alone. */
121
- const origin = String(cookies[0].url)
122
- let others = []
123
- try {
124
- const read = await within(fetch(`${BRIDGE_ROUTE}/cookies?key=${encodeURIComponent(key)}`), 3000, 'cookie read')
125
- if (read.ok) {
126
- const body = await read.json()
127
- others = (Array.isArray(body?.cookies) ? body.cookies : []).filter((each) => each?.url !== origin)
128
- }
129
- } catch {
130
- /* an unreadable store is replaced, not merged into */
131
- }
132
- const response = await within(fetch(`${BRIDGE_ROUTE}/cookies?key=${encodeURIComponent(key)}`, {
133
- method: 'POST',
134
- body: JSON.stringify({ cookies: [...others, ...cookies] }),
135
- }), 3000, 'cookie write')
136
- if (!response.ok) return 0
137
- const body = await response.json()
138
- return Number(body?.kept ?? 0)
139
- } catch (error) {
140
- probe?.({ why: 'cookies-save-failed', message: String(error?.message ?? error).slice(0, 80) })
141
- return 0
142
- }
143
- }
144
8
 
145
- /**
146
- * Put back the cookies kept for this workspace's current origin.
147
- * @param {any} el - the guest webview.
148
- * @param {string} key - workspace key the guest was leased with.
149
- * @returns {Promise<number>} how many cookies were put back.
150
- */
151
- async function cookiesRestore(el, key, probe) {
152
- if (key === '') return 0
153
- try {
154
- const origin = await pageOrigin(el)
155
- if (origin === '' || origin === 'null') return 0
156
- const response = await within(fetch(`${BRIDGE_ROUTE}/cookies?key=${encodeURIComponent(key)}`), 3000, 'cookie read')
157
- if (!response.ok) return 0
158
- const body = await response.json()
159
- const cookies = (Array.isArray(body?.cookies) ? body.cookies : []).filter(
160
- (each) => typeof each?.url === 'string' && each.url === origin,
161
- )
162
- if (cookies.length === 0) return 0
163
- /* One assignment per cookie: a joined string sets only its first pair. */
164
- const script = cookies
165
- .map((each) => `document.cookie = ${JSON.stringify(`${String(each.name)}=${String(each.value)}; path=/`)}`)
166
- .join('; ')
167
- await within(el.executeJavaScript(script, true), 2000, 'cookie restore')
168
- probe?.({ why: 'cookies-restored', origin, count: cookies.length })
169
- return cookies.length
170
- } catch (error) {
171
- probe?.({ why: 'cookies-restore-failed', message: String(error?.message ?? error).slice(0, 80) })
172
- return 0
173
- }
174
- }
9
+ var BRIDGE_ROUTE = '/dsh-inner-browser-use'
175
10
 
176
11
  window.__ModuleLoader__.load({
177
12
  id: 'dsh-inner-browser-use',
@@ -180,3178 +15,540 @@ window.__ModuleLoader__.load({
180
15
  const h = React.createElement
181
16
 
182
17
  const NS = 'inner-browser-use'
183
- const KIND = 'inner-browser-use'
184
- const ID = 'dsh-inner-browser-use'
18
+ /* The sidecar's tab is a picture of another Chrome, not a guest of this app:
19
+ it is named apart from the product's own browser so the two never swap. */
20
+ const SIDE_ID = 'dsh-inner-browser-use:sidecar'
21
+ const SIDE_KIND = 'inner-browser-sidecar'
22
+ const SIDE_VIEW = { width: 1440, height: 900 }
23
+ /* The black arrow, inline so the pane needs no image of its own. */
24
+ const SIDE_POINTER = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 2.5 20.5 11l-7.2 1.6L9.8 21z" fill="#111" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/></svg>')
185
25
  const ROUTE = BRIDGE_ROUTE
186
26
  const STYLE_ID = 'dsh-inner-browser-use-css'
187
- const ALIVE_MS = 20_000
188
- /* The ask stream belongs to the half that was serving when it opened. After
189
- that half reloads, the stream still looks open and reaches nothing, so the
190
- beat that names a new one reopens it. */
191
- let streamEpoch = ''
192
- let reopenStream = null
27
+ /** Set by the sidecar tab while it is registered: opens the mirror in the chat that asked. */
28
+ let sidecarOpener = null
193
29
  const SLOT = 'sidebar.right.pane.tab'
194
30
  const TITLE_SLOT = 'sidebar.right.pane.tab.title'
195
- const WATCH_SLOT = 'conversation.session.header.utilities'
196
31
 
197
32
  const DICT = {
198
33
  zh: {
199
- 'type.label': '内置浏览器',
200
- 'guide.description': '让助手在侧边栏里操作页面',
201
- 'phase.opening': '正在打开…',
202
- 'phase.ready': '已连接',
203
- 'phase.closed': '已关闭',
204
- 'error.desktopOnly': '需要在桌面版中使用',
205
- 'error.lease': '无法打开浏览器面板',
206
- 'error.captureUnavailable': '页面现在不可截图',
207
- 'pane.drag': '拖到聊天',
208
- 'pane.dragHint': '把这一页的选中文字拖进聊天',
209
- 'phase.act.select': '正在选择',
210
- 'phase.act.eval': '正在操作页面',
211
- 'phase.act.upload': '正在上传',
212
- 'phase.act.download': '正在下载',
213
- 'phase.act.shot': '正在截图',
214
- 'phase.act.scroll': '正在滚动',
215
- 'phase.act.type': '正在输入',
216
- 'phase.act.read': '正在读取页面',
217
- 'phase.act.click': '正在点击',
218
- 'phase.act.navigate': '正在打开页面',
34
+ 'side.label': '后台浏览器',
35
+ 'side.empty': '还没有打开的页面',
36
+ 'side.connecting': '正在连接…',
37
+ 'side.failed': '画面没有连上',
38
+ 'side.foreign': '这个浏览器属于发起它的会话,回到那个会话继续。',
39
+ 'side.back': '后退',
40
+ 'side.forward': '前进',
41
+ 'side.reload': '刷新',
42
+ 'side.address': '输入地址',
43
+ 'side.type': '输入文字',
219
44
  },
220
45
  en: {
221
- 'type.label': 'Inner browser',
222
- 'guide.description': 'Let the assistant drive a page in the sidebar',
223
- 'phase.opening': 'Opening…',
224
- 'phase.ready': 'Connected',
225
- 'phase.closed': 'Closed',
226
- 'error.desktopOnly': 'Requires the desktop app',
227
- 'error.lease': 'Could not open the browser panel',
228
- 'error.captureUnavailable': 'The page cannot be captured right now',
229
- 'pane.drag': 'Drag to chat',
230
- 'pane.dragHint': 'Drag the selected text of this page into the chat',
231
- 'phase.act.select': 'Choosing',
232
- 'phase.act.eval': 'Working on the page',
233
- 'phase.act.upload': 'Uploading',
234
- 'phase.act.download': 'Saving a file',
235
- 'phase.act.shot': 'Taking an image',
236
- 'phase.act.scroll': 'Scrolling',
237
- 'phase.act.type': 'Typing',
238
- 'phase.act.read': 'Reading the page',
239
- 'phase.act.click': 'Clicking',
240
- 'phase.act.navigate': 'Opening the page',
46
+ 'side.label': 'Background browser',
47
+ 'side.empty': 'No page open',
48
+ 'side.connecting': 'Connecting…',
49
+ 'side.failed': 'The picture did not connect',
50
+ 'side.foreign': 'This browser belongs to the chat that opened it.',
51
+ 'side.back': 'Back',
52
+ 'side.forward': 'Forward',
53
+ 'side.reload': 'Reload',
54
+ 'side.address': 'Enter an address',
55
+ 'side.type': 'Type here',
241
56
  },
242
57
  }
243
58
  const LANG = /^zh/i.test(typeof navigator === 'undefined' ? '' : navigator.language ?? '') ? 'zh' : 'en'
244
59
  const L = (key) => DICT[LANG][key] ?? key
245
60
 
246
61
  const CSS = [
247
- '.dshib-body{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;height:100%;position:relative;background:var(--dsw-alias-bg-base)}',
248
- '.dshib-viewport{display:flex;flex:1 1 auto;min-width:0;min-height:0;position:relative}',
249
- /* Kept off screen rather than display:none: an unrendered guest cannot
250
- answer, and this one has to answer while another chat is on screen. */
251
- '.dshib-stash{position:fixed;left:0;top:0;width:1100px;height:820px;opacity:0;z-index:-1;pointer-events:none}',
252
- '.dshib-guest{flex:1 1 auto;min-width:0;min-height:0;width:100%;height:100%;border:0;display:flex;background:var(--dsw-alias-bg-base)}',
253
- '.dshib-state{flex:none;display:flex;align-items:center;gap:6px;padding:6px 10px;font-size:12px;color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-subtle)}',
254
- '.dshib-state[data-tone="error"]{color:var(--dsw-alias-label-danger)}',
255
- '.dshib-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-tertiary);flex:none}',
256
- '.dshib-state[data-tone="ready"] .dshib-dot{background:var(--dsw-alias-label-success)}',
257
- '.dshib-state[data-tone="error"] .dshib-dot{background:var(--dsw-alias-label-danger)}',
258
- '.dshib-href{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;direction:rtl;background:transparent;border:0;padding:0;font:inherit;color:var(--dsh-text-2,inherit)}',
259
- '.dshib-href:focus{outline:none;text-align:left;direction:ltr;color:var(--dsh-text-1,inherit)}',
260
- '.dshib-drag{flex:none;display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:999px;',
261
- 'border:1px solid var(--dsw-alias-border-subtle);color:var(--dsw-alias-label-secondary);cursor:grab;',
262
- 'user-select:none;white-space:nowrap}',
263
- '.dshib-drag:active{cursor:grabbing}',
62
+ /* The sidecar pane: a picture of another browser, with its own pointer. */
63
+ '.dshib-side{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;height:100%;background:var(--dsw-alias-bg-base)}',
64
+ '.dshib-side-bar{flex:none;display:flex;align-items:center;gap:4px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-subtle)}',
65
+ '.dshib-side-key{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:1;cursor:pointer}',
66
+ '.dshib-side-key:hover{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
67
+ '.dshib-side-url{flex:1 1 auto;min-width:0;border:1px solid transparent;border-radius:6px;background:var(--dsw-alias-bg-subtle);padding:4px 8px;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);outline:none}',
68
+ '.dshib-side-url:focus{border-color:var(--dsw-alias-border-strong);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}',
69
+ '.dshib-side-stage{flex:1 1 auto;position:relative;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--dsw-alias-bg-base);outline:none;cursor:default}',
70
+ '.dshib-side-frame{display:block;user-select:none;-webkit-user-drag:none;cursor:default}',
71
+ '.dshib-side-ime{position:absolute;width:1px;height:1px;padding:0;margin:0;border:0;resize:none;overflow:hidden;opacity:0;pointer-events:none}',
72
+ /* The black arrow is the agent's pointer, not the person's cursor: both are
73
+ on screen at once, and moving the mouse never takes the arrow over. */
74
+ '.dshib-side-ptr{position:absolute;width:20px;height:20px;pointer-events:none;z-index:2;transform:translate(-2px,-2px);transition:left 60ms linear,top 60ms linear;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}',
75
+ '.dshib-side-note{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:0 16px;text-align:center}',
264
76
  ].join('')
265
77
 
266
- /**
267
- * The page runtime. Injected on every call, so it survives full navigations
268
- * without any load listener: it installs the pointer once, then answers one
269
- * operation.
270
- */
271
- const RUNTIME = String.raw`
272
- const D = document, W = window
273
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
274
- const SEL = 'a[href],button,input,textarea,select,summary,label,[role="button"],[role="link"],[role="tab"],[role="checkbox"],[role="menuitem"],[contenteditable="true"],[tabindex]:not([tabindex="-1"])'
275
- const visible = (el) => {
276
- const r = el.getBoundingClientRect()
277
- if (r.width < 2 || r.height < 2) return false
278
- const s = W.getComputedStyle(el)
279
- return s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0'
280
- }
281
- const clip = (s, n) => (s || '').replace(/\s+/g, ' ').trim().slice(0, n)
282
- const chrome = () => {
283
- if (D.getElementById('__dsh_ib_css') !== null) return
284
- const style = D.createElement('style')
285
- style.id = '__dsh_ib_css'
286
- style.textContent = [
287
- '#__dsh_ib_ptr{position:fixed;left:0;top:0;width:26px;height:26px;z-index:2147483647;pointer-events:none;',
288
- 'transform:translate3d(-60px,-60px,0);opacity:1;',
289
- 'transition:transform 240ms cubic-bezier(.2,.7,.3,1),opacity 400ms ease,scale 120ms ease;',
290
- 'will-change:transform;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35)) drop-shadow(0 6px 12px rgba(0,0,0,.22))}',
291
- '#__dsh_ib_ptr.away{opacity:0}',
292
- '#__dsh_ib_ptr.press{scale:.84}',
293
- /* A soft mark under the arrow: it trails the tip instead of snapping to
294
- it, so a press reads as a movement rather than a jump. */
295
- '#__dsh_ib_haze{position:fixed;left:0;top:0;width:64px;height:64px;margin:-32px 0 0 -32px;',
296
- 'border-radius:50%;z-index:2147483645;pointer-events:none;opacity:.55;',
297
- 'background:radial-gradient(circle,rgba(0,0,0,.30) 0%,rgba(0,0,0,.14) 42%,rgba(0,0,0,0) 70%);',
298
- 'transform:translate3d(-90px,-90px,0);transition:transform 340ms cubic-bezier(.25,.8,.35,1),opacity 500ms ease}',
299
- '#__dsh_ib_haze.away{opacity:0}',
300
- '#__dsh_ib_rip{position:fixed;left:0;top:0;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;',
301
- 'z-index:2147483646;pointer-events:none;opacity:0;background:rgba(17,17,19,.42);',
302
- 'transform:translate3d(-60px,-60px,0) scale(.2)}',
303
- '#__dsh_ib_rip.on{animation:__dsh_ib_rip_a 520ms ease-out forwards}',
304
- '@keyframes __dsh_ib_rip_a{0%{opacity:.75;transform:translate3d(var(--x),var(--y),0) scale(.22)}',
305
- '100%{opacity:0;transform:translate3d(var(--x),var(--y),0) scale(1.7)}}',
306
- '[data-dsh-ib-ref]{outline:2px solid rgba(17,17,19,.72)!important;outline-offset:1px!important}',
307
- ].join('')
308
- ;(D.head || D.documentElement).appendChild(style)
309
- const haze = D.createElement('div')
310
- haze.id = '__dsh_ib_haze'
311
- haze.setAttribute('aria-hidden', 'true')
312
- D.documentElement.appendChild(haze)
313
- const ptr = D.createElement('div')
314
- ptr.id = '__dsh_ib_ptr'
315
- ptr.setAttribute('aria-hidden', 'true')
316
- /* A dark arrow with a light edge stays legible on a white page and on a
317
- dark one; the marks under it give it a body. */
318
- ptr.innerHTML =
319
- '<svg viewBox="0 0 26 26" width="26" height="26">' +
320
- '<path d="M4 2.6l16.6 8.6-7.4 1.8-2.9 7.3z" fill="#0b0b0c" stroke="#fdfdfd" stroke-width="1.15" stroke-linejoin="round"/>' +
321
- '</svg>'
322
- D.documentElement.appendChild(ptr)
323
- const rip = D.createElement('div')
324
- rip.id = '__dsh_ib_rip'
325
- rip.setAttribute('aria-hidden', 'true')
326
- D.documentElement.appendChild(rip)
327
- }
328
- let away = 0
329
- /* Where the arrow is standing, so a report can say the press was seen. */
330
- const at = () => {
331
- const last = W.__dsh_ib_last
332
- return last === undefined ? 'no pointer' : Math.round(last.x) + ',' + Math.round(last.y)
333
- }
334
- const aim = async (el) => {
335
- chrome()
336
- const r = el.getBoundingClientRect()
337
- const x = r.left + r.width / 2, y = r.top + r.height / 2
338
- W.__dsh_ib_last = { x: x, y: y }
339
- const ptr = D.getElementById('__dsh_ib_ptr')
340
- const haze = D.getElementById('__dsh_ib_haze')
341
- const rip = D.getElementById('__dsh_ib_rip')
342
- clearTimeout(away)
343
- if (ptr) {
344
- ptr.classList.remove('away')
345
- ptr.style.transform = 'translate3d(' + (x - 4) + 'px,' + (y - 2) + 'px,0)'
346
- /* The pointer stays where it acted long enough to be seen, then leaves. */
347
- away = setTimeout(() => {
348
- ptr.classList.add('away')
349
- haze?.classList.add('away')
350
- }, 20000)
351
- }
352
- if (haze) {
353
- haze.classList.remove('away')
354
- haze.style.transform = 'translate3d(' + x + 'px,' + y + 'px,0)'
355
- }
356
- await sleep(210)
357
- if (rip) {
358
- rip.style.setProperty('--x', (x - 14) + 'px')
359
- rip.style.setProperty('--y', (y - 14) + 'px')
360
- rip.classList.remove('on')
361
- void rip.offsetWidth
362
- rip.classList.add('on')
363
- }
364
- await sleep(120)
365
- return { x: Math.round(x), y: Math.round(y) }
366
- }
367
- /* The control that sends what is typed: a form's submit sits beside the
368
- field, the pointer lands on it, so the page shows the same press the
369
- agent made. */
370
- const sendControl = (field) => {
371
- const named = /发送|send|submit/i
372
- const marks = (el) => [el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('data-testid'), el.className, String(el.textContent ?? '')].join(' ')
373
- let nearEl = field
374
- for (let up = 0; up < 4 && nearEl !== null && nearEl !== D.body; up += 1) nearEl = nearEl.parentElement
375
- const marked = (pool) => pool.filter((el) => visible(el) && el.disabled !== true && named.test(marks(el)))
376
- const near = marked(Array.from((nearEl?.querySelectorAll?.('button,[role="button"]') ?? [])))
377
- if (near.length > 0) return near[0]
378
- const anywhere = marked(Array.from(D.querySelectorAll('button,[role="button"]')))
379
- return anywhere.length > 0 ? anywhere[0] : null
380
- }
381
- const index = () => {
382
- const found = []
383
- for (const el of D.querySelectorAll(SEL)) {
384
- if (!visible(el) || el.disabled === true) continue
385
- found.push(el)
386
- if (found.length >= 400) break
387
- }
388
- found.forEach((el, i) => el.setAttribute('data-dsh-ib-ref', String(i + 1)))
389
- return found
390
- }
391
- const label = (el) => {
392
- const tag = el.tagName.toLowerCase()
393
- const kind = tag === 'a' ? 'link'
394
- : tag === 'button' || el.getAttribute('role') === 'button' ? 'button'
395
- : tag === 'input' || tag === 'textarea' || el.isContentEditable === true ? 'input'
396
- : tag === 'select' ? 'select'
397
- : tag === 'label' ? 'label'
398
- : (el.getAttribute('role') || tag)
399
- const name = el.getAttribute('aria-label') || el.getAttribute('placeholder')
400
- || clip(el.innerText, 40) || el.getAttribute('title') || el.getAttribute('name')
401
- || el.id || ''
402
- const value = el.tagName === 'INPUT' && el.type !== 'password' && el.value ? ' = ' + clip(el.value, 30) : ''
403
- return '[' + kind + '] ' + (name || '(no label)') + value
404
- }
405
- const view = () => {
406
- const found = index()
407
- const lines = []
408
- lines.push('URL ' + location.href)
409
- lines.push('Title ' + (D.title || '(none)'))
410
- lines.push('Controls ' + found.length)
411
- found.slice(0, 80).forEach((el, i) => lines.push('#' + (i + 1) + ' ' + label(el)))
412
- if (found.length > 80) lines.push('+' + (found.length - 80) + ' more')
413
- const text = clip(D.body ? D.body.innerText : '', 2400)
414
- if (text !== '') lines.push('Text ' + text)
415
- for (const el of found) el.removeAttribute('data-dsh-ib-ref')
416
- return lines.join('\n')
417
- }
418
- const find = (ref) => {
419
- const el = D.querySelector('[data-dsh-ib-ref="' + ref + '"]')
420
- if (el !== null) return el
421
- const found = index()
422
- const hit = found[Number(ref) - 1]
423
- if (hit === undefined) throw new Error('no control #' + ref + ' on this page')
424
- return hit
425
- }
426
- const setValue = (el, text) => {
427
- const proto = el.tagName === 'TEXTAREA' ? W.HTMLTextAreaElement.prototype
428
- : el.tagName === 'SELECT' ? W.HTMLSelectElement.prototype : W.HTMLInputElement.prototype
429
- const desc = Object.getOwnPropertyDescriptor(proto, 'value')
430
- if (desc && desc.set) desc.set.call(el, text)
431
- else el.value = text
432
- el.dispatchEvent(new Event('input', { bubbles: true }))
433
- el.dispatchEvent(new Event('change', { bubbles: true }))
434
- }
435
- const press = (el, key) => {
436
- const init = { key, code: key, keyCode: key === 'Enter' ? 13 : 0, which: key === 'Enter' ? 13 : 0, bubbles: true }
437
- el.dispatchEvent(new KeyboardEvent('keydown', init))
438
- el.dispatchEvent(new KeyboardEvent('keypress', init))
439
- el.dispatchEvent(new KeyboardEvent('keyup', init))
440
- }
441
- const keep = () => (W.__dshib_dl = W.__dshib_dl || {})
442
- const join64 = (parts) => {
443
- let size = 0
444
- for (const part of parts) size += part.length
445
- const all = new Uint8Array(size)
446
- let at = 0
447
- for (const part of parts) { all.set(part, at); at += part.length }
448
- let s = ''
449
- for (let i = 0; i < all.length; i += 8192) s += String.fromCharCode.apply(null, all.subarray(i, i + 8192))
450
- return W.btoa(s)
451
- }
452
- const blob64 = async (blob) => {
453
- const bytes = new Uint8Array(await blob.arrayBuffer())
454
- let s = ''
455
- for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192))
456
- return W.btoa(s)
457
- }
458
- const fileUrl = (a) => {
459
- if (typeof a.url === 'string' && a.url !== '') return a.url
460
- if (a.ref === undefined) throw new Error('give a url, or the control that links to the file')
461
- const el = find(a.ref)
462
- const holder = el.closest('a[href]') || el
463
- const href = holder.getAttribute('href') || holder.getAttribute('src') || holder.src || ''
464
- if (href === '' || href.startsWith('javascript:')) throw new Error('#' + a.ref + ' carries no file address')
465
- return new URL(href, location.href).href
466
- }
467
- const fileName = (a, url, res) => {
468
- if (typeof a.name === 'string' && a.name !== '') return a.name
469
- const sent = res.headers.get('content-disposition') || ''
470
- const named = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(sent)
471
- if (named !== null) return decodeURIComponent(named[1])
472
- const tail = new URL(url).pathname.split('/').filter(Boolean).pop() || ''
473
- if (tail !== '') return decodeURIComponent(tail)
474
- /* A page with no name in its address is still worth a file: its type
475
- gives it one the desktop knows how to open. */
476
- const kind = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
477
- const ext = {
478
- 'text/html': 'html', 'application/xhtml+xml': 'html', 'text/plain': 'txt', 'text/markdown': 'md',
479
- 'application/json': 'json', 'application/pdf': 'pdf', 'image/png': 'png', 'image/jpeg': 'jpg',
480
- 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'application/zip': 'zip',
481
- }[kind]
482
- return 'page.' + (ext ?? 'bin')
483
- }
484
- const run = async (op, a) => {
485
- chrome()
486
- if (op === 'view') return view()
487
- /* An image taken after the arrow faded should still show where the last
488
- action landed, so put it back where it was. */
489
- if (op === 'pointer') {
490
- const last = W.__dsh_ib_last
491
- const ptr = last === undefined ? null : D.getElementById('__dsh_ib_ptr')
492
- if (ptr === null) return ''
493
- clearTimeout(away)
494
- ptr.classList.remove('away')
495
- ptr.style.transform = 'translate3d(' + (last.x - 4) + 'px,' + (last.y - 2) + 'px,0)'
496
- away = setTimeout(() => ptr.classList.add('away'), 20000)
497
- return ''
498
- }
499
- if (op === 'click') {
500
- const el = find(a.ref)
501
- el.scrollIntoView({ block: 'center', inline: 'center' })
502
- const at = await aim(el)
503
- el.click()
504
- await sleep(420)
505
- return 'Clicked #' + a.ref + ' ' + label(el) + ' at ' + at.x + ',' + at.y + '\n' + view()
78
+ return {
79
+ inject: ['slots', 'locale'],
80
+ apply(ctx) {
81
+ try {
82
+ ctx.locale.register(NS, { zh: DICT.zh, 'zh-CN': DICT.zh, en: DICT.en })
83
+ } catch {
84
+ /* one locale service or none */
506
85
  }
507
- if (op === 'type') {
508
- const el = find(a.ref)
509
- el.scrollIntoView({ block: 'center', inline: 'center' })
510
- await aim(el)
511
- el.focus()
512
- if (el.isContentEditable === true) {
513
- const room = D.createRange()
514
- room.selectNodeContents(el)
515
- const held = W.getSelection()
516
- held.removeAllRanges()
517
- held.addRange(room)
518
- let inserted = false
86
+ ctx.effect(() => {
87
+ const style = document.createElement('style')
88
+ style.id = STYLE_ID
89
+ /* A sheet is keyed by the plugin that brought it. */
90
+ style.dataset.pluginCss = 'dsh-inner-browser-use'
91
+ style.textContent = CSS
92
+ document.head.appendChild(style)
93
+ return () => style.remove()
94
+ }, 'dsh-inner-browser-use: styles')
95
+
96
+ /* The sidecar tab: the column shows it, the work happens in another process. */
97
+ ctx.inject(['sidebarRightTabs', 'sidebarRight'], (scope) => scope.effect(() => {
98
+ const tabs = scope.sidebarRightTabs
99
+ const surface = scope.sidebarRight
100
+ const disposers = []
101
+ /* The one chat that owns the mirror. Every other chat loses the tab
102
+ and gets no picture: the column is global, the browser is not. */
103
+ let ownerSession = ''
104
+ const mountedOf = () => {
519
105
  try {
520
- inserted = D.execCommand('insertText', false, a.text)
106
+ return String(surface?.mounted?.getSnapshot?.() ?? '')
521
107
  } catch {
522
- inserted = false
523
- }
524
- if (inserted !== true) {
525
- el.textContent = a.text
526
- el.dispatchEvent(new Event('input', { bubbles: true }))
108
+ return ''
527
109
  }
528
- } else {
529
- setValue(el, a.text)
530
110
  }
531
- let where = ''
532
- if (a.submit === true) {
533
- const held = () => String(el.isContentEditable === true ? el.innerText : (el.value ?? '')).trim()
534
- const before = held()
535
- let go = sendControl(el)
536
- for (let wait = 0; go === null && wait < 5; wait += 1) {
537
- await sleep(140)
538
- go = sendControl(el)
539
- }
540
- if (go !== null) {
541
- await aim(go)
542
- go.click()
543
- where = 'Pressed ' + label(go) + ' at ' + at()
544
- await sleep(460)
111
+ const closeHere = (sessionId) => {
112
+ let records = []
113
+ try {
114
+ records = surface?.tabsIn?.(sessionId) ?? []
115
+ } catch {
116
+ records = []
545
117
  }
546
- if (before !== '' && held() === before) {
547
- press(el, 'Enter')
548
- where = where === '' ? 'Pressed Enter' : where + ', then Enter'
549
- await sleep(460)
550
- if (held() === before) {
551
- const late = sendControl(el)
552
- if (late !== null && late !== go) {
553
- await aim(late)
554
- late.click()
555
- where = 'Pressed ' + label(late) + ' at ' + at()
556
- await sleep(460)
557
- }
118
+ for (const record of records) {
119
+ const kind = String(record?.kind ?? record?.record?.kind ?? '')
120
+ const id = String(record?.id ?? record?.record?.id ?? '')
121
+ if (kind !== SIDE_KIND && id !== SIDE_ID) continue
122
+ try {
123
+ if (typeof record?.close === 'function') record.close()
124
+ else if (typeof surface?.closeIn === 'function') surface.closeIn(sessionId, id)
125
+ } catch {
126
+ /* a tab already gone needs no closing */
558
127
  }
559
128
  }
560
- await sleep(200)
561
- } else {
562
- await sleep(120)
563
- }
564
- return 'Typed into #' + a.ref + ' ' + label(el) + (where === '' ? '' : '\n' + where) + '\n' + view()
565
- }
566
- if (op === 'selection') {
567
- const picked = String(W.getSelection ? W.getSelection().toString() : '')
568
- const active = D.activeElement
569
- const field = active !== null && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')
570
- && typeof active.value === 'string' && active.type !== 'password' ? String(active.value) : ''
571
- const lines = ['URL ' + location.href]
572
- if (picked.trim() !== '') lines.push('Selected ' + clip(picked, 4000))
573
- else if (field !== '') lines.push('Field ' + clip(field, 4000))
574
- else lines.push('Selected (none)')
575
- return lines.join('\n')
576
- }
577
- if (op === 'copy') {
578
- let text = String(W.getSelection ? W.getSelection().toString() : '')
579
- if (text.trim() === '' && a.ref !== undefined) {
580
- const el = find(a.ref)
581
- text = typeof el.value === 'string' ? String(el.value) : String(el.innerText || el.textContent || '')
582
- }
583
- if (text.trim() === '') throw new Error('nothing selected on this page')
584
- const area = D.createElement('textarea')
585
- area.value = text
586
- area.setAttribute('readonly', '')
587
- area.style.position = 'fixed'
588
- area.style.left = '-9999px'
589
- D.documentElement.appendChild(area)
590
- area.select()
591
- let ok = false
592
- try {
593
- ok = D.execCommand('copy')
594
- } finally {
595
- area.remove()
596
- }
597
- if (ok !== true) throw new Error('this page refuses the clipboard')
598
- return 'Copied ' + text.length + ' characters'
599
- }
600
- if (op === 'upload') {
601
- const el = find(a.ref)
602
- if (el.tagName !== 'INPUT' || el.type !== 'file') throw new Error('#' + a.ref + ' is not a file field')
603
- const binary = atob(a.base64)
604
- const bytes = new Uint8Array(binary.length)
605
- for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
606
- const file = new File([bytes], a.name, { type: a.type || 'application/octet-stream' })
607
- const tray = new DataTransfer()
608
- tray.items.add(file)
609
- el.files = tray.files
610
- el.dispatchEvent(new Event('input', { bubbles: true }))
611
- el.dispatchEvent(new Event('change', { bubbles: true }))
612
- el.scrollIntoView({ block: 'center' })
613
- await aim(el)
614
- await sleep(320)
615
- return 'Attached ' + a.name + ' to #' + a.ref + '\n' + view()
616
- }
617
- if (op === 'href') {
618
- return fileUrl({ ref: a.ref })
619
- }
620
- if (op === 'dl-open') {
621
- const url = fileUrl(a)
622
- const res = await fetch(url, { credentials: 'include', redirect: 'follow' })
623
- if (res.ok !== true) throw new Error('the page answered ' + res.status + ' for ' + url.slice(0, 160))
624
- if (res.body === null) throw new Error('this page cannot stream ' + url.slice(0, 160))
625
- const name = fileName(a, url, res)
626
- const type = res.headers.get('content-type') || ''
627
- keep()[String(a.id || 'dl')] = { reader: res.body.getReader() }
628
- return { name, type, size: Number(res.headers.get('content-length') || 0) }
629
- }
630
- if (op === 'dl-next') {
631
- const id = String(a.id || 'dl')
632
- const entry = (W.__dshib_dl || {})[id]
633
- if (entry === undefined) throw new Error('no transfer ' + id)
634
- const parts = []
635
- let size = 0
636
- while (size < 512 * 1024) {
637
- const step = await entry.reader.read()
638
- if (step.done === true) {
639
- delete W.__dshib_dl[id]
640
- return { data: join64(parts), done: true }
641
- }
642
- parts.push(step.value)
643
- size += step.value.length
644
- }
645
- return { data: join64(parts), done: false }
646
- }
647
- if (op === 'dl-watch') {
648
- /* A file the page starts on its own is read here, so the chat gets it
649
- without being told to save it twice. A file a page builds in memory
650
- is kept as it is built: asking for its address again would build a
651
- different one. */
652
- if (W.__dshib_watch === true) return 'watching'
653
- W.__dshib_watch = true
654
- W.__dshib_land = []
655
- W.__dshib_blobs = W.__dshib_blobs || new Map()
656
- W.__dshib_skip = []
657
- const skip = (name, size, why) => {
658
- if (W.__dshib_skip.length < 6) W.__dshib_skip.push({ name, size, why })
659
- }
660
- const keep = (blob, name, url) => {
661
- if (!(blob instanceof Blob)) return
662
- if (blob.size === 0) return
663
- if (blob.size > 7 * 1024 * 1024) return skip(name, blob.size, 'large')
664
- if (W.__dshib_land.length >= 6) return skip(name, blob.size, 'many')
665
- W.__dshib_land.push(
666
- blob64(blob).then((data) => ({ name: name !== '' ? name : fileName({}, url, {}), size: blob.size, data })),
667
- )
668
129
  }
669
- const take = async (url, name) => {
130
+ const ask = (sessionId) => {
131
+ if (sessionId === '' || mountedOf() !== sessionId) return
132
+ if (ownerSession !== '' && ownerSession !== sessionId) closeHere(ownerSession)
133
+ ownerSession = sessionId
670
134
  try {
671
- if (W.__dshib_land.length >= 6) return
672
- const held = W.__dshib_blobs.get(url)
673
- if (held !== undefined) return keep(held, name, url)
674
- const res = await fetch(url, { credentials: 'include', redirect: 'follow' })
675
- if (res.ok !== true) return
676
- keep(await res.blob(), name !== '' ? name : fileName({}, url, res), url)
135
+ if (typeof surface?.openTabIn === 'function') surface.openTabIn(sessionId, SIDE_KIND, {})
136
+ else if (typeof surface?.openTab === 'function') surface.openTab(SIDE_KIND)
677
137
  } catch {
678
- /* a page that forbids reading its own file keeps its own save */
138
+ /* a column that refuses keeps its own tabs */
679
139
  }
680
140
  }
681
- const address = (href, name) => {
682
- if (href.startsWith('blob:') === true || href.startsWith('data:') === true) {
683
- void take(href, name)
684
- return
141
+ disposers.push(tabs.register({
142
+ id: SIDE_ID, // ui-copy-ignore
143
+ kind: SIDE_KIND, // ui-copy-ignore
144
+ keepMounted: true,
145
+ single: true,
146
+ priority: 'extension',
147
+ title: () => L('side.label'),
148
+ }))
149
+ disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
150
+ name: SLOT, // ui-copy-ignore
151
+ key: SIDE_ID, // ui-copy-ignore
152
+ inject: (sessionId) => ({ sessionId, dropMirror: () => closeHere(sessionId) }),
153
+ }, SidecarBody)))
154
+ disposers.push(scope.slots.inject(TITLE_SLOT, () => scope.slots.register({
155
+ name: TITLE_SLOT, // ui-copy-ignore
156
+ key: SIDE_ID, // ui-copy-ignore
157
+ locale: NS, // ui-copy-ignore
158
+ }, () => h('span', { className: 'dshib-title' }, L('side.label')))))
159
+ /* The mirror comes only when this chat asked for it: the ask is pushed
160
+ to this chat's page, and nothing opens by itself. // ui-copy-ignore */
161
+ sidecarOpener = (sessionId) => ask(sessionId)
162
+ /* The ask arrives on this chat's own downlink, so a frame for another
163
+ chat never reaches this column. The stream follows the chat on
164
+ screen: a switch closes the old one and opens the new one. */
165
+ let askSource = null
166
+ let askWho = ''
167
+ const closeAsk = () => {
168
+ if (askSource !== null) {
169
+ try {
170
+ askSource.close()
171
+ } catch {
172
+ /* a stream already gone needs no closing */
173
+ }
685
174
  }
686
- /* A plain link to a file is a transfer too; one to a page is not. */
687
- if (W.__dshib_watch !== true || /\.(zip|rar|7z|tar|gz|xlsx?|docx?|pptx?|pdf|csv|txt|md|json|png|jpe?g|webp|mp3|mp4)$/i.test(href) !== true) return
688
- void take(href, name)
175
+ askSource = null
176
+ askWho = ''
689
177
  }
690
- const create = W.URL.createObjectURL.bind(W.URL)
691
- W.URL.createObjectURL = (object) => {
692
- const url = create(object)
178
+ const syncAsk = () => {
179
+ const who = mountedOf()
180
+ if (who === askWho) return
181
+ closeAsk()
182
+ if (who === '') return
693
183
  try {
694
- if (object instanceof Blob && W.__dshib_blobs.size < 12) W.__dshib_blobs.set(url, object)
184
+ const source = new EventSource(`${ROUTE}/downlink?session=${encodeURIComponent(who)}`) // ui-copy-ignore
185
+ source.addEventListener('message', (event) => {
186
+ let frame
187
+ try {
188
+ frame = JSON.parse(event.data)
189
+ } catch {
190
+ return
191
+ }
192
+ if (frame?.type !== 'sidecar-wanted') return
193
+ const asked = String(frame.session ?? '')
194
+ if (asked !== '') sidecarOpener?.(asked)
195
+ })
196
+ askSource = source
197
+ askWho = who
695
198
  } catch {
696
- /* an address that cannot be kept is only an address */
199
+ /* a page with no stream keeps its tab closed */
697
200
  }
698
- return url
699
201
  }
700
- D.addEventListener('click', (event) => {
701
- const node = event.target
702
- const link = node !== null && typeof node.closest === 'function' ? node.closest('a[download],a[href]') : null
703
- if (link === null) return
704
- const href = String(link.href || '')
705
- const name = String(link.getAttribute('download') || '')
706
- const wanted = name !== '' || href.startsWith('blob:') === true || /\.[a-z0-9]{2,5}$/i.test(href)
707
- if (wanted !== true) return
708
- event.preventDefault()
709
- event.stopPropagation()
710
- address(href, name)
711
- }, true)
712
- const open = W.open.bind(W)
713
- W.open = (url, ...rest) => {
202
+ const watchAsk = () => {
714
203
  try {
715
- if (typeof url === 'string' && url.startsWith('blob:') === true) address(url, '')
204
+ if (typeof surface?.mounted?.subscribe === 'function') {
205
+ const off = surface.mounted.subscribe(syncAsk)
206
+ if (typeof off === 'function') return off
207
+ }
716
208
  } catch {
717
- /* a window that opens on its own is left alone */
209
+ /* a store that cannot be watched is polled instead */
718
210
  }
719
- return open(url, ...rest)
211
+ return null
720
212
  }
721
- return 'watching'
722
- }
723
- if (op === 'dl-skip') {
724
- const held = Array.isArray(W.__dshib_skip) ? W.__dshib_skip.splice(0, W.__dshib_skip.length) : []
725
- return held
726
- }
727
- if (op === 'dl-drain') {
728
- const held = Array.isArray(W.__dshib_land) ? W.__dshib_land.splice(0, W.__dshib_land.length) : []
729
- const kept = []
730
- for (const one of held) {
731
- try {
732
- const file = await one
733
- if (file !== null && file !== undefined) kept.push(file)
734
- } catch {
735
- /* a file that could not be read is not a file */
213
+ syncAsk()
214
+ const offAsk = watchAsk()
215
+ const pollAsk = setInterval(syncAsk, 1000)
216
+ const stopAsk = () => {
217
+ clearInterval(pollAsk)
218
+ if (typeof offAsk === 'function') {
219
+ try {
220
+ offAsk()
221
+ } catch {
222
+ /* a gone store needs no release */
223
+ }
736
224
  }
225
+ closeAsk()
737
226
  }
738
- return kept
739
- }
740
- throw new Error('unknown operation ' + op)
741
- }
742
- `
743
-
744
- /**
745
- * Files the page saved on its own, written to the chat's folder.
746
- *
747
- * The page keeps what it captured; this half is the only one allowed to
748
- * write, and it names the chat so the file lands where that chat works.
749
- * @param {any} el - guest. @param {string} sessionId - chat the page belongs to.
750
- * @returns {Promise<Array<{name: string, path: string, bytes: number}>>} what landed.
751
- */
752
- async function landFiles(el, sessionId) {
753
- let list
754
- try {
755
- list = await runIn(el, 'dl-drain', {})
756
- } catch {
757
- return []
758
- }
759
- if (Array.isArray(list) !== true || list.length === 0) return []
760
- const landed = []
761
- for (const item of list) {
762
- const name = String(item?.name ?? 'download')
763
- try {
764
- const answer = await fetch(
765
- `${ROUTE}/land?session=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(String(globalThis.crypto?.randomUUID?.() ?? Date.now()))}&name=${encodeURIComponent(name)}&final=1`, // ui-copy-ignore
766
- { method: 'POST', headers: { 'x-dshib-land': '1' }, body: bytesOf(String(item?.data ?? '')) },
767
- )
768
- const wrote = await answer.json().catch(() => null)
769
- if (wrote?.ok !== true) throw new Error(String(wrote?.error ?? 'the file did not reach the folder'))
770
- landed.push({ name, path: String(wrote.path ?? ''), bytes: Number(wrote.bytes ?? item?.size ?? 0) })
771
- } catch (error) {
772
- probe?.({ why: 'land-failed', name, error: String(error?.message ?? error).slice(0, 120) })
773
- }
774
- }
775
- if (landed.length > 0) probe?.({ why: 'auto-land', files: landed.map((file) => file.path).join(', ').slice(0, 300) })
776
- return landed
777
- }
778
-
779
- /** @param {string} base64 - bytes as base64. @returns {Uint8Array} the bytes. */
780
- function bytesOf(base64) {
781
- const binary = atob(base64)
782
- const bytes = new Uint8Array(binary.length)
783
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
784
- return bytes
785
- }
786
-
787
- /** @param {Uint8Array} bytes - raw bytes. @returns {string} the same bytes as base64. */
788
- function encode64(bytes) {
789
- let binary = ''
790
- for (let index = 0; index < bytes.length; index += 8192) {
791
- binary += String.fromCharCode.apply(null, bytes.subarray(index, index + 8192))
792
- }
793
- return btoa(binary)
794
- }
795
-
796
- /**
797
- * A page can forbid its own network (a strict connect-src) and still show the
798
- * address of the file. This half has its own network rules, so it takes over.
799
- * @param {string} url - absolute address.
800
- * @param {string} wanted - name the caller asked for, if any.
801
- * @returns {Promise<{name: string, next: () => Promise<{data: string, done: boolean}>}>} a stream.
802
- */
803
- async function fetchHere(url, wanted) {
804
- const res = await fetch(url, { credentials: 'omit', redirect: 'follow' })
805
- if (res.ok !== true) throw new Error('this machine answered ' + res.status + ' for the address')
806
- if (res.body === null) throw new Error('this machine cannot stream the address')
807
- const reader = res.body.getReader()
808
- const tail = new URL(url).pathname.split('/').filter(Boolean).pop() ?? ''
809
- const asked = wanted !== '' ? wanted : decodeURIComponent(tail)
810
- return {
811
- name: asked === '' ? 'download' : asked,
812
- next: async () => {
813
- const parts = []
814
- let size = 0
815
- while (size < 512 * 1024) {
816
- const step = await reader.read()
817
- if (step.done === true) return { data: encode64(parts.reduce((all, part) => {
818
- const next = new Uint8Array(all.length + part.length)
819
- next.set(all, 0)
820
- next.set(part, all.length)
821
- return next
822
- }, new Uint8Array(0))), done: true }
823
- parts.push(step.value)
824
- size += step.value.length
227
+ return () => {
228
+ if (sidecarOpener !== null) sidecarOpener = null
229
+ stopAsk()
230
+ for (const dispose of disposers.reverse()) {
231
+ try {
232
+ dispose()
233
+ } catch {
234
+ /* a slot already gone needs no release */
235
+ }
236
+ }
825
237
  }
826
- const merged = parts.reduce((all, part) => {
827
- const next = new Uint8Array(all.length + part.length)
828
- next.set(all, 0)
829
- next.set(part, all.length)
830
- return next
831
- }, new Uint8Array(0))
832
- return { data: encode64(merged), done: false }
833
- },
834
- }
835
- }
836
-
837
- /** @param {HTMLWebViewElement} el - guest. @param {string} op - operation name. @param {Record<string, unknown>} args - arguments. @returns {Promise<string>} guest answer. */
838
- function runIn(el, op, args) {
839
- const code = `(async function(){ ${RUNTIME}\n return await run(${JSON.stringify(op)}, ${JSON.stringify(args ?? {})}) })()`
840
- return el.executeJavaScript(code, true)
841
- }
842
-
843
- /**
844
- * Give up on a step that never settles.
845
- * @param {Promise<unknown>} work - pending step. @param {number} ms - budget. @param {string} label - stage name.
846
- * @returns {Promise<unknown>} the step's value.
847
- */
848
- function tick(work, ms, label) {
849
- return new Promise((resolve, reject) => {
850
- const timer = setTimeout(() => reject(new Error(label + ' timed out')), ms)
851
- work.then((value) => { clearTimeout(timer); resolve(value) }, (error) => { clearTimeout(timer); reject(error) })
852
- })
238
+ }, 'dsh-inner-browser-use: sidecar tab'))
239
+ },
853
240
  }
854
241
 
855
242
  /**
856
- * Read the guest as a PNG, base64-encoded.
243
+ * The sidecar's screen in the column.
857
244
  *
858
- * The panel's own capture hands back an image object whose conversion runs
859
- * outside this page and fails there, so the debugger answers in base64
860
- * directly; the panel's capture stays as the fallback.
861
- * @param {HTMLWebViewElement} el - guest.
862
- * @returns {Promise<string>} base64 PNG.
245
+ * It is a picture of another Chrome: a click here is sent to that browser as
246
+ * real mouse input, and the black arrow shows where the last one landed. The
247
+ * page keeps working while this pane is not being watched.
863
248
  */
864
- /** Where the driven guest sits, and what covers it. */
865
- function surface(driven) {
866
- try {
867
- const r = driven.getBoundingClientRect()
868
- let wcid = 0
869
- try {
870
- wcid = typeof driven.getWebContentsId === 'function' ? (driven.getWebContentsId() ?? 0) : 0
871
- } catch {
872
- wcid = 0
873
- }
874
- const cx = Math.round(r.left + r.width / 2)
875
- const cy = Math.round(Math.min(r.top + r.height / 2, window.innerHeight - 4))
876
- const inside = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight
877
- const hit = inside ? document.elementFromPoint(cx, cy) : null
878
- const guests = []
879
- for (const each of document.querySelectorAll('webview')) {
880
- const b = each.getBoundingClientRect()
881
- guests.push([Math.round(b.left), Math.round(b.top), Math.round(b.width), Math.round(b.height)].join(','))
882
- }
883
- return {
884
- wcid,
885
- rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)].join(','),
886
- view: window.innerWidth + 'x' + window.innerHeight,
887
- covers: hit === null ? 'none' : String(hit.tagName) + '.' + String(hit.className || '').slice(0, 20),
888
- guests,
889
- seen: document.visibilityState,
890
- }
891
- } catch (error) {
892
- return { surface: String(error?.message ?? error).slice(0, 60) }
893
- }
894
- }
895
-
896
- async function grab(el) {
897
- const notes = []
898
-
899
- let bug = null
900
- try {
901
- bug = el.debugger ?? null
902
- } catch (error) {
903
- notes.push('debugger: ' + String(error?.message ?? error))
904
- bug = null
905
- }
906
- if (bug === null) notes.push('debugger: absent')
907
- if (bug !== null) {
908
- let attached = false
909
- /* A page the compositor is not drawing still answers a capture asked
910
- for from the page itself, so the capture that needs no surface is
911
- tried after the plain one instead of giving up with it. */
912
- const shapes = [
913
- { format: 'png' },
914
- { format: 'png', fromSurface: false },
915
- { format: 'png', fromSurface: false, captureBeyondViewport: true },
916
- ]
917
- try {
918
- await tick(bug.attach('1.3'), 3000, 'attach')
919
- attached = true
920
- try {
921
- await tick(bug.sendCommand('Page.enable'), 2000, 'enable')
922
- } catch (error) {
923
- notes.push('enable: ' + String(error?.message ?? error).slice(0, 60))
924
- }
925
- for (const shape of shapes) {
926
- try {
927
- const shot = await tick(bug.sendCommand('Page.captureScreenshot', shape), 8000, 'capture')
928
- const data = shot?.data
929
- if (typeof data === 'string' && data !== '') return data
930
- notes.push('debugger: no data ' + JSON.stringify(shape))
931
- } catch (error) {
932
- notes.push('debugger: ' + String(error?.message ?? error).slice(0, 70) + ' ' + JSON.stringify(shape))
933
- }
934
- }
935
- } catch (error) {
936
- notes.push('debugger: ' + String(error?.message ?? error).slice(0, 70))
937
- } finally {
938
- if (attached) bug.detach().catch(() => {})
939
- }
940
- }
941
- /* The window owns the view, so it can be read even when the app draws
942
- nothing there: ask the half that owns it before giving up. */
943
- try {
944
- el.scrollIntoView?.({ block: 'center', inline: 'nearest' }) // ui-copy-ignore
945
- } catch {
946
- /* a guest outside the scroller answers through its own capture */
947
- }
948
- const tries = []
949
- for (let attempt = 1; attempt <= 2; attempt += 1) {
950
- try {
951
- const image = await tick(el.capturePage(), 1200, 'panel')
952
- const empty = typeof image?.isEmpty === 'function' ? image.isEmpty() : false
953
- /* A data URL crosses the desktop bridge as a plain string; the PNG
954
- Buffer does not cross it at all. */
955
- const dataUrl = typeof image?.toDataURL === 'function' ? image.toDataURL() : ''
956
- const png = dataUrl.startsWith('data:') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : ''
957
- tries.push(attempt + ':' + (empty === true ? 'empty' : png.length + 'B'))
958
- if (empty !== true && png !== '') return png
959
- } catch (error) {
960
- tries.push(attempt + ':' + String(error?.message ?? error))
249
+ function SidecarBody(props) {
250
+ const [frame, setFrame] = React.useState(null)
251
+ const [ptr, setPtr] = React.useState(null)
252
+ const [phase, setPhase] = React.useState('connecting')
253
+ const [note, setNote] = React.useState('')
254
+ const [url, setUrl] = React.useState('')
255
+ const [draft, setDraft] = React.useState('')
256
+ const [editing, setEditing] = React.useState(false)
257
+ const [size, setSize] = React.useState({ w: 0, h: 0 })
258
+ const stage = React.useRef(null)
259
+ /* Which chat owns the mirror, as the sidecar host records it. // ui-copy-ignore
260
+ Empty until
261
+ the host answers; a chat that does not own it draws no picture at all. // ui-copy-ignore */
262
+ const [owner, setOwner] = React.useState(null)
263
+ React.useEffect(() => {
264
+ let alive = true
265
+ let dropped = false
266
+ const read = () => {
267
+ void fetch(`${ROUTE}/sidecar/state`, { cache: 'no-store' }) // ui-copy-ignore
268
+ .then((reply) => (reply.ok === true ? reply.json() : null))
269
+ .then((info) => {
270
+ if (alive !== true) return
271
+ const next = String(info?.owner ?? '')
272
+ setOwner((prev) => (prev === next ? prev : next))
273
+ /* Another chat holds the browser now: this tab goes away instead
274
+ of sitting here as a note. */
275
+ if (dropped !== true && next !== '' && next !== String(props.sessionId ?? '')) {
276
+ dropped = true
277
+ try {
278
+ props.dropMirror?.()
279
+ } catch {
280
+ /* a column already gone needs no closing */
281
+ }
282
+ }
283
+ })
284
+ .catch(() => {})
961
285
  }
962
- await new Promise((resolve) => setTimeout(resolve, 400))
963
- }
964
- try {
965
- const chain = []
966
- let node = el
967
- for (let up = 0; up < 16 && node !== null; up += 1) {
968
- const box2 = typeof node.getBoundingClientRect === 'function' ? node.getBoundingClientRect() : { width: 0, height: 0 }
969
- const style = getComputedStyle(node)
970
- chain.push(String(node.tagName) + '.' + String(node.dataset?.open ?? '') + '.' + String(node.className ?? '').slice(0, 28) + ' '
971
- + Math.round(box2.width) + 'x' + Math.round(box2.height) + ' ' + style.display + '/' + style.visibility)
972
- node = node.parentElement
286
+ read()
287
+ const timer = setInterval(read, 2500)
288
+ return () => {
289
+ alive = false
290
+ clearInterval(timer)
973
291
  }
974
- probe?.({ why: 'capture-empty', tries: tries.join(' '), chain, page: document.visibilityState })
975
- } catch {
976
- /* the error below still reports the geometry */
977
- }
978
- const box = typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : {}
979
- throw new Error('no pixels (' + tries.join(' ') + '; rect=' + Math.round(box.width ?? 0) + 'x' + Math.round(box.height ?? 0)
980
- + ' offset=' + (el.offsetWidth ?? 0) + 'x' + (el.offsetHeight ?? 0) + ' connected=' + String(el.isConnected)
981
- + '; ' + notes.join('; ') + ')')
982
- }
983
-
984
- /**
985
- * Tell the Host this half is alive, and later what it managed to register. // ui-copy-ignore
986
- * Nothing else in the Client runs until a tab opens, so this is the only
987
- * proof the module itself executed.
988
- * @param {Record<string, unknown>} frame - what to report.
989
- */
990
- /**
991
- * Keep a chat's view laid out while it is not the one on screen.
992
- *
993
- * The app hides a view it is not showing, and a hidden guest paints nothing,
994
- * so a capture finds white there. This parks such a view out of sight at a
995
- * real size, which keeps the page painted and its state intact.
996
- */
997
- function parkHiddenViews() {
998
- if (document.getElementById('dshib-park') !== null) return
999
- const style = document.createElement('style')
1000
- style.id = 'dshib-park'
1001
- /* Inside the window, because a painter given no place inside it is never
1002
- drawn: parked at a negative offset the page keeps its state but has no
1003
- picture. Nearly clear, out of the way of clicks, and under the app's
1004
- own surface, it paints without being seen. */
1005
- const park = '{display:block !important;position:fixed !important;'
1006
- + 'left:0 !important;top:0 !important;z-index:-1 !important;opacity:0.02 !important;'
1007
- + 'pointer-events:none !important;width:var(--dshib-park-w,575px) !important;'
1008
- + 'height:var(--dshib-park-h,1080px) !important;visibility:visible !important}'
1009
- style.textContent = '[hidden]:has(.dshib-guest), .dshib-guest[hidden], .dshib-stash [hidden]' + park
1010
- document.head.appendChild(style)
1011
- }
1012
-
1013
- /**
1014
- * Take that surface back.
1015
- *
1016
- * It is borrowed for one call and returned at the end of it: left in place,
1017
- * it holds another chat's hidden view laid out in front of the app, which
1018
- * grows a column that belongs to nobody.
1019
- */
1020
- function unparkHiddenViews() {
1021
- document.getElementById('dshib-park')?.remove()
1022
- }
1023
-
1024
- /** The injected pane face, reached from the capture path as well.
1025
- * @type {() => any} */
1026
- let faceOf = () => undefined
1027
-
1028
- /* The product's own browser tab: the page these tools drive lives in it, so
1029
- the chrome, the partition and the address bar are the ones it draws. */
1030
- const NATIVE_KIND = 'browser'
1031
- const NATIVE_MODE = true
1032
- const GUEST_SELECTOR = 'webview, [data-sidebar-browser-frame="webview"]'
292
+ }, [props.sessionId])
293
+ const asked = String(props.sessionId ?? '')
294
+ /* Unknown until the host answers: the pane then shows no picture. // ui-copy-ignore
295
+ But it
296
+ does not flash a refusal either. Once the answer names another chat,
297
+ this chat keeps only a note. // ui-copy-ignore */
298
+ const unknown = owner === null
299
+ const mine = unknown !== true && owner === asked
1033
300
 
1034
- /** Depth-first query that also walks a root's shadow children. */
1035
- function deepFind(root, selector) {
1036
- if (root === null || root === undefined) return null
1037
- try {
1038
- const direct = root.querySelector(selector)
1039
- if (direct !== null) return direct
1040
- } catch {
1041
- return null
1042
- }
1043
- const queue = [root]
1044
- while (queue.length > 0) {
1045
- const node = queue.pop()
1046
- for (const child of node.querySelectorAll('*')) {
1047
- const shadow = child.shadowRoot
1048
- if (shadow === null || shadow === undefined) continue
301
+ React.useEffect(() => {
302
+ if (mine !== true) return undefined
303
+ const who = encodeURIComponent(String(props.sessionId ?? ''))
304
+ const source = new EventSource(`${ROUTE}/sidecar/frames?session=${who}`)
305
+ /* Bursts arrive faster than a picture can decode: only the newest frame
306
+ is painted, once per display refresh, so the mirror never falls behind
307
+ by a queue of stale frames. */
308
+ /** @type {any} */
309
+ let pending = null
310
+ let raf = 0
311
+ const paint = () => {
312
+ raf = 0
313
+ if (pending === null) return
314
+ const message = pending
315
+ pending = null
316
+ setFrame(message)
317
+ setPtr(message.pointer ?? null)
318
+ setPhase('live')
319
+ if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
320
+ }
321
+ const draw = (message) => {
322
+ pending = message
323
+ if (raf !== 0) return
324
+ if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
325
+ else paint()
326
+ }
327
+ source.onmessage = (event) => {
328
+ let message
1049
329
  try {
1050
- const hit = shadow.querySelector(selector)
1051
- if (hit !== null) return hit
330
+ message = JSON.parse(event.data)
1052
331
  } catch {
1053
- /* a closed root answers nothing */
332
+ return
1054
333
  }
1055
- queue.push(shadow)
334
+ if (message.type === 'hello') {
335
+ setPhase('live')
336
+ setUrl(String(message.url ?? ''))
337
+ } else if (message.type === 'failed') {
338
+ setPhase('failed')
339
+ setNote(String(message.text ?? ''))
340
+ } else if (message.type === 'pointer') {
341
+ /* The arrow is drawn the moment the agent moves, not when the page
342
+ next repaints: it stays honest even on a page that is idle. */
343
+ setPtr(message.pointer ?? null)
344
+ } else if (typeof message.jpeg === 'string') {
345
+ draw(message)
346
+ }
347
+ }
348
+ source.onerror = () => setPhase('lost')
349
+ return () => {
350
+ if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
351
+ source.close()
1056
352
  }
1057
- }
1058
- return null
1059
- }
353
+ }, [props.sessionId, mine])
1060
354
 
1061
- /** True when a guest is laid out in the right part of the window. */
1062
- function onRight(node) {
1063
- try {
1064
- const rect = node.getBoundingClientRect()
1065
- return rect.width > 80 && rect.height > 80 && rect.left > window.innerWidth * 0.35
1066
- } catch {
1067
- return false
1068
- }
1069
- }
355
+ /* The address field follows the page except while the person is editing it. */
356
+ React.useEffect(() => {
357
+ if (editing !== true) setDraft(url)
358
+ }, [url, editing])
1070
359
 
1071
- /** The panels the product keeps for one chat, one per sidebar it is shown in. */
1072
- function panelOf(sessionId) {
1073
- if (String(sessionId ?? '') === '') return []
1074
- try {
1075
- /* The column of this chat is the one holding this chat's own pane: the
1076
- column's id is a different one from the chat id, so the pane is what
1077
- says which column belongs here. */
1078
- const pane = paneHosts.get(String(sessionId ?? ''))
1079
- if (pane !== undefined) {
1080
- const around = pane.closest?.('[data-sidebar-right-session]')
1081
- if (around !== null && around !== undefined) return [around]
1082
- }
1083
- /* A browser the product drew sits in the column that names this chat.
1084
- It is found from the column's own mark, never by asking for the page
1085
- first: that lookup asks this function back, and the two would chase
1086
- each other until the stack ran out. */
1087
- for (const frame of Array.from(document.querySelectorAll('[data-sidebar-browser-frame]'))) {
1088
- const around = frame.closest?.('[data-sidebar-right-session]')
1089
- if (around !== null && around !== undefined && String(around.dataset?.sidebarRightSession ?? '') === String(sessionId)) return [around]
1090
- }
1091
- return Array.from(document.querySelectorAll('[data-sidebar-right-session]'))
1092
- .filter((node) => node.dataset?.sidebarRightSession === sessionId)
1093
- } catch {
1094
- return []
1095
- }
1096
- }
360
+ React.useEffect(() => {
361
+ const el = stage.current
362
+ if (el === null) return undefined
363
+ const measure = () => {
364
+ const box = el.getBoundingClientRect()
365
+ setSize({ w: box.width, h: box.height })
366
+ }
367
+ const observer = new ResizeObserver(measure)
368
+ observer.observe(el)
369
+ measure()
370
+ return () => observer.disconnect()
371
+ }, [])
1097
372
 
1098
- /**
1099
- * Whether a column on screen belongs to another chat. A tab is never asked
1100
- * for while one is up: the product puts it in that column, in front of the
1101
- * chat the person is reading.
1102
- * @param {string} sessionId - chat the call is for.
1103
- * @returns {boolean} true when a drawn column is not this chat's.
1104
- */
1105
- function foreignColumn(sessionId) {
1106
- try {
1107
- return Array.from(document.querySelectorAll('[data-sidebar-right-session]')).some((node) => {
1108
- const box = typeof node.getBoundingClientRect === 'function' ? node.getBoundingClientRect() : null
1109
- if (box === null || box.width < 80) return false
1110
- return String(node.dataset?.sidebarRightSession ?? '') !== String(sessionId ?? '')
373
+ const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / SIDE_VIEW.width, size.h / SIDE_VIEW.height) : 0
374
+ const padX = (size.w - SIDE_VIEW.width * scale) / 2
375
+ const padY = (size.h - SIDE_VIEW.height * scale) / 2
376
+
377
+ const who = String(props.sessionId ?? '')
378
+ const post = (path, body) => {
379
+ void fetch(`${ROUTE}${path}`, {
380
+ method: 'POST',
381
+ headers: { 'content-type': 'application/json' },
382
+ body: JSON.stringify({ ...body, session: who }),
383
+ }).catch(() => {})
384
+ }
385
+ /* The page names the cursor it would show under the hand; the black
386
+ arrow is the agent's, so it keeps its own shape and colour. */
387
+ const CURSORS = ['default', 'text', 'pointer', 'crosshair', 'move', 'grab', 'grabbing', 'not-allowed', 'help', 'wait', 'progress', 'ew-resize', 'ns-resize', 'nwse-resize', 'nesw-resize', 'col-resize', 'row-resize', 'zoom-in', 'zoom-out']
388
+ const setCursor = (name) => {
389
+ const el = stage.current
390
+ if (el === null || el.style === undefined) return
391
+ el.style.cursor = CURSORS.includes(name) === true ? name : 'default'
392
+ }
393
+ const hover = (point) => {
394
+ /* A page that cannot say keeps the plain cursor: nothing here throws. */
395
+ void fetch(`${ROUTE}/sidecar/input`, {
396
+ method: 'POST',
397
+ headers: { 'content-type': 'application/json' },
398
+ body: JSON.stringify({ kind: 'hover', x: point.x, y: point.y, session: who }),
1111
399
  })
1112
- } catch {
1113
- return false
1114
- }
1115
- }
1116
-
1117
- /* One chat, one page. The element a chat's operations were first aimed at is
1118
- kept here, with the guest number behind it, so a later call goes to that
1119
- same page instead of looking again and finding whatever is drawn now. */
1120
- const pageBindings = new Map()
1121
- /* A chat whose page went away is remembered as lost: it does not get a quiet
1122
- stand-in that looks the same and holds none of the same state. */
1123
- const pageLost = new Map()
1124
-
1125
- /** @param {any} el - a page element. @returns {number} the guest behind it, or 0 when it does not say. */
1126
- function guestNumber(el) {
1127
- try {
1128
- if (el === null || el === undefined) return 0
1129
- if (typeof el.getWebContentsId === 'function') return Number(el.getWebContentsId()) || 0
1130
- return 0
1131
- } catch {
1132
- return 0
1133
- }
1134
- }
1135
-
1136
- /** Keep this page as the one this chat's operations go to. @param {string} sessionId - chat. @param {any} el - the page element. @returns {void} */
1137
- function bindPage(sessionId, el) {
1138
- const who = String(sessionId ?? '')
1139
- if (who === '' || el === null || el === undefined) return
1140
- const held = pageBindings.get(who)
1141
- /* A different element for the same chat is a different page: the person is
1142
- told, because the state they left is not in this one. */
1143
- if (held !== undefined && held.el !== el) beat({ tabType: true, phase: 'page-rebound', session: who, error: 'the product drew another page for this chat' })
1144
- const guest = guestNumber(el)
1145
- pageBindings.set(who, { el, guest, at: Date.now() })
1146
- /* Who this chat's page is, said out loud once, so a person can check that
1147
- two calls went to one guest instead of two that look alike. */
1148
- beat({ tabType: true, phase: 'page-bound', session: who, error: String(guest) })
1149
- pageLost.delete(who)
1150
- rememberTab(who, el)
1151
- }
1152
-
1153
- /** @param {string} sessionId - chat. @returns {{el: any, guest: number}|undefined} its page while that same page is still there. */
1154
- function boundPage(sessionId) {
1155
- const who = String(sessionId ?? '')
1156
- const held = pageBindings.get(who)
1157
- if (held === undefined) return undefined
1158
- const gone = held.el?.isConnected !== true
1159
- /* Same node, different guest: the element was moved or its content was
1160
- rebuilt, so it no longer holds the page this chat was working on. */
1161
- const replaced = gone !== true && held.guest !== 0 && guestNumber(held.el) !== held.guest
1162
- if (gone === true || replaced === true) {
1163
- pageBindings.delete(who)
1164
- pageLost.set(who, gone === true ? 'unmounted' : 'replaced')
1165
- beat({ tabType: true, phase: 'open', step: 'page-lost', session: who, error: pageLost.get(who) })
1166
- return undefined
1167
- }
1168
- return held
1169
- }
1170
-
1171
- /** Whether this chat was ever given a browser of the product's. @param {string} sessionId - chat. @returns {boolean} whether its page lives in that browser. */
1172
- function hasTab(sessionId) {
1173
- try {
1174
- return String(window.localStorage.getItem('dshib:tab:' + String(sessionId ?? '')) ?? '') !== ''
1175
- } catch {
1176
- return false
400
+ .then((reply) => (reply.ok === true ? reply.json() : null))
401
+ .then((answer) => {
402
+ if (answer !== null && typeof answer.cursor === 'string') setCursor(answer.cursor)
403
+ })
404
+ .catch(() => {})
405
+ }
406
+ const toView = (event) => {
407
+ const el = stage.current
408
+ if (el === null || scale <= 0) return null
409
+ const box = el.getBoundingClientRect()
410
+ const x = (event.clientX - box.left - padX) / scale
411
+ const y = (event.clientY - box.top - padY) / scale
412
+ if (x < 0 || y < 0 || x > SIDE_VIEW.width || y > SIDE_VIEW.height) return null
413
+ return { x: Math.round(x), y: Math.round(y) }
1177
414
  }
1178
- }
1179
-
1180
- /** That chat's page in the product's browser tab, once the tab made one. */
1181
- function nativeElement(sessionId) {
1182
- const bound = boundPage(sessionId)
1183
- if (bound !== undefined) return bound.el
1184
- /* Ours is never the page: a guest this plugin parked is out of the flow of
1185
- the column and answers nothing the person is reading. */
1186
- const ours = (node) => {
1187
- if (ownViews.has(node)) return true
1188
- try {
1189
- return node.closest('.dshib-stash') !== null
1190
- } catch {
1191
- return false
415
+ const ime = React.useRef(null)
416
+ const composing = React.useRef(false)
417
+ const sendText = (value) => {
418
+ const text = String(value ?? '')
419
+ if (text === '') return
420
+ post('/sidecar/input', { kind: 'text', text })
421
+ }
422
+ const clearIme = () => {
423
+ const field = ime.current
424
+ if (field !== null && typeof field.value === 'string') field.value = ''
425
+ }
426
+ const stageRef = stage
427
+ const onPointerDown = (event) => {
428
+ const point = toView(event)
429
+ if (point === null) return
430
+ const field = ime.current
431
+ if (field !== null && typeof field.focus === 'function') field.focus()
432
+ else {
433
+ const el = stageRef.current
434
+ if (el !== null && typeof el.focus === 'function') el.focus()
435
+ }
436
+ post('/sidecar/input', { kind: 'click', x: point.x, y: point.y })
437
+ }
438
+ const movedAt = React.useRef(0)
439
+ const onPointerMove = (event) => {
440
+ const point = toView(event)
441
+ if (point === null) return
442
+ /* Hover is part of a page: menus open under the hand, so the movement
443
+ also reaches the browser. It never moves the agent's arrow, which
444
+ only follows what the agent itself did. */
445
+ const now = Date.now()
446
+ if (now - movedAt.current < 60) return
447
+ movedAt.current = now
448
+ hover(point)
449
+ }
450
+ const onWheel = (event) => {
451
+ event.preventDefault()
452
+ const point = toView(event) ?? { x: Math.round(SIDE_VIEW.width / 2), y: Math.round(SIDE_VIEW.height / 2) }
453
+ post('/sidecar/input', { kind: 'wheel', x: point.x, y: point.y, dx: event.deltaX, dy: event.deltaY })
454
+ }
455
+ const onKeyDown = (event) => {
456
+ /* A key still being composed belongs to the IME, not to the page. */
457
+ if (event.isComposing === true) return
458
+ const named = ['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']
459
+ if (named.includes(event.key) === true) {
460
+ event.preventDefault()
461
+ post('/sidecar/input', { kind: 'key', key: event.key, code: event.code || event.key, keyCode: event.keyCode || 0 }) // ui-copy-ignore
462
+ return
1192
463
  }
1193
- }
1194
- const inside = (root) => {
1195
- const known = root.querySelector('webview[data-sidebar-browser-frame], [data-sidebar-browser-frame="webview"]')
1196
- if (known !== null && !ours(known)) return known
1197
- /* What the product's browser tab draws is marked as its frame; a webview
1198
- without the mark belongs to something else in the column, and driving
1199
- it would be driving the wrong page. */
1200
- if (NATIVE_MODE === true) return null
1201
- const guests = Array.from(root.querySelectorAll('webview')).filter((node) => !ours(node))
1202
- return guests.length > 0 ? guests.at(-1) : null
1203
- }
1204
- /* A chat's page is in that chat's own panel and nowhere else: a guest taken
1205
- from another panel is another chat's page, and driving it is the bug this
1206
- lookup exists to prevent. */
1207
- if (String(sessionId ?? '') !== '') {
1208
- for (const panel of panelOf(sessionId)) {
1209
- const found = inside(panel)
1210
- if (found !== null) {
1211
- /* The first page this chat is given is the one its calls keep. */
1212
- bindPage(String(sessionId ?? ''), found)
1213
- return found
1214
- }
464
+ if (event.metaKey === true || event.ctrlKey === true || event.altKey === true) return
465
+ if (event.key != null && event.key.length === 1) {
466
+ event.preventDefault()
467
+ post('/sidecar/input', { kind: 'text', text: event.key })
1215
468
  }
1216
- return null
1217
469
  }
1218
- const known = deepFind(document.body, '[data-sidebar-browser-frame="webview"]')
1219
- if (known !== null && onRight(known) && !ours(known)) return known
1220
- if (NATIVE_MODE === true) return null
1221
- const guests = Array.from(document.querySelectorAll('webview'))
1222
- .filter((node) => onRight(node) && !ours(node))
1223
- return guests.length > 0 ? guests.at(-1) : null
1224
- }
1225
-
1226
- /** The address bar of one chat's panel, present before its page is. */
1227
- function addressIn(sessionId) {
1228
- for (const panel of panelOf(sessionId)) {
1229
- const found = addressOf(panel.querySelector('webview') ?? panel)
1230
- if (found.input !== null) return found
1231
- }
1232
- return { form: null, input: null }
1233
- }
1234
-
1235
- /** The box holding a guest and its address bar, for priming the address bar. */
1236
- function nativeBox(webview) {
1237
- let node = webview ?? null
1238
- for (let i = 0; i < 12 && node !== null; i += 1) {
1239
- if (typeof node.querySelector === 'function' && node.querySelector('form') !== null && node.querySelector('input') !== null) return node
1240
- node = node.parentElement ?? null
1241
- }
1242
- return null
1243
- }
1244
-
1245
- /** The address bar of the browser tab the guest belongs to. */
1246
- function addressOf(webview) {
1247
- const root = nativeBox(webview) ?? document.body
1248
- try {
1249
- const named = root.querySelector('input[class*="address"]')
1250
- if (named !== null) return { form: named.closest('form') ?? null, input: named }
1251
- const candidates = Array.from(root.querySelectorAll('input')).filter((node) => /http|地址|url/i.test(String(node.placeholder ?? '') + String(node.getAttribute?.('aria-label') ?? '')))
1252
- if (candidates.length > 0) return { form: candidates[0].closest('form') ?? null, input: candidates[0] }
1253
- } catch {
1254
- /* a bar this version does not name is reported by the caller */
470
+ const goto = () => {
471
+ const address = String(draft ?? '').trim()
472
+ if (address === '') return
473
+ setEditing(false)
474
+ post('/sidecar/nav', { op: 'goto', url: address })
1255
475
  }
1256
- return { form: null, input: null }
1257
- }
1258
-
1259
- /** Wait for that page to exist. */
1260
- async function nativeReady(tries, sessionId) {
1261
- const rounds = tries ?? 25
1262
- for (let i = 0; i < rounds; i += 1) {
1263
- const found = nativeElement(sessionId)
1264
- if (found !== null) return found
1265
- await new Promise((resolve) => setTimeout(resolve, 200))
1266
- }
1267
- beat({ phase: 'native-missing', ...domDump() })
1268
- return undefined
1269
- }
1270
476
 
1271
- /** A small description of what the window holds, for a call that cannot find its page. */
1272
- function domDump() {
1273
- try {
1274
- return dumpNow()
1275
- } catch {
1276
- return { width: 0 }
1277
- }
1278
- }
1279
-
1280
- /** The description itself; a window without the usual pieces reports nothing. */
1281
- function dumpNow() {
1282
- const box = (node) => {
1283
- try {
1284
- const rect = node.getBoundingClientRect()
1285
- return { tag: String(node.tagName ?? '').toLowerCase(), cls: String(node.className ?? '').slice(0, 70), w: Math.round(rect.width), h: Math.round(rect.height), x: Math.round(rect.left) }
1286
- } catch {
1287
- return { tag: '?', cls: '', w: 0, h: 0, x: 0 }
1288
- }
1289
- }
1290
- const guests = Array.from(document.querySelectorAll('webview')).slice(0, 5).map(box)
1291
- const frames = Array.from(document.querySelectorAll('[data-sidebar-browser-frame]')).slice(0, 5).map(box)
1292
- const inputs = Array.from(document.querySelectorAll('input')).slice(0, 8).map((node) => {
1293
- const rect = node.getBoundingClientRect()
1294
- return { cls: String(node.className ?? '').slice(0, 60), ph: String(node.placeholder ?? '').slice(0, 40), w: Math.round(rect.width), x: Math.round(rect.left) }
1295
- })
1296
- const rights = Array.from(document.querySelectorAll('[class*="sidebar" i], [class*="rightbar" i]')).slice(0, 8).map(box)
1297
- const own = document.querySelectorAll('#dshib-park, .dshib-stash, .dshib-body, .dshib-guest').length
1298
- const root = document.documentElement
1299
- return {
1300
- guests,
1301
- frames,
1302
- inputs,
1303
- rights,
1304
- own,
1305
- width: Math.round(window.innerWidth),
1306
- scroll: Math.round(Number(root?.scrollWidth ?? 0)),
1307
- rootStyle: String(root?.getAttribute?.('style') ?? '').slice(0, 120),
477
+ /* A chat that does not own the browser never shows its picture. */
478
+ if (mine !== true && unknown !== true) {
479
+ return h('div', { className: 'dshib-side' },
480
+ h('div', { className: 'dshib-side-note' }, L('side.foreign')))
1308
481
  }
482
+ const pointer = ptr
483
+ const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
484
+ return h('div', { className: 'dshib-side' },
485
+ h('div', { className: 'dshib-side-bar' },
486
+ h('button', { className: 'dshib-side-key', type: 'button', title: L('side.back'), 'aria-label': L('side.back'), onClick: () => post('/sidecar/nav', { op: 'back' }) }, '\u2190'), // ui-copy-ignore
487
+ h('button', { className: 'dshib-side-key', type: 'button', title: L('side.forward'), 'aria-label': L('side.forward'), onClick: () => post('/sidecar/nav', { op: 'forward' }) }, '\u2192'), // ui-copy-ignore
488
+ h('button', { className: 'dshib-side-key', type: 'button', title: L('side.reload'), 'aria-label': L('side.reload'), onClick: () => post('/sidecar/nav', { op: 'reload' }) }, '\u27f3'), // ui-copy-ignore
489
+ h('input', {
490
+ className: 'dshib-side-url',
491
+ value: draft,
492
+ placeholder: L('side.address'),
493
+ spellCheck: false,
494
+ 'aria-label': L('side.address'),
495
+ onChange: (event) => setDraft(event.target.value),
496
+ onFocus: () => setEditing(true),
497
+ onBlur: () => setEditing(false),
498
+ onKeyDown: (event) => {
499
+ if (event.key !== 'Enter') return
500
+ event.preventDefault()
501
+ goto()
502
+ },
503
+ })),
504
+ h('div', {
505
+ className: 'dshib-side-stage',
506
+ ref: stage,
507
+ tabIndex: 0,
508
+ onPointerDown,
509
+ onPointerMove,
510
+ onWheel,
511
+ onKeyDown,
512
+ },
513
+ frame === null ? h('div', { className: 'dshib-side-note' }, noteText) : null,
514
+ h('textarea', {
515
+ className: 'dshib-side-ime',
516
+ ref: ime,
517
+ tabIndex: 0,
518
+ spellCheck: false,
519
+ 'aria-label': L('side.type'),
520
+ style: pointer === null ? undefined : { left: `${padX + pointer.x * scale}px`, top: `${padY + pointer.y * scale}px` },
521
+ onCompositionStart: () => {
522
+ composing.current = true
523
+ },
524
+ onCompositionEnd: (event) => {
525
+ composing.current = false
526
+ sendText(event.data)
527
+ clearIme()
528
+ },
529
+ onChange: (event) => {
530
+ if (composing.current === true) return
531
+ sendText(event.target.value)
532
+ clearIme()
533
+ },
534
+ onKeyDown,
535
+ }),
536
+ frame === null ? null : h('img', {
537
+ className: 'dshib-side-frame',
538
+ src: `data:image/jpeg;base64,${frame.jpeg}`,
539
+ width: Math.max(1, Math.round(SIDE_VIEW.width * scale)),
540
+ height: Math.max(1, Math.round(SIDE_VIEW.height * scale)),
541
+ alt: '', // ui-copy-ignore
542
+ draggable: false,
543
+ }),
544
+ pointer === null ? null : h('img', {
545
+ className: 'dshib-side-ptr',
546
+ src: SIDE_POINTER, // ui-copy-ignore
547
+ alt: '', // ui-copy-ignore
548
+ style: { left: `${padX + pointer.x * scale}px`, top: `${padY + pointer.y * scale}px` },
549
+ })))
1309
550
  }
1310
-
1311
- /* A tab type that holds many pages gives a new tab for every ask, so one
1312
- chat asks once and keeps the tab it got. */
1313
- const nativeTabs = new Set()
1314
- const tabKey = (session) => 'dshib:tab:' + String(session ?? '')
1315
- /** @param {string} sessionId - chat. @returns {string} tab id this chat was given, '' when none. */
1316
- function givenTab(sessionId) {
1317
- try {
1318
- return String(window.localStorage.getItem(tabKey(sessionId)) ?? '')
1319
- } catch {
1320
- return ''
1321
- }
1322
- }
1323
- /** The tab this chat was given is still drawn in its column. @param {string} sessionId - chat. @returns {boolean} true when it is. */
1324
- function givenTabDrawn(sessionId) {
1325
- const id = givenTab(sessionId)
1326
- if (id === '') return false
1327
- try {
1328
- return panelOf(sessionId).some((panel) => panel.querySelector('[data-sidebar-right-tab="' + id + '"]') !== null)
1329
- } catch {
1330
- return false
1331
- }
1332
- }
1333
- /** Remember which tab is this chat's, so a reload does not ask for a second one. @param {string} sessionId - chat. @param {any} page - the page's own element. */
1334
- function rememberTab(sessionId, page) {
1335
- try {
1336
- const body = page?.closest?.('[data-sidebar-right-tab]')
1337
- const id = String(body?.getAttribute?.('data-sidebar-right-tab') ?? '')
1338
- if (id !== '') window.localStorage.setItem(tabKey(sessionId), id)
1339
- } catch {
1340
- /* a page that became stale already said which tab it was */
1341
- }
1342
- }
1343
- /** @param {string} sessionId - the chat the tab belongs to. @param {string} [kind] - the type to ask for. @returns {string} what was done. */
1344
- function openNativeTab(sessionId, kind = NATIVE_KIND, address = '', shown = false, face = undefined) {
1345
- const who = String(sessionId ?? '')
1346
- const slot = who + ':' + String(kind)
1347
- if (who === '') return 'none'
1348
- /* A tab is only put in the column of the chat being read, and only when
1349
- that column is already up: asking any earlier is what opened a column
1350
- in a chat nobody was looking at. */
1351
- if (NATIVE_MODE === true) {
1352
- /* A column on screen for this chat is the person looking at it; nothing
1353
- is asked for a chat whose column is not up, and nothing at all while
1354
- another chat is the one being read. */
1355
- /* Two witnesses of which chat the window shows: the address and the
1356
- pane the product mounted. They must agree, and they must name the
1357
- chatting chat. Nothing here acts for a chat the person is not on. */
1358
- const byUrl = routedSession()
1359
- const byPane = String(currentSession ?? '')
1360
- if (byUrl !== '' && byPane !== '' && byUrl !== byPane) return 'elsewhere'
1361
- const screen = byPane || byUrl
1362
- if (screen !== '' && who !== screen) return 'elsewhere'
1363
- if (panelOf(who).length === 0) {
1364
- /* Only the chat on screen may bring its own column up: a column opened
1365
- while someone reads elsewhere takes that chat's panel from them. */
1366
- if (screen === '' || screen !== who) return 'no-column'
1367
- try {
1368
- const button = document.querySelector('[data-sidebar-right-expand]')
1369
- if (button !== null && button !== undefined && typeof button.click === 'function') button.click()
1370
- else scope.layout.openRightbar(true, false) // ui-copy-ignore
1371
- } catch {
1372
- return 'no-column'
1373
- }
1374
- }
1375
- }
1376
- /* Only the chat being read gets a tab by name; the product opens it in
1377
- that chat's own column, which is out of sight when someone reads
1378
- elsewhere. The ask carries the id, so it never lands in another column. */
1379
- if (NATIVE_MODE !== true && shown !== true && mayShow(who) !== true) return 'elsewhere'
1380
- const page = String(address ?? '')
1381
- /* A tab with nothing to show is a tab the person has to close. Only a page
1382
- is worth a tab: a call that has none reads the one already there. */
1383
- if (page === '') return 'no-address'
1384
- if (nativeTabs.has(slot + ':' + page)) return 'kept'
1385
- /* The tab this chat was given is still there: asking again would put a
1386
- second browser beside it. */
1387
- if (page !== '' && givenTabDrawn(who) === true) {
1388
- nativeTabs.add(slot + ':' + page)
1389
- return 'kept'
1390
- }
1391
- if (nativeElement(who) !== null) {
1392
- /* The page is kept but the column shows its own start list: the entry
1393
- the person would press brings that page forward. */
1394
- if (givenTabDrawn(who) !== true) pressBrowserEntry(who)
1395
- nativeTabs.add(slot + ':' + page)
1396
- return 'page'
1397
- }
1398
- try {
1399
- /* The strip this page was given: a call that waited for a column keeps
1400
- asking its own strip, not one a later page load brought in. */
1401
- const held = face ?? faceOf?.()
1402
- const how = page === '' ? {} : { params: { url: page } }
1403
- /* A new tab can take the keyboard for its own address field. Whoever was
1404
- typing keeps it: the field they were in holds the caret again. */
1405
- const typing = document.activeElement
1406
- if (typeof held?.openTabIn === 'function') held.openTabIn(who, kind, how) // ui-copy-ignore
1407
- else if (typeof held?.openTab === 'function') held.openTab(kind, how) // ui-copy-ignore
1408
- else return 'none'
1409
- setTimeout(() => {
1410
- try {
1411
- const gives = String(typing?.tagName ?? '').toLowerCase()
1412
- const caret = gives === 'input' || gives === 'textarea' || typing?.isContentEditable === true
1413
- if (caret && typing.isConnected === true && document.activeElement !== typing) typing.focus()
1414
- } catch {
1415
- /* a page without a caret to return has nothing to give back */
1416
- }
1417
- }, 150)
1418
- } catch {
1419
- return 'none'
1420
- }
1421
- nativeTabs.add(slot + ':' + page)
1422
- return 'asked'
1423
- }
1424
-
1425
- /** A tab in the column, in either markup the product has drawn. */
1426
- const TAB_SEL = '[data-dockkit-tab], [data-sidebar-right-tab]'
1427
-
1428
- /** A chip drawn for a browser, not for a file or a terminal. @param {Element} chip - tab header. @returns {boolean} whether it is a browser chip. */
1429
- function browserChip(chip) {
1430
- try {
1431
- const words = String(chip.textContent ?? '') + ' ' + String(chip.getAttribute?.('aria-label') ?? '')
1432
- return /浏览器|Browser/i.test(words)
1433
- } catch {
1434
- return false
1435
- }
1436
- }
1437
-
1438
- /** @param {string} sessionId - chat. @returns {boolean} whether its tab was brought to the front. */
1439
- function showTab(sessionId) {
1440
- for (const panel of panelOf(sessionId)) {
1441
- let chips
1442
- try {
1443
- chips = Array.from(panel.querySelectorAll(TAB_SEL)).filter(browserChip)
1444
- } catch {
1445
- continue
1446
- }
1447
- const chip = chips[0]
1448
- if (chip === undefined) continue
1449
- try {
1450
- if (typeof chip.click === 'function') chip.click()
1451
- return true
1452
- } catch {
1453
- continue
1454
- }
1455
- }
1456
- return false
1457
- }
1458
-
1459
- /** Brings a kept page forward by pressing the column's own browser entry, the one the person would press. @param {string} sessionId - chat. @returns {void} */
1460
- function pressBrowserEntry(sessionId) {
1461
- if (mayShow(sessionId) !== true) return
1462
- for (const panel of panelOf(sessionId)) {
1463
- let row
1464
- try {
1465
- row = Array.from(panel.querySelectorAll('button, [role="button"]')).find((node) => {
1466
- const words = String(node.textContent ?? '').trim()
1467
- return words.length < 12 && /浏览器|Browser/i.test(words)
1468
- })
1469
- } catch {
1470
- continue
1471
- }
1472
- if (row === undefined || row === null) continue
1473
- try {
1474
- if (typeof row.click === 'function') row.click()
1475
- return
1476
- } catch {
1477
- continue
1478
- }
1479
- }
1480
- }
1481
-
1482
- /** One chat keeps one browser tab: extra ones are closed here, and only in
1483
- the column of the chat being read. @param {string} sessionId - chat. @returns {void} */
1484
- function tidyTabs(sessionId) {
1485
- /* Tabs belong to the person. Closing one is not this plugin's call, and a
1486
- page closed here is a page whose state is gone. */
1487
- return
1488
- // eslint-disable-next-line no-unreachable
1489
- if (mayShow(sessionId) !== true) return
1490
- for (const panel of panelOf(sessionId)) {
1491
- let chips
1492
- try {
1493
- chips = Array.from(panel.querySelectorAll(TAB_SEL))
1494
- } catch {
1495
- continue
1496
- }
1497
- const browsers = chips.filter(browserChip)
1498
- if (browsers.length < 2) continue
1499
- const extra = browsers.slice(1)
1500
- for (const chip of extra) {
1501
- const shut = chip.querySelector('[data-dockkit-tab-close], [data-sidebar-right-tab-close], [aria-label*="关闭"], button, [role="button"]')
1502
- try {
1503
- if (shut !== null && typeof shut.click === 'function') shut.click()
1504
- } catch {
1505
- /* a chip mid-teardown takes its controls with it */
1506
- }
1507
- }
1508
- }
1509
- }
1510
-
1511
- /** @param {string} sessionId - chat. @returns {number} tabs drawn in that chat's column. */
1512
- function tabsIn(sessionId) {
1513
- let count = 0
1514
- for (const panel of panelOf(sessionId)) {
1515
- try {
1516
- count += panel.querySelectorAll(TAB_SEL).length
1517
- } catch {
1518
- /* a column mid-teardown draws no tabs */
1519
- }
1520
- }
1521
- return count
1522
- }
1523
-
1524
- /* The product's browser tab can come back as a checkpoint of the page it
1525
- last had, with one button that brings the page back. A checkpoint is not a
1526
- page, so the column would keep showing a card instead of the site. */
1527
- const restoreTried = new Map()
1528
- const RESTORE_WORD = /^(恢复页面|恢复|Restore page|Restore)$/
1529
- /** Press the control that shows `word`, whether it is a button or a link. @param {Element} root - column to look in. @param {RegExp} word - the words a control shows. @returns {boolean} whether one was pressed. */
1530
- function pressWord(root, word) {
1531
- let all
1532
- try {
1533
- all = Array.from(root.querySelectorAll('button, a, [role="button"]'))
1534
- } catch {
1535
- return false
1536
- }
1537
- for (const one of all) {
1538
- if (word.test(String(one.textContent ?? '').trim()) !== true) continue
1539
- try {
1540
- one.click()
1541
- return true
1542
- } catch {
1543
- return false
1544
- }
1545
- }
1546
- /* A control whose words sit in a child is found by the words itself. */
1547
- try {
1548
- for (const one of Array.from(root.querySelectorAll('span, div'))) {
1549
- if (word.test(String(one.textContent ?? '').trim()) !== true) continue
1550
- const control = one.closest('button, a, [role="button"]') ?? one
1551
- control.click()
1552
- return true
1553
- }
1554
- } catch {
1555
- return false
1556
- }
1557
- return false
1558
- }
1559
- /** Bring back the page a tab shows only a checkpoint of. @param {string} sessionId - chat whose column to look in. @returns {boolean} whether a control was pressed. */
1560
- function restoreFace(sessionId) {
1561
- const who = String(sessionId ?? '')
1562
- /* A face is restored in the chat the person is on. Anywhere else it takes
1563
- a column the person is not reading. */
1564
- const screen = routedSession() || String(currentSession ?? '')
1565
- if (screen !== '' && screen !== who) return false
1566
- const last = restoreTried.get(who) ?? 0
1567
- if (Date.now() - last < 5_000) return false
1568
- let clicked = false
1569
- for (const root of who === '' ? [document.documentElement] : panelOf(who)) {
1570
- if (root === null || root === undefined) continue
1571
- if (pressWord(root, RESTORE_WORD) !== true) continue
1572
- restoreTried.set(who, Date.now())
1573
- clicked = true
1574
- break
1575
- }
1576
- if (clicked === true) beat({ tabType: true, phase: 'restore-face', session: who })
1577
- return clicked
1578
- }
1579
-
1580
- /** Type into the product's address bar: how an empty tab is made to hold a page. */
1581
- async function nativeOpen(sessionId, url) {
1582
- if (url === '') return 'no-url'
1583
- try {
1584
- let face
1585
- try {
1586
- face = faceOf?.()
1587
- } catch {
1588
- face = undefined
1589
- }
1590
- /* The tab opens at the page this call is for: an empty browser tab is a
1591
- second browser beside the one the person is reading. */
1592
- if (face !== undefined && face !== null && (mayShow(sessionId) === true || NATIVE_MODE === true)) openNativeTab(sessionId, NATIVE_KIND, url)
1593
- let webview = nativeElement(sessionId)
1594
- let slot = { form: null, input: null }
1595
- for (let i = 0; i < 20; i += 1) {
1596
- webview = nativeElement(sessionId)
1597
- slot = addressIn(sessionId)
1598
- if (slot.input === null && webview !== null) slot = addressOf(webview)
1599
- if (slot.input !== null) break
1600
- await new Promise((resolve) => setTimeout(resolve, 200))
1601
- }
1602
- if (slot.input === null) {
1603
- beat({ phase: 'native-address', session: sessionId, guest: webview !== null, ...domDump() })
1604
- return 'no-address'
1605
- }
1606
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
1607
- if (setter !== undefined) setter.call(slot.input, url)
1608
- else slot.input.value = url
1609
- slot.input.dispatchEvent(new Event('input', { bubbles: true }))
1610
- const form = slot.form
1611
- if (form !== null && typeof form.requestSubmit === 'function') form.requestSubmit()
1612
- else if (form !== null) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
1613
- else slot.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }))
1614
- beat({ phase: 'native-typed', session: sessionId, url: url.slice(0, 120), value: String(slot.input.value ?? '').slice(0, 120), form: form !== null, boxes: panelOf(sessionId).map((node) => ({ attrs: Array.from(node.attributes).map((a) => a.name + '=' + String(a.value).slice(-10)), addr: node.querySelectorAll('input[class*="address"]').length, guest: node.querySelectorAll('webview').length, frame: node.querySelectorAll('iframe').length, kid: node.children.length, html: String(node.innerHTML ?? '').slice(0, 80) })) })
1615
- return 'typed'
1616
- } catch (error) {
1617
- return 'failed:' + String(error?.message ?? error).slice(0, 80)
1618
- }
1619
- }
1620
-
1621
- /** The pane's own way into its guest, for an address typed by hand. @type {Map<string, (url: string) => void>} */
1622
- const steer = new Map()
1623
- /** Puts this chat's pane on screen.
1624
- * @type {null | ((asking: string) => void)} */
1625
- let reveal = null
1626
- /* Re-asserting the pane is what survives another tab closing it. */
1627
- let raise = null
1628
- /* The log, not the error, carries why a capture found no pixels. */
1629
- let probe = null
1630
-
1631
- /** Each chat's own pane element: the column holding this pane is that
1632
- * chat's column, whatever id the column is drawn under.
1633
- * @type {Map<string, Element>} */
1634
- const paneHosts = new Map()
1635
-
1636
- /** The chat whose sidebar pane is mounted here: a broadcast ask may only
1637
- * fall back to "the current chat" when this is the chat asking.
1638
- * @type {string} */
1639
- let currentSession = ''
1640
-
1641
- /**
1642
- * The chat the shell's address names, when it names one. The person can switch
1643
- * chat without any of this plugin's pages mounting, so the address is the only
1644
- * thing that says which chat is on screen; '' means the shell does not say.
1645
- * @returns {string} the chat on screen, or '' when unknown.
1646
- */
1647
- function routedSession() {
1648
- try {
1649
- const address = String(window.location?.href ?? '')
1650
- const hit = /session[-/:]([0-9a-f][0-9a-f-]{7,})/i.exec(address) // ui-copy-ignore
1651
- return hit === null ? '' : 'session-' + hit[1]
1652
- } catch {
1653
- return ''
1654
- }
1655
- }
1656
- /* Chats whose page is actually on screen. A page kept mounted behind another
1657
- chat is not: opening the right column for it takes the panel away from
1658
- what the person is looking at. */
1659
- const onScreen = new Set()
1660
-
1661
- /** @param {string} sessionId - chat the call belongs to. @returns {boolean} true when the panel may open for it. */
1662
- function mayShow(sessionId) {
1663
- if (revealAllowed(sessionId, { current: currentSession, routed: routedSession(), onScreen })) return true
1664
- /* This chat's own pane being drawn is this chat being read. */
1665
- const pane = paneHosts.get(String(sessionId ?? ''))
1666
- try {
1667
- const box = pane?.getBoundingClientRect?.()
1668
- return box !== undefined && box !== null && box.width >= 2 && box.height >= 2
1669
- } catch {
1670
- return false
1671
- }
1672
- }
1673
- /* What the assistant is doing in each chat's page, so the pane can say it. */
1674
- const acting = new Map()
1675
- /* The last file each chat downloaded, kept only as long as it is small
1676
- enough to hand back: the row above the page offers it to the chat. */
1677
- const offers = new Map()
1678
- const OFFER_LIMIT = 8 * 1024 * 1024
1679
- /** @type {Set<() => void>} */
1680
- const actWatchers = new Set()
1681
- const offWatchers = new Set()
1682
-
1683
- /**
1684
- * @param {string} name - file name.
1685
- * @returns {string} the type to hand a chat, from the name alone.
1686
- */
1687
- function mimeOf(name) {
1688
- const ext = String(name).toLowerCase().split('.').pop() ?? ''
1689
- const known = {
1690
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
1691
- gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf',
1692
- json: 'application/json', txt: 'text/plain', md: 'text/markdown',
1693
- csv: 'text/csv', zip: 'application/zip',
1694
- }
1695
- return known[ext] ?? 'application/octet-stream'
1696
- }
1697
- const setActing = (chat, tool) => {
1698
- if (String(tool) === '') acting.delete(chat)
1699
- else acting.set(chat, String(tool))
1700
- for (const tell of actWatchers) tell()
1701
- try {
1702
- beat({ tabType: registered, phase: 'act', tool: String(tool) })
1703
- } catch {
1704
- /* before the tab type is known there is nothing to report to */
1705
- }
1706
- }
1707
- /* Set when this page was showing one of our pages; a panel we opened and
1708
- left empty is given back instead of staying as a blank strip. */
1709
- let openedByUs = false
1710
- /** @type {null | ((open: boolean) => void)} right panel, once this page can reach it */
1711
- let column = null
1712
- /** Opens the chat's right column docked: the way the product's own tab does. */
1713
- let showColumn = null
1714
-
1715
- function beat(frame) {
1716
- void fetch(`${ROUTE}/uplink?session=`, {
1717
- method: 'POST',
1718
- headers: { 'content-type': 'application/json' },
1719
- body: JSON.stringify({ type: 'client-alive', at: new Date().toISOString(), ...frame }),
1720
- }).then(async (response) => {
1721
- if (response?.ok !== true) return
1722
- const seen = (await response.json().catch(() => null))?.epoch
1723
- if (typeof seen !== 'string' || seen === '' || seen === streamEpoch) return
1724
- const known = streamEpoch !== ''
1725
- streamEpoch = seen
1726
- if (known) reopenStream?.()
1727
- }).catch(() => {})
1728
- }
1729
-
1730
- /**
1731
- * Resolve the workspace that owns a chat, the same key the built-in browser
1732
- * leases its guest with: one partition per canonical CWD.
1733
- * @param {any} source - workspace controller face. @param {string} sessionId - chat. @returns {Promise<string>} lease key.
1734
- */
1735
- /** The service that says which workspace owns a chat, once it exists.
1736
- * @returns {any} the service, or undefined before it is provided. */
1737
- let workspacesNow = null
1738
-
1739
- /**
1740
- * The storage key of the chat's workspace, waited for instead of guessed: a
1741
- * key taken too early is a different jar of cookies from the product's tab.
1742
- * @param {string} sessionId - chat.
1743
- * @returns {Promise<string>} key the desktop bridge files storage under.
1744
- */
1745
- async function settledKey(sessionId) {
1746
- for (let i = 0; i < 60; i += 1) {
1747
- if (workspacesNow !== null) return workspaceKey(workspacesNow, sessionId)
1748
- await new Promise((resolve) => setTimeout(resolve, 100))
1749
- }
1750
- throw new Error(L('error.lease'))
1751
- }
1752
-
1753
- function workspaceKey(source, sessionId) {
1754
- return new Promise((resolve, reject) => {
1755
- const store = source?.list ?? source
1756
- /* A face that is not a store cannot say which workspace owns a chat; a
1757
- key per chat still keeps one sign-in per chat instead of failing. */
1758
- if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') {
1759
- resolve(`session:${sessionId}`)
1760
- return
1761
- }
1762
- const read = () => {
1763
- const snapshot = store?.getSnapshot?.()
1764
- if (snapshot === undefined || snapshot.phase !== 'ready') return undefined
1765
- const workspace = snapshot.items.find((item) => item.sessionIds.some((id) => id === sessionId))
1766
- return workspace === undefined ? `session:${sessionId}` : `cwd:${workspace.path}`
1767
- }
1768
- const settled = read()
1769
- if (settled !== undefined) {
1770
- resolve(settled)
1771
- return
1772
- }
1773
- const stop = store.subscribe(() => {
1774
- const value = read()
1775
- if (value === undefined) return
1776
- stop()
1777
- resolve(value)
1778
- })
1779
- setTimeout(() => {
1780
- stop()
1781
- reject(new Error(L('error.lease')))
1782
- }, 10_000)
1783
- })
1784
- }
1785
-
1786
- /** @param {HTMLWebViewElement} el - guest. @param {string} name - event. @param {number} ms - budget. @returns {Promise<boolean>} whether it fired. */
1787
- function waitEvent(el, name, ms) {
1788
- return new Promise((resolve) => {
1789
- let done = false
1790
- const finish = (hit) => {
1791
- if (done) return
1792
- done = true
1793
- try {
1794
- el.removeEventListener?.(name, onHit)
1795
- } catch {
1796
- /* a guest that has gone takes its listeners with it */
1797
- }
1798
- resolve(hit)
1799
- }
1800
- const onHit = () => finish(true)
1801
- el.addEventListener(name, onHit)
1802
- setTimeout(() => finish(false), ms)
1803
- })
1804
- }
1805
-
1806
- /**
1807
- * Frame reader for one downlink stream.
1808
- * @param {ReadableStream<Uint8Array>} body - response body. @param {(frame: any) => void} onFrame - frame sink. @returns {() => void} cancel.
1809
- */
1810
- function readFrames(body, onFrame) {
1811
- const reader = body.getReader()
1812
- const decoder = new TextDecoder()
1813
- let buffer = ''
1814
- let stopped = false
1815
- void (async () => {
1816
- while (stopped === false) {
1817
- const { value, done } = await reader.read().catch(() => ({ done: true }))
1818
- if (done) return
1819
- buffer += decoder.decode(value, { stream: true })
1820
- let cut
1821
- while ((cut = buffer.indexOf('\n\n')) >= 0) {
1822
- const chunk = buffer.slice(0, cut)
1823
- buffer = buffer.slice(cut + 2)
1824
- for (const line of chunk.split('\n')) {
1825
- if (line.startsWith('data:') === false) continue
1826
- try {
1827
- onFrame(JSON.parse(line.slice(5).trim()))
1828
- } catch {
1829
- /* a frame this version does not understand */
1830
- }
1831
- }
1832
- }
1833
- }
1834
- })()
1835
- return () => {
1836
- stopped = true
1837
- void reader.cancel().catch(() => {})
1838
- }
1839
- }
1840
-
1841
- /**
1842
- * Own one guest for one tab.
1843
- * @param {{host: HTMLElement, sessionId: string, key: () => Promise<string>, onPhase: (phase: string, error?: string) => void}} options - mount options. // ui-copy-ignore
1844
- * @returns {{dispose: () => void}} handle.
1845
- */
1846
- /* The strip only mounts the tab it shows, so a chat that is not on screen has
1847
- no page at all. This keeps one stashed guest per such chat, which is what
1848
- lets a call work with nobody looking. */
1849
- let keyForSession = null
1850
- const residents = new Map()
1851
- /* A chat's tab is asked for once: asking again rebuilds the page under a
1852
- call that is already using it. The mark is cleared with the pane. */
1853
- /* A chat whose tab was closed by an update is asked for once, at boot. */
1854
- const restoredOnce = new Set()
1855
- /* Chats whose own pane is open. Their pane owns the wire, so no stashed
1856
- guest is made for them: two guests on one chat leave the wire pointing at
1857
- whichever opened last, which is the one nobody can see. */
1858
- const paneSessions = new Set()
1859
- /** Pages mounted by a column, so a dead one can be told from a live one. @type {Map<string, {alive: () => boolean}>} */
1860
- const paneGuests = new Map()
1861
- let stashNode = null
1862
- /** @returns {HTMLElement} the container hidden guests live in. */
1863
- function stashHost() {
1864
- if (stashNode === null) {
1865
- stashNode = document.createElement('div')
1866
- stashNode.className = 'dshib-stash'
1867
- stashNode.setAttribute('aria-hidden', 'true')
1868
- /* The product's page is driven where it is; nothing is parked, so no
1869
- container of ours goes into the page. */
1870
- document.body.appendChild(stashNode)
1871
- }
1872
- return stashNode
1873
- }
1874
-
1875
- /* The product's browser tab exists only while its column is on screen. A
1876
- chat that is not on screen gets a page of its own, out of sight, so the
1877
- work goes on without touching what the person is reading. */
1878
- const PART_KEY = 'dshib:partition'
1879
-
1880
-
1881
- /** Pages this plugin made: they are never a page of the product's to drive. */
1882
- const ownViews = new WeakSet()
1883
-
1884
- /** @param {string} sessionId - chat whose page is out of sight. @returns {Promise<HTMLWebViewElement>} the guest. */
1885
-
1886
- /** @param {string} sessionId - chat that has no mounted pane. @returns {void} */
1887
- function keepResident(sessionId) {
1888
- if (sessionId === '') return
1889
- /* A chat that already had a page of the product's never gets a quiet
1890
- copy of it here: the copy looks the same and holds none of the same
1891
- state, so a lost page is reported instead. */
1892
- const who = String(sessionId ?? '')
1893
- /* In the product's own mode a page of our own is a stand-in: same URL,
1894
- none of the state. A chat out of sight is answered with a refusal
1895
- instead, and its work waits for the person to come back to it. */
1896
- if (NATIVE_MODE === true) {
1897
- beat({ tabType: true, phase: 'open', step: 'out-of-sight', session: who, error: 'native-mode' })
1898
- return
1899
- }
1900
- if (hasTab(who) === true || pageBindings.has(who) || pageLost.has(who)) {
1901
- beat({ tabType: true, phase: 'open', step: hasTab(who) === true ? 'kept' : 'page-lost', session: who, error: hasTab(who) === true ? 'page-exists' : pageLost.get(who) ?? 'bound' })
1902
- return
1903
- }
1904
- /* This window lends a page only to the product's own tab, and that tab
1905
- lives in the column of the chat being read. A chat out of sight waits
1906
- for its turn instead of opening a column in front of someone else. */
1907
- if (NATIVE_MODE === true && mayShow(sessionId) === true && panelOf(sessionId).length > 0) return
1908
- /* A chat whose column is not on screen has no page of the product's to
1909
- drive, so it gets one of its own out of sight, in the same cookies. */
1910
- /* A guest that lost its page is not the one this call needs: it goes, and
1911
- the chat gets a page again. */
1912
- const kept = residents.get(sessionId)
1913
- if (kept !== undefined && kept.alive() !== false) return
1914
- if (kept !== undefined) dropResident(sessionId)
1915
- const shown = paneGuests.get(sessionId)
1916
- if (shown !== undefined && shown.alive() === true) return
1917
- /* Three pages out of sight is enough to answer what is being asked; the
1918
- oldest one goes so a long session cannot pile them up. */
1919
- if (residents.size >= 3) {
1920
- const oldest = residents.keys().next()
1921
- if (oldest.done !== true) dropResident(oldest.value)
1922
- }
1923
- beat({ tabType: true, phase: 'resident-open', session: sessionId })
1924
- const guest = mountGuest({
1925
- shelf: stashHost(), // ui-copy-ignore
1926
- hidden: true,
1927
- sessionId,
1928
- key: () => (keyForSession === null ? '' : keyForSession(sessionId)),
1929
- onPhase: (next, error) => beat({ tabType: true, phase: `resident-${next}`, session: sessionId, error: error ?? '' }),
1930
- })
1931
- residents.set(sessionId, guest)
1932
- }
1933
-
1934
- /** @param {string} sessionId - chat whose pane took over. @returns {void} */
1935
- function dropResident(sessionId) {
1936
- const guest = residents.get(sessionId)
1937
- if (guest === undefined) return
1938
- residents.delete(sessionId)
1939
- guest.dispose()
1940
- beat({ tabType: true, phase: 'resident-drop', session: sessionId })
1941
- }
1942
-
1943
- function mountGuest(options) {
1944
- if (options.pane === true) paneSessions.add(options.sessionId)
1945
- const carrier = globalThis.dshDesktop
1946
- const bridge = carrier?.protocolVersion === 1 ? carrier.browser : undefined
1947
- let disposed = false
1948
- let key = ''
1949
- let lease
1950
- let webview
1951
- /* What this end made, and what it only borrowed, are never the same node:
1952
- a page the product drew is dropped by the product, and deleting it here
1953
- would take the person's browser away with this end. */
1954
- let ownedElement
1955
- let leaseId = ''
1956
- let cancel = () => {}
1957
- let cookieTimer
1958
-
1959
- const dispose = () => {
1960
- disposed = true
1961
- if (options.pane === true) paneSessions.delete(options.sessionId)
1962
- steer.delete(options.sessionId)
1963
- cancel()
1964
- clearInterval(cookieTimer)
1965
- try {
1966
- if (ownedElement !== undefined) ownedElement.remove()
1967
- } catch { /* an element the window already took */ }
1968
- if (bridge !== undefined && leaseId !== '') bridge.release(leaseId)
1969
- options.onPhase('closed')
1970
- }
1971
-
1972
- void (async () => {
1973
- if (NATIVE_MODE === false && bridge === undefined) {
1974
- options.onPhase('error', L('error.desktopOnly'))
1975
- return
1976
- }
1977
- options.onPhase('opening')
1978
- try {
1979
- if (NATIVE_MODE === true && options.hidden !== true) {
1980
- /* The product's tab owns the page. This end answers that page's
1981
- wire and nothing else: no lease, no element of its own. */
1982
- owns = false
1983
- const early = await nativeReady(20, options.sessionId)
1984
- webview = early
1985
- if (webview === undefined) {
1986
- const first = rememberedPage(options.sessionId)
1987
- if (first !== '') {
1988
- await nativeOpen(options.sessionId, first)
1989
- webview = await nativeReady(40, options.sessionId)
1990
- }
1991
- }
1992
- if (webview === undefined) {
1993
- /* The window lends a page only to the product's own tab, so this
1994
- chat's column is asked for one that already holds the page. */
1995
- openNativeTab(options.sessionId, NATIVE_KIND, rememberedPage(options.sessionId))
1996
- webview = await nativeReady(25, options.sessionId)
1997
- }
1998
- options.onPhase(webview === undefined ? 'waiting' : 'ready')
1999
- /* A chat with no column up gets its page made here instead, and the
2000
- product is never asked for a tab on its behalf. */
2001
- if (webview !== undefined) {
2002
- steer.set(options.sessionId, (to) => {
2003
- rememberPage(options.sessionId, to)
2004
- try {
2005
- webview.loadURL(to)
2006
- } catch (error) {
2007
- probe?.({ why: 'steer-failed', url: to, error: String(error?.message ?? error).slice(0, 80) })
2008
- }
2009
- })
2010
- /* What the tab is showing is remembered as it changes, so a reload
2011
- of the app reopens that page instead of an older one, whichever
2012
- way the page was reached. */
2013
- const note = () => {
2014
- try {
2015
- const here = String(webview.getURL?.() ?? '')
2016
- if (here === '' || here.startsWith('about:blank')) return
2017
- rememberPage(options.sessionId, here)
2018
- options.onUrl?.(here)
2019
- } catch { /* a guest mid-teardown has no URL */ }
2020
- }
2021
- for (const kind of ['did-navigate', 'did-navigate-in-page', 'did-stop-loading']) {
2022
- webview.addEventListener(kind, note)
2023
- }
2024
- note()
2025
- }
2026
- } else if (options.hidden === true && NATIVE_MODE === true) {
2027
- /* The product's tab lives only while its column is on screen. A chat
2028
- that is not on screen gets a page of its own out of sight, in the
2029
- same cookies, so the work goes on where nobody is reading. */
2030
- /* Only a guest the window has leased is allowed to attach, so a page
2031
- starts blank and is sent to its address once it is attached;
2032
- the lease also carries the storage the product's own tab uses, so
2033
- cookies are the same ones. */
2034
- if (bridge === undefined) {
2035
- options.onPhase('waiting')
2036
- return
2037
- }
2038
- try {
2039
- key = await options.key()
2040
- lease = await bridge.acquire(key)
2041
- leaseId = String(lease?.lease ?? '')
2042
- } catch (error) {
2043
- probe?.({ why: 'resident-lease-failed', error: String(error?.message ?? error).slice(0, 80) })
2044
- options.onPhase('waiting')
2045
- return
2046
- }
2047
- const part = String(lease?.partition ?? '')
2048
- if (part === '') {
2049
- options.onPhase('waiting')
2050
- return
2051
- }
2052
- webview = document.createElement('webview')
2053
- ownedElement = webview
2054
- ownViews.add(webview)
2055
- webview.className = 'dshib-guest'
2056
- webview.setAttribute('name', lease.lease)
2057
- webview.setAttribute('partition', part)
2058
- webview.setAttribute('allowpopups', '')
2059
- webview.setAttribute('src', `about:blank#${lease.lease}`)
2060
- const ready = waitEvent(webview, 'dom-ready', 20_000)
2061
- options.shelf.appendChild(webview) // ui-copy-ignore
2062
- if ((await ready) === false) {
2063
- const box = webview.getBoundingClientRect()
2064
- let wc = 'none'
2065
- try {
2066
- wc = String(webview.getWebContentsId())
2067
- } catch (error) {
2068
- wc = `ERR ${String(error?.message ?? error).slice(0, 40)}`
2069
- }
2070
- beat({ phase: 'guest-failed', session: options.sessionId, part, src: webview.getAttribute('src') ?? '', w: Math.round(box.width), h: Math.round(box.height), inPage: document.body.contains(webview), wc })
2071
- throw new Error(L('error.background'))
2072
- }
2073
- if (disposed) return
2074
- options.onPhase('ready')
2075
- const note = () => {
2076
- try {
2077
- const here = String(webview.getURL?.() ?? '')
2078
- if (here === '' || here.startsWith('about:blank')) return
2079
- rememberPage(options.sessionId, here)
2080
- options.onUrl?.(here)
2081
- } catch { /* a page mid-teardown has no URL */ }
2082
- }
2083
- for (const kind of ['did-navigate', 'did-navigate-in-page', 'did-stop-loading']) {
2084
- webview.addEventListener(kind, note)
2085
- }
2086
- const first = rememberedPage(options.sessionId)
2087
- if (first !== '') {
2088
- try {
2089
- webview.loadURL(first)
2090
- probe?.({ why: 'resident-open', url: first })
2091
- } catch (error) {
2092
- probe?.({ why: 'resident-open-failed', url: first, error: String(error?.message ?? error).slice(0, 80) })
2093
- }
2094
- }
2095
- steer.set(options.sessionId, (to) => {
2096
- rememberPage(options.sessionId, to)
2097
- try {
2098
- webview.loadURL(to)
2099
- } catch (error) {
2100
- probe?.({ why: 'steer-failed', url: to, error: String(error?.message ?? error).slice(0, 80) })
2101
- }
2102
- })
2103
- note()
2104
- } else {
2105
- key = await options.key()
2106
- lease = await bridge.acquire(key)
2107
- leaseId = String(lease?.lease ?? '')
2108
- if (disposed) return
2109
- webview = document.createElement('webview')
2110
- ownedElement = webview
2111
- ownViews.add(webview)
2112
- webview.className = 'dshib-guest'
2113
- webview.setAttribute('name', lease.lease)
2114
- webview.setAttribute('partition', lease.partition)
2115
- webview.setAttribute('allowpopups', '')
2116
- webview.setAttribute('src', `about:blank#${lease.lease}`)
2117
- const ready = waitEvent(webview, 'dom-ready', 20_000)
2118
- options.shelf.appendChild(webview) // ui-copy-ignore
2119
- if ((await ready) === false) throw new Error(L('error.lease'))
2120
- if (disposed) return
2121
- options.onPhase('ready')
2122
- /* Cookies change without a reload, so they are kept while the guest is alive;
2123
- a page that has not touched them since the last save is not written again. */
2124
- let saved = ''
2125
- const saveIfChanged = async () => {
2126
- if (disposed || key === '') return
2127
- try {
2128
- const now = String(await within(webview.executeJavaScript('document.cookie', true), 4000, 'cookie read'))
2129
- if (now === saved) return
2130
- saved = now
2131
- await cookiesSave(webview, key, probe)
2132
- } catch {
2133
- /* a page mid-load is saved on its next stop */
2134
- }
2135
- }
2136
- cookieTimer = setInterval(() => {
2137
- void saveIfChanged()
2138
- }, 30_000)
2139
- webview.addEventListener('did-stop-loading', () => {
2140
- void saveIfChanged()
2141
- })
2142
- /* A tab that was closed on a page opens on that page again, without
2143
- waiting for the chat to ask for anything. */
2144
- const kept = rememberedPage(options.sessionId)
2145
- if (kept !== '') {
2146
- try {
2147
- const loaded = waitEvent(webview, 'did-stop-loading', 12_000)
2148
- webview.loadURL(kept)
2149
- probe?.({ why: 'reopen', url: kept })
2150
- /* A sign-in can only be written once the page it belongs to is loaded,
2151
- and writing it does not redraw the page, so it is loaded once more. */
2152
- if ((await loaded) !== false) {
2153
- const restored = await cookiesRestore(webview, key, probe)
2154
- if (restored > 0) webview.loadURL(kept)
2155
- }
2156
- } catch (error) {
2157
- probe?.({ why: 'reopen-failed', url: kept, error: String(error?.message ?? error).slice(0, 80) })
2158
- }
2159
- }
2160
- steer.set(options.sessionId, (to) => {
2161
- rememberPage(options.sessionId, to)
2162
- try {
2163
- webview.loadURL(to)
2164
- } catch (error) {
2165
- probe?.({ why: 'steer-failed', url: to, error: String(error?.message ?? error).slice(0, 80) })
2166
- }
2167
- })
2168
- /* The pane shows which page it holds; the guest is the only one that
2169
- knows, so it reports every navigation it makes. */
2170
- const tellUrl = () => {
2171
- try {
2172
- const here = String(webview.getURL?.() ?? '')
2173
- if (here !== '') options.onUrl?.(here)
2174
- } catch { /* a guest mid-teardown has no URL */ }
2175
- }
2176
- webview.addEventListener('did-navigate', tellUrl)
2177
- webview.addEventListener('did-navigate-in-page', tellUrl)
2178
- tellUrl()
2179
-
2180
- }
2181
- cancel = readFrames(
2182
- (await fetch(`${ROUTE}/downlink?session=${encodeURIComponent(options.sessionId)}&workspace=${encodeURIComponent(keyForSession?.(options.sessionId) ?? '')}`, { cache: 'no-store' })).body,
2183
- (frame) => {
2184
- if (frame?.type !== 'call') return
2185
- /* A page answers its own chat and no other: a call that names a
2186
- different chat is not this page's to run. */
2187
- const owner = String(frame.session ?? '')
2188
- if (owner !== '' && owner !== String(options.sessionId ?? '')) return
2189
- const reply = (payload) => {
2190
- void fetch(`${ROUTE}/uplink?session=${encodeURIComponent(options.sessionId)}`, {
2191
- method: 'POST',
2192
- headers: { 'content-type': 'application/json' },
2193
- body: JSON.stringify({ id: frame.id, ...payload }),
2194
- })
2195
- }
2196
- setActing(options.sessionId, String(frame.tool ?? ''))
2197
- tidyTabs(options.sessionId)
2198
- void (async () => {
2199
- try {
2200
- if (NATIVE_MODE === true) {
2201
- /* The column came back: what the person is looking at is the
2202
- page the operation belongs to. */
2203
- const shown = nativeElement(options.sessionId)
2204
- if (shown !== null && shown.isConnected === true) webview = shown
2205
- }
2206
- /* A page the product folded away is still this chat's page.
2207
- Small or absent surface only says the column is not drawn; it
2208
- is not evidence that the page is gone, and a second page would
2209
- not carry the form, the scroll or the state the first holds. */
2210
- if (NATIVE_MODE === true && (webview === undefined || webview.isConnected !== true)) {
2211
- /* A chat whose column is not on screen keeps a page of its own
2212
- out of sight, so the work goes on while the person reads
2213
- another chat. */
2214
- let own = NATIVE_MODE === true
2215
- ? undefined
2216
- : residents.get(options.sessionId)?.view?.() ?? paneGuests.get(options.sessionId)?.view?.()
2217
- if (own === undefined) {
2218
- keepResident(options.sessionId)
2219
- for (let wait = 0; wait < 40 && own === undefined; wait += 1) {
2220
- await new Promise((resolve) => setTimeout(resolve, 150))
2221
- own = residents.get(options.sessionId)?.view?.()
2222
- }
2223
- if (own !== undefined) beat({ tabType: true, phase: 'resident-used', session: options.sessionId })
2224
- }
2225
- if (own !== undefined) {
2226
- webview = own
2227
- } else if (hasTab(options.sessionId) === true) {
2228
- /* This chat has a browser of the product's: the page it
2229
- already holds is the one to drive, so it is looked for
2230
- again instead of a second one being made here. */
2231
- if (frame.tool === 'navigate') {
2232
- const ask = String(frame.args?.url ?? '')
2233
- if (ask !== '') await nativeOpen(options.sessionId, ask)
2234
- }
2235
- webview = await nativeReady(25, options.sessionId)
2236
- if (webview === undefined) {
2237
- reply({ ok: false, error: L('error.background') })
2238
- return
2239
- }
2240
- } else {
2241
- const ask = String(frame.args?.url ?? '')
2242
- if (frame.tool === 'navigate' && ask !== '') await nativeOpen(options.sessionId, ask)
2243
- /* The product's tab holds nothing until a page is asked for,
2244
- so the page this chat last had is what it is given. */
2245
- if (frame.tool !== 'navigate') {
2246
- const last = String(rememberedPage(options.sessionId) ?? '')
2247
- if (last !== '') await nativeOpen(options.sessionId, last)
2248
- }
2249
- webview = await nativeReady(25, options.sessionId)
2250
- if (webview === undefined) {
2251
- /* This chat is not the one on screen, so it has no pane to
2252
- drive. Every other chat keeps the page it is reading. */
2253
- reply({ ok: false, error: L('error.background') })
2254
- return
2255
- }
2256
- }
2257
- }
2258
- /* A view the app is not drawing has no surface, and a guest
2259
- without one runs no script. Give it a surface out of sight
2260
- first, and only bring the chat's tab forward as a second try. */
2261
- const room = () => webview.getBoundingClientRect()
2262
- const hidden = () => webview.hidden === true || webview.closest?.('[hidden]') !== null
2263
- if (NATIVE_MODE === false && (hidden() || room().width < 2 || room().height < 2)) {
2264
- parkHiddenViews()
2265
- for (let wait = 0; wait < 25; wait += 1) {
2266
- await new Promise((resolve) => setTimeout(resolve, 100))
2267
- if (room().width >= 2 && room().height >= 2) break
2268
- if (wait === 5) {
2269
- try {
2270
- reveal?.(String(frame.session ?? ''))
2271
- } catch {
2272
- /* the page stays where it is; the wait below reports it */
2273
- }
2274
- }
2275
- }
2276
- if (room().width < 2 || room().height < 2) {
2277
- reply({ ok: false, error: L('error.background') })
2278
- return
2279
- }
2280
- }
2281
- /* A page the product keeps but does not draw answers script,
2282
- reads and ordinary clicks where it is. Nothing is brought
2283
- forward for a chat the person is not reading. */
2284
- if (NATIVE_MODE === true && frame.tool === 'shot' && (hidden() || room().width < 2 || room().height < 2)) {
2285
- reply({ ok: false, error: L('error.captureUnavailable') })
2286
- return
2287
- }
2288
- if (frame.tool !== 'navigate' && frame.tool !== 'shot') {
2289
- const asked = rememberedPage(frame.session)
2290
- let here = ''
2291
- let known = false
2292
- try {
2293
- here = String(webview.getURL?.() ?? '')
2294
- known = true
2295
- } catch {
2296
- /* a guest that cannot report its page is left alone */
2297
- known = false
2298
- }
2299
- /* Only a guest that says it is blank is reopened: reloading a
2300
- live page would throw away what the chat was looking at. */
2301
- const blank = known === true && (here === '' || here.startsWith('about:blank'))
2302
- if (blank === true && asked !== '') {
2303
- try {
2304
- await new Promise((resolve) => {
2305
- webview.addEventListener('did-stop-loading', () => resolve(), { once: true })
2306
- webview.loadURL(asked)
2307
- setTimeout(resolve, 20_000)
2308
- })
2309
- probe?.({ why: 'restore', url: asked })
2310
- } catch (error) {
2311
- probe?.({ why: 'restore-failed', url: asked, error: String(error?.message ?? error).slice(0, 80) })
2312
- }
2313
- }
2314
- }
2315
- if (frame.tool === 'navigate') {
2316
- let failed = ''
2317
- const stop = new Promise((resolve) => {
2318
- webview.addEventListener('did-fail-load', (event) => {
2319
- if (event.errorCode === -3) return
2320
- failed = event.errorDescription || 'load failed'
2321
- resolve()
2322
- }, { once: true })
2323
- webview.addEventListener('did-stop-loading', () => resolve(), { once: true })
2324
- setTimeout(resolve, 45_000)
2325
- })
2326
- const asked = String(frame.args?.url ?? '')
2327
- /* Asked for the page already on screen: a browser answers that
2328
- from its own cache, so the network is what has to be asked
2329
- again, and a page the machine changed shows its new text. */
2330
- if (webview.getURL() === asked) {
2331
- webview.reloadIgnoringCache()
2332
- probe({ why: 'reloaded', url: asked })
2333
- } else {
2334
- webview.loadURL(asked)
2335
- }
2336
- await stop
2337
- if (failed !== '') throw new Error(failed)
2338
- /* A site this workspace signed in to before opens signed in, one
2339
- silent reload later: a cookie written from the page cannot
2340
- affect the document already on screen. */
2341
- if (NATIVE_MODE === false) try {
2342
- const before = String(await within(
2343
- webview.executeJavaScript('document.cookie', true), 4000, 'cookie read',
2344
- ))
2345
- if ((await cookiesRestore(webview, key, probe)) > 0) {
2346
- const after = String(await within(
2347
- webview.executeJavaScript('document.cookie', true), 4000, 'cookie read',
2348
- ))
2349
- if (after !== before) {
2350
- const again = new Promise((resolve) => {
2351
- webview.addEventListener('did-stop-loading', () => resolve(), { once: true })
2352
- setTimeout(resolve, 45_000)
2353
- })
2354
- webview.loadURL(asked)
2355
- await again
2356
- probe?.({ why: 'signed-in-again', url: asked })
2357
- }
2358
- }
2359
- } catch (error) {
2360
- probe?.({ why: 'sign-in-failed', message: String(error?.message ?? error).slice(0, 80) })
2361
- }
2362
- /* A page the chat asked for is the one the person wants to
2363
- watch, so it comes forward while the chat drives it — but
2364
- only for the chat on screen, or the panel would move under
2365
- whoever is reading a different one. */
2366
- if (mayShow(String(frame.session ?? ''))) {
2367
- try {
2368
- reveal?.(String(frame.session ?? ''))
2369
- } catch {
2370
- /* the page loads behind; the answer still carries it */
2371
- }
2372
- }
2373
- rememberPage(frame.session, asked)
2374
- reply({ ok: true, value: await runIn(webview, 'view', {}) })
2375
- return
2376
- }
2377
- if (frame.tool === 'shot') {
2378
- let target = webview
2379
- /* The chat that asked is the only one whose page this call may
2380
- bring forward; anyone else keeps the view they are using. */
2381
- const asking = String(frame.session ?? '')
2382
- const mine = mayShow(asking)
2383
- const colWide = () => {
2384
- const column = document.querySelector('[class*="rightbarCol"]')
2385
- if (column === null) return 'no-column'
2386
- const r = column.getBoundingClientRect()
2387
- return Math.round(r.width) + 'x' + Math.round(r.height)
2388
- }
2389
- const before = colWide()
2390
- const box = target.getBoundingClientRect()
2391
- const keepQuiet = frame.args?.offscreen === true
2392
- if (keepQuiet === false && (box.width < 2 || box.height < 2 || target.isConnected === false)) {
2393
- if (mine) {
2394
- try {
2395
- reveal?.(asking)
2396
- } catch {
2397
- /* the pane stays closed; the capture reports the geometry */
2398
- }
2399
- }
2400
- for (let wait = 0; wait < 12; wait += 1) {
2401
- await new Promise((resolve) => setTimeout(resolve, 150))
2402
- if (mine) raise?.(asking)
2403
- const live = options.shelf.querySelector('webview') // ui-copy-ignore
2404
- if (live !== null && live.getBoundingClientRect().width > 1) {
2405
- target = live // ui-copy-ignore
2406
- break
2407
- }
2408
- }
2409
- }
2410
- if (webview.hidden === true || webview.closest?.('[hidden]') !== null || webview.getBoundingClientRect().width < 2) {
2411
- parkHiddenViews()
2412
- try {
2413
- document.documentElement.style.setProperty('--dshib-park-w', '575px')
2414
- document.documentElement.style.setProperty('--dshib-park-h', '1080px')
2415
- } catch {
2416
- /* the rule keeps its own size */
2417
- }
2418
- for (let wait = 0; wait < 6; wait += 1) {
2419
- await new Promise((resolve) => setTimeout(resolve, 120))
2420
- if (webview.getBoundingClientRect().width > 1) {
2421
- target = webview
2422
- break
2423
- }
2424
- }
2425
- probe?.({ why: 'capture-parked', body: Math.round(target.getBoundingClientRect().width) })
2426
- }
2427
- const wide = colWide()
2428
- probe?.({
2429
- why: 'shot-geometry', columnBefore: before, columnAfter: wide, mine,
2430
- tries: 'poll', ask: asking, cur: currentSession, routed: routedSession(), ...surface(webview),
2431
- })
2432
- if (target.getBoundingClientRect().width < 2 || target.getBoundingClientRect().height < 2) {
2433
- throw new Error("this chat's page is not on screen")
2434
- }
2435
- await runIn(target, 'pointer', {}).catch(() => {})
2436
- try {
2437
- reply({ ok: true, value: { png: await grab(target) } })
2438
- return
2439
- } catch (error) {
2440
- /* A page with a size can still produce no frames while it
2441
- is covered; parking it gives the surface one of its own. */
2442
- probe?.({ why: 'capture-retry', first: String(error?.message ?? error).slice(0, 90) })
2443
- const here = await webview.executeJavaScript('location.href', true).catch(() => '')
2444
- const remembered = rememberedPage(frame.session)
2445
- const href = String(here ?? '').startsWith('about:blank') === true ? remembered : String(here ?? '')
2446
- /* A page can keep its size and still be behind another tab of
2447
- this chat's pane: the app draws what it shows. Showing this
2448
- tab is what makes a surface, and it is what the image is of.
2449
- A chat that is not the one on screen is not shown. */
2450
- try {
2451
- if (mayShow(asking)) reveal?.(asking)
2452
- } catch {
2453
- /* the pane stays as it is; the second try reports it */
2454
- }
2455
- let shown = false
2456
- for (let wait = 0; wait < 10; wait += 1) {
2457
- await new Promise((resolve) => setTimeout(resolve, 120))
2458
- try {
2459
- raise?.(asking)
2460
- } catch {
2461
- /* keep polling */
2462
- }
2463
- if (webview.getBoundingClientRect().width > 1) shown = true
2464
- }
2465
- probe?.({ why: 'capture-show', href, shown, column: colWide() })
2466
- try {
2467
- const png = await grab(webview)
2468
- reply({ ok: true, value: { png } })
2469
- return
2470
- } catch (again) {
2471
- probe?.({ why: 'capture-covered', href, error: String(again?.message ?? again).slice(0, 400) })
2472
- throw new Error('this chat\'s page is not being drawn — bring its browser tab forward to take an image')
2473
- }
2474
- }
2475
- }
2476
- if (frame.tool === 'download') {
2477
- const id = 'dl-' + frame.id
2478
- const want = String(frame.args?.name ?? '')
2479
- let name = want
2480
- let next = null
2481
- try {
2482
- const opened = await runIn(webview, 'dl-open', {
2483
- id,
2484
- url: frame.args?.url, // ui-copy-ignore
2485
- ref: frame.args?.ref, // ui-copy-ignore
2486
- name: want, // ui-copy-ignore
2487
- })
2488
- name = String(opened?.name ?? (want === '' ? 'download' : want))
2489
- next = () => runIn(webview, 'dl-next', { id })
2490
- } catch (error) {
2491
- /* The page refused its own network; take the address and go
2492
- around it rather than telling the user to. */
2493
- const here = typeof frame.args?.url === 'string' && frame.args.url !== ''
2494
- ? frame.args.url
2495
- : await runIn(webview, 'href', { ref: frame.args?.ref })
2496
- const mine = await fetchHere(String(here), want)
2497
- name = mine.name
2498
- next = mine.next
2499
- probe?.({ why: 'download-half', href: String(here).slice(0, 120), error: String(error?.message ?? error).slice(0, 80) })
2500
- }
2501
- const ticket = String(frame.args?.ticket ?? '')
2502
- let bag = ''
2503
- let offered = true
2504
- let offset = 0
2505
- let done = false
2506
- let landed = null
2507
- while (done === false) {
2508
- const part = await next()
2509
- const bytes = bytesOf(String(part?.data ?? ''))
2510
- if (offered === true) {
2511
- bag += String(part?.data ?? '')
2512
- if (bag.length > OFFER_LIMIT * 2) offered = false
2513
- }
2514
- done = part?.done === true
2515
- const answer = await fetch(
2516
- `${ROUTE}/grab?ticket=${encodeURIComponent(ticket)}&name=${encodeURIComponent(name)}` // ui-copy-ignore
2517
- + `&offset=${offset}&final=${done === true ? 1 : 0}`,
2518
- { method: 'POST', body: bytes },
2519
- )
2520
- landed = await answer.json().catch(() => null)
2521
- if (landed?.ok !== true) throw new Error(String(landed?.error ?? 'the file did not reach the folder'))
2522
- offset += bytes.length
2523
- }
2524
- const savedPath = String(landed?.path ?? '')
2525
- const savedBytes = Number(landed?.bytes ?? offset)
2526
- if (offered === true && bag !== '' && offers.has(String(frame.session)) === true) {
2527
- offers.set(String(frame.session), { name, b64: bag })
2528
- for (const tell of offWatchers) tell()
2529
- }
2530
- /* Both shapes travel: an older half reads the sentence, and
2531
- the half that saved the file reads the path. */
2532
- reply({
2533
- ok: true,
2534
- value: { text: `Saved ${savedPath} (${savedBytes} bytes)`, path: savedPath, bytes: savedBytes },
2535
- })
2536
- return
2537
- }
2538
- if (frame.tool === 'script') {
2539
- const body = String(frame.args?.code ?? '')
2540
- /* A bare script returns its completion value, so "1 + 1" and
2541
- "const a = 1; a + 1" both answer. Only code that needs a
2542
- function body — a top-level await or return — falls back
2543
- to the wrapper, because the wrapper hides completion. */
2544
- let result
2545
- try {
2546
- result = await webview.executeJavaScript(body, true)
2547
- } catch (error) {
2548
- /* Only a script that will not parse needs the wrapper;
2549
- a script that threw must keep the message it threw. */
2550
- const why = String(error?.message ?? error)
2551
- if (/SyntaxError|Unexpected (token|identifier|end)|await is only valid|Illegal return/i.test(why) !== true) throw error // ui-copy-ignore
2552
- result = await webview.executeJavaScript('(async () => {\n' + body + '\n})()', true)
2553
- }
2554
- const text = typeof result === 'string' ? result : JSON.stringify(result ?? null)
2555
- reply({ ok: true, value: text.slice(0, 8000) })
2556
- return
2557
- }
2558
- await runIn(webview, 'dl-watch', {}).catch(() => '')
2559
- const answer = await runIn(webview, frame.tool, frame.args ?? {})
2560
- const landed = await landFiles(webview, String(frame.session ?? ''))
2561
- const skipped = await runIn(webview, 'dl-skip', {}).catch(() => [])
2562
- const missed = (Array.isArray(skipped) ? skipped : [])
2563
- .map((one) => (one?.why === 'many'
2564
- ? `\nNot saved (six files at a time): ${String(one?.name ?? 'download')}`
2565
- : `\nNot saved (over 7 MB): ${String(one?.name ?? 'download')} (${Number(one?.size ?? 0)} bytes)`))
2566
- .join('')
2567
- if (landed.length === 0 && missed === '') {
2568
- reply({ ok: true, value: answer })
2569
- } else {
2570
- const tail = landed.map((file) => `\nSaved ${file.path} (${file.bytes} bytes)`).join('')
2571
- reply({ ok: true, value: typeof answer === 'string' ? answer + tail + missed : { text: (tail + missed).trim(), files: landed } })
2572
- }
2573
- } catch (error) {
2574
- reply({ ok: false, error: String(error?.message ?? error) })
2575
- } finally {
2576
- unparkHiddenViews()
2577
- setActing(options.sessionId, '')
2578
- }
2579
- })()
2580
- },
2581
- )
2582
- } catch (error) {
2583
- if (disposed) return
2584
- options.onPhase('error', String(error?.message ?? error))
2585
- }
2586
- })()
2587
-
2588
- const held = {
2589
- dispose,
2590
- /** @returns {boolean} true while this guest still holds a live page. */
2591
- alive: () => disposed !== true && webview !== undefined && webview.isConnected !== false,
2592
- /** @returns {HTMLElement | undefined} the page this guest holds, when it has one. */
2593
- view: () => (disposed !== true && webview !== undefined && webview.isConnected !== false ? webview : undefined),
2594
- /** Put this page into a slot of its own chat, keeping the page itself.
2595
- * @param {HTMLElement | null | undefined} shelf - the slot to fill.
2596
- * @returns {boolean} whether the page moved there. */
2597
- adopt(shelf) {
2598
- if (shelf === undefined || shelf === null) return false
2599
- const el = held.view()
2600
- /* Only a page this end made can be moved: a page the product drew is
2601
- placed by the product, and moving it would take it off its tab. */
2602
- if (el === undefined || ownedElement === undefined) return false
2603
- try {
2604
- shelf.appendChild(el) // ui-copy-ignore
2605
- return true
2606
- } catch {
2607
- return false
2608
- }
2609
- },
2610
- /** Take this page out of sight without losing it, so the chat it
2611
- * belongs to finds it as it was when the person comes back.
2612
- * @returns {boolean} whether the page was kept. */
2613
- park() {
2614
- if (held.view() === undefined || ownedElement === undefined) {
2615
- dispose()
2616
- return false
2617
- }
2618
- try {
2619
- stashHost().appendChild(ownedElement) // ui-copy-ignore
2620
- return true
2621
- } catch {
2622
- return false
2623
- }
2624
- },
2625
- }
2626
- if (options.pane === true) paneGuests.set(options.sessionId, held)
2627
- return held
2628
- }
2629
-
2630
- /* The product's browser tab holds the page, so nothing of ours is mounted
2631
- for it and no slot of ours is drawn: the wire is opened here instead, for
2632
- the chat whose tab exists. Without it every call would find no tab. */
2633
- function wireNative(sessionId, page = '') {
2634
- const who = String(sessionId ?? '')
2635
- if (NATIVE_MODE !== true || who === '') return
2636
- const held = paneGuests.get(who)
2637
- if (held !== undefined && held.alive() === true) return
2638
- /* A page takes a moment to attach. Asking again while it is still coming
2639
- used to leave a second, then a third page for one chat, and every call
2640
- then landed on a page the last call had not made. */
2641
- const born = wireBorn.get(who) ?? 0
2642
- if (born !== 0 && Date.now() - born < WIRE_GRACE_MS) return
2643
- if (held !== undefined) paneGuests.delete(who)
2644
- wireBorn.set(who, Date.now())
2645
- /* A chat whose column is not on screen has no tab of the product's to
2646
- drive, so the wire keeps a page of its own, out of sight, in the same
2647
- storage: the work goes on while someone reads another chat. */
2648
- /* A tab of the product's is used when this chat's column is on screen: the
2649
- person sees the page and the pointer. A chat that is not being read has
2650
- no column to put a tab in, so the page is kept out of sight instead. */
2651
- /* A chat that was already given a browser of the product's keeps that page
2652
- even while its column is not drawn: a second page made here would look
2653
- the same and hold none of the same state. */
2654
- let own = NATIVE_MODE === true && panelOf(who).length === 0 && hasTab(who) !== true
2655
- /* A page with an address belongs in the product's tab of the chat on
2656
- screen: the person sees it and the pointer has somewhere to be. Any
2657
- other chat keeps its page out of sight. */
2658
- const asked = String(page ?? '')
2659
- if (own && asked !== '') {
2660
- const put = openNativeTab(who, NATIVE_KIND, asked)
2661
- if (put === 'asked' || put === 'kept' || put === 'page') own = false
2662
- }
2663
- if (own) ownWires.add(who)
2664
- beat({ tabType: true, phase: own ? 'wired-own' : 'wired', session: who })
2665
- mountGuest({
2666
- shelf: stashHost(), // ui-copy-ignore
2667
- hidden: own,
2668
- pane: true,
2669
- sessionId: who,
2670
- /* The same jar the product's own tab uses: a page keyed by chat alone
2671
- would hold a second, signed-out copy of every site. */
2672
- key: () => settledKey(who),
2673
- onPhase: (next, message) => beat({ tabType: true, phase: `wire-${next}`, session: who, error: message ?? '' }),
2674
- })
2675
- }
2676
-
2677
- /** Wires that hold a page of their own because no tab of the product's was there. */
2678
- const ownWires = new Set()
2679
-
2680
- /* A wire outlives its page unless it is looked after: the tab is closed by
2681
- the person, not by us, so the page it was driving is checked for, and who
2682
- is on screen is read from the pixels the page is given. */
2683
- /** When each wire was made, so one still looking for its page is not dropped. */
2684
- const wireBorn = new Map()
2685
- /** How long a wire may look for its page before it is given up on. */
2686
- const WIRE_GRACE_MS = 20_000
2687
- function tendWires() {
2688
- if (NATIVE_MODE !== true) return
2689
- for (const [who, held] of Array.from(paneGuests.entries())) {
2690
- const page = nativeElement(who)
2691
- if (ownWires.has(who) && page === null && held.alive() === true) continue
2692
- if (page !== null) ownWires.delete(who)
2693
- if (page === null || held.alive() !== true) {
2694
- const born = wireBorn.get(who) ?? 0
2695
- /* The product may keep this chat's page mounted while it draws another
2696
- chat's column. Nothing here can be sure the page is gone, so a chat
2697
- that was given a browser keeps its wire. */
2698
- if (page === null && held.alive() === true && hasTab(who) === true) {
2699
- beat({ tabType: true, phase: 'wire-tab-kept', session: who })
2700
- continue
2701
- }
2702
- if (page === null) {
2703
- restoreFace(who)
2704
- if (Date.now() - born < WIRE_GRACE_MS) continue
2705
- paneGuests.delete(who)
2706
- wireBorn.delete(who)
2707
- held.dispose()
2708
- beat({ tabType: true, phase: 'wire-gone', session: who })
2709
- }
2710
- continue
2711
- }
2712
- let wide = false
2713
- try {
2714
- const box = page.getBoundingClientRect()
2715
- wide = box.width >= 2 && box.height >= 2 && page.closest('[hidden]') === null
2716
- } catch {
2717
- wide = false
2718
- }
2719
- if (wide === true) {
2720
- onScreen.add(who)
2721
- currentSession = who
2722
- } else {
2723
- onScreen.delete(who)
2724
- if (currentSession === who) currentSession = ''
2725
- }
2726
- }
2727
- }
2728
- if (globalThis.__dshibWires !== true) {
2729
- globalThis.__dshibWires = true
2730
- setInterval(tendWires, 3_000) // ui-copy-ignore
2731
- }
2732
-
2733
- /** @param {{sessionId: string, key: () => Promise<string>}} props - slot props. @returns {any} element. */
2734
- function Body(props) {
2735
- const host = React.useRef(null) // ui-copy-ignore
2736
- const [phase, setPhase] = React.useState('opening')
2737
- const [error, setError] = React.useState('')
2738
- const [href, setHref] = React.useState('')
2739
- const [draft, setDraft] = React.useState('')
2740
- const [picked, setPicked] = React.useState('')
2741
- const [offered, setOffered] = React.useState(null)
2742
- const [act, setAct] = React.useState('')
2743
- React.useEffect(() => {
2744
- offers.set(props.sessionId, null)
2745
- const tell = () => setOffered(offers.get(props.sessionId) ?? null)
2746
- offWatchers.add(tell)
2747
- tell()
2748
- return () => {
2749
- offWatchers.delete(tell)
2750
- offers.delete(props.sessionId)
2751
- }
2752
- }, [props.sessionId])
2753
- React.useEffect(() => {
2754
- const tell = () => setAct(acting.get(props.sessionId) ?? '')
2755
- actWatchers.add(tell)
2756
- tell()
2757
- return () => actWatchers.delete(tell)
2758
- }, [props.sessionId])
2759
- React.useEffect(() => {
2760
- if (host.current === null) return undefined // ui-copy-ignore
2761
- paneHosts.set(props.sessionId, host.current) // ui-copy-ignore
2762
- currentSession = props.sessionId
2763
- onScreen.add(props.sessionId)
2764
- /* A page this chat already had comes back as it was: it is moved into
2765
- the column, not made again. */
2766
- const before = paneGuests.get(props.sessionId)
2767
- const kept = before !== undefined && before.alive() === true && before.adopt(host.current) === true ? before : undefined // ui-copy-ignore
2768
- const guest = kept ?? mountGuest({
2769
- shelf: host.current, // ui-copy-ignore
2770
- pane: true,
2771
- sessionId: props.sessionId,
2772
- key: props.workspace, // ui-copy-ignore
2773
- onUrl: setHref,
2774
- onPhase: (next, message) => { // ui-copy-ignore
2775
- setPhase(next)
2776
- if (next === 'ready') dropResident(props.sessionId)
2777
- setError(message ?? '')
2778
- beat({ tabType: true, phase: `guest-${next}`, session: props.sessionId, error: message ?? '' })
2779
- },
2780
- })
2781
- return () => {
2782
- paneHosts.delete(props.sessionId)
2783
- onScreen.delete(props.sessionId)
2784
- if (currentSession === props.sessionId) currentSession = ''
2785
- /* Leaving a chat parks its page; only a page this end does not own is
2786
- dropped, because the product holds that one. */
2787
- guest.park()
2788
- }
2789
- }, [props.sessionId])
2790
- React.useEffect(() => { setDraft(href) }, [href])
2791
- /* A selection is read from the page while the pane is on screen, so the
2792
- drag handle carries it without asking the page at drag time. */
2793
- React.useEffect(() => {
2794
- let live = true
2795
- const tick = setInterval(() => {
2796
- const el = host.current?.querySelector('webview') // ui-copy-ignore
2797
- if (live !== true) return
2798
- if (!el) return
2799
- /* Who is on screen is decided here, from the pixels the pane is given,
2800
- not from who mounted last. */
2801
- const wide = el.getBoundingClientRect().width >= 2 && el.closest('[hidden]') === null // ui-copy-ignore
2802
- if (wide) {
2803
- onScreen.add(props.sessionId)
2804
- currentSession = props.sessionId
2805
- openedByUs = true
2806
- } else {
2807
- onScreen.delete(props.sessionId)
2808
- if (currentSession === props.sessionId && routedSession() === '') currentSession = ''
2809
- /* The page stepped out of sight; the column, its tab and the page
2810
- stay exactly as the person left them. Nothing is closed here. */
2811
- openedByUs = false
2812
- return
2813
- }
2814
- el.executeJavaScript('(() => String(window.getSelection() || "").slice(0, 4000))()', true)
2815
- .then((text) => {
2816
- if (live !== true) return
2817
- const next = typeof text === 'string' ? text.trim() : ''
2818
- setPicked((was) => {
2819
- if (was !== next) probe({ phase: 'picked', len: next.length })
2820
- return next
2821
- })
2822
- })
2823
- .catch(() => {})
2824
- }, 900)
2825
- return () => {
2826
- live = false
2827
- clearInterval(tick)
2828
- }
2829
- }, [])
2830
- const tone = phase === 'error' ? 'error' : phase === 'ready' ? 'ready' : 'opening'
2831
- /* The line says what is happening now; an action the assistant is taking
2832
- is more current than the life of the page behind it. */
2833
- const said = (life, tool) => {
2834
- if (tool === '') return L(`phase.${life}`)
2835
- const label = L(`phase.act.${tool}`)
2836
- return label.startsWith('phase.act.') ? L('phase.act.eval') : label
2837
- }
2838
- /* An address is only an address once it has a scheme; a bare host is a host. */ // ui-copy-ignore
2839
- const open = () => {
2840
- const typed = draft.trim()
2841
- if (typed === '') return
2842
- let to = typed
2843
- if (/^[a-z][a-z0-9+.-]*:/i.test(typed) !== true) to = 'https://' + typed.replace(/^\/+/, '')
2844
- try {
2845
- new URL(to)
2846
- } catch {
2847
- return
2848
- }
2849
- steer.get(props.sessionId)?.(to)
2850
- }
2851
- return h('div', { className: 'dshib-body' }, [
2852
- h('div', { key: 'state', className: 'dshib-state', 'data-tone': tone }, [
2853
- h('span', { key: 'dot', className: 'dshib-dot' }),
2854
- h('span', { key: 'text' }, error !== '' ? error : said(phase, act)),
2855
- h('input', {
2856
- key: 'href',
2857
- className: 'dshib-href',
2858
- type: 'text', // ui-copy-ignore
2859
- value: draft, // ui-copy-ignore
2860
- title: href, // ui-copy-ignore
2861
- placeholder: L('pane.address'), // ui-copy-ignore
2862
- spellCheck: false, // ui-copy-ignore
2863
- autoComplete: 'off', // ui-copy-ignore
2864
- onChange: (event) => setDraft(event.target.value),
2865
- onKeyDown: (event) => { if (event.key === 'Enter') open() },
2866
- }),
2867
- picked === '' && offered === null ? null : h('span', {
2868
- key: 'drag',
2869
- className: 'dshib-drag',
2870
- draggable: true,
2871
- title: L('pane.dragHint'), // ui-copy-ignore
2872
- onDragStart: (event) => {
2873
- const data = event.dataTransfer
2874
- if (data === null) return
2875
- data.effectAllowed = 'copy'
2876
- if (picked !== '') data.setData('text/plain', picked)
2877
- if (offered === null) return
2878
- try {
2879
- const bin = atob(offered.b64)
2880
- const bytes = new Uint8Array(bin.length)
2881
- for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i)
2882
- const file = new File([bytes], offered.name, { type: mimeOf(offered.name) })
2883
- data.setData('text/plain', offered.name)
2884
- data.items.add(file)
2885
- } catch {
2886
- /* a jar that refuses files still carries the name as text */
2887
- }
2888
- },
2889
- }, L('pane.drag')),
2890
- ]),
2891
- h('div', { key: 'host', ref: host, className: 'dshib-viewport' }), // ui-copy-ignore
2892
- ])
2893
- }
2894
-
2895
- return {
2896
- inject: ['slots', 'locale', 'layout'],
2897
- /**
2898
- * Register the tab type, its body, and its title.
2899
- * @param {any} ctx - client plugin context.
2900
- */
2901
- apply(ctx) {
2902
- let registered = false
2903
- /* The version this copy was published as: a page that finds another one
2904
- published reloads until it has it, read from the disk. */
2905
- const REV = '0.11.33'
2906
- beat({ tabType: false, phase: 'loaded', v: REV })
2907
- probe = (frame) => beat({ tabType: registered, ...frame }) // ui-copy-ignore
2908
- setTimeout(() => beat({ tabType: registered, phase: registered ? 'ready' : 'idle' }), 3_000)
2909
- /* A page still running an older bundle replaces itself: the build it
2910
- switched to is remembered first, so a reload can never repeat. */
2911
- /* A copy served from the window's own cache keeps a page on old code
2912
- however often it reloads. Asking for the file itself from the disk
2913
- puts the new one where the next load will read it. */
2914
- const warmBundles = async () => {
2915
- try {
2916
- const names = performance
2917
- .getEntriesByType('resource')
2918
- .map((entry) => entry.name)
2919
- .filter((name) => name.includes('dsh-inner-browser-use') || name.endsWith('/client.js'))
2920
- const unique = Array.from(new Set(names))
2921
- for (const name of unique) {
2922
- try {
2923
- await fetch(name, { cache: 'reload' })
2924
- } catch {
2925
- /* one unreachable file does not stop the rest */
2926
- }
2927
- }
2928
- } catch {
2929
- /* no resource timing: the reload below is still worth a try */
2930
- }
2931
- }
2932
- const watchBuild = () => {
2933
- void fetch(`${ROUTE}/state`, { cache: 'no-store' })
2934
- .then((response) => (response?.ok === true ? response.json() : null))
2935
- .then(async (state) => {
2936
- const build = String(state?.clientBuild ?? '')
2937
- const version = String(state?.version ?? '')
2938
- if (build === '' || build === 'unknown') return
2939
- const held = String(sessionStorage.getItem('dshib.build') ?? '')
2940
- let marked = ''
2941
- try {
2942
- marked = String(new URL(location.href).searchParams.get('dshib') ?? '')
2943
- } catch {
2944
- marked = ''
2945
- }
2946
- /* A page built by the app carries no marker yet: it is the build
2947
- the app fetched a moment ago. */
2948
- if (held === '' && marked === '') {
2949
- sessionStorage.setItem('dshib.build', build)
2950
- return
2951
- }
2952
- /* Only the build the app is serving decides this. A different
2953
- plugin version with the same build means the app itself is
2954
- older than the files, which reloading this page cannot fix. */
2955
- const behind = held !== build && marked !== build
2956
- if (!behind) {
2957
- sessionStorage.removeItem('dshib.tries')
2958
- /* The page is current but the app is not: it keeps serving the
2959
- files it already loaded, so the page says so once. */
2960
- if (version !== '' && version !== REV && sessionStorage.getItem('dshib.old') !== '1') {
2961
- sessionStorage.setItem('dshib.old', '1')
2962
- beat({ phase: 'app-older', v: REV })
2963
- }
2964
- return
2965
- }
2966
- /* Reloading is worth a few tries; a window that cannot get the
2967
- file waits instead of reloading itself in a loop. The wait
2968
- lengthens and the count is bounded, so a fix applied later
2969
- still reaches a window that was already stuck. */
2970
- /* A call in flight owns the page; taking it out from under the
2971
- stream is what a person sees as a crash. It reloads next time. */
2972
- if (acting.size > 0) return
2973
- /* Reloading the shell throws away every page it holds, so it is
2974
- never done on this plugin's own initiative: `dshib:auto-reload`
2975
- set to 1 is the person asking for it. */
2976
- let wanted = false
2977
- try {
2978
- wanted = String(window.localStorage.getItem('dshib:auto-reload') ?? '') === '1'
2979
- } catch {
2980
- wanted = false
2981
- }
2982
- if (wanted !== true) {
2983
- beat({ tabType: true, phase: 'build-behind', session: '' })
2984
- return
2985
- }
2986
- const tries = Number(sessionStorage.getItem('dshib.tries') ?? '0')
2987
- const last = Number(sessionStorage.getItem('dshib.last') ?? '0')
2988
- if (!Number.isFinite(tries) || tries >= 12) return
2989
- if (tries >= 6 && Number.isFinite(last) && Date.now() - last < 600_000) return
2990
- sessionStorage.setItem('dshib.build', build)
2991
- sessionStorage.setItem('dshib.tries', String(tries + 1))
2992
- sessionStorage.setItem('dshib.last', String(Date.now()))
2993
- await warmBundles()
2994
- try {
2995
- /* The address carries this build so the document is fetched
2996
- rather than reused. 'dshib' is a name the app does not read,
2997
- so the shell keeps its own route; an earlier build left its
2998
- marker in the address and is dropped here. */
2999
- const next = new URL(location.href)
3000
- next.searchParams.delete('interface')
3001
- next.searchParams.set('dshib', build)
3002
- location.replace(next.href)
3003
- } catch {
3004
- location.reload()
3005
- }
3006
- })
3007
- .catch(() => {})
3008
- }
3009
- setTimeout(watchBuild, 2_000)
3010
- /* A window nobody is looking at still has to come back on the right
3011
- build: it is the one that gets looked at next. */
3012
- setInterval(watchBuild, 6_000)
3013
- if (typeof document.addEventListener === 'function') {
3014
- document.addEventListener('visibilitychange', () => { if (document.hidden !== true) watchBuild() })
3015
- }
3016
- ctx.effect(() => {
3017
- const tick = setInterval(() => {
3018
- const seen = routedSession()
3019
- if (seen !== '' && seen !== currentSession) {
3020
- currentSession = seen
3021
- beat({ tabType: registered, phase: 'route', session: seen })
3022
- }
3023
- beat({ tabType: registered, phase: 'alive', native: NATIVE_MODE, session: currentSession })
3024
- /* A tab the person reopened is wired again, so the next call finds
3025
- it without asking for anything. */
3026
- if (NATIVE_MODE === true && currentSession !== '') {
3027
- try {
3028
- if (nativeElement(currentSession) !== null) {
3029
- if (paneGuests.has(currentSession) !== true) wireNative(currentSession)
3030
- } else {
3031
- /* A reload leaves the tab on a checkpoint; the page comes back
3032
- by itself so the column is never left asking to be restored. */
3033
- restoreFace(currentSession)
3034
- }
3035
- } catch {
3036
- /* a page mid-teardown is wired on a later beat */
3037
- }
3038
- }
3039
- }, ALIVE_MS)
3040
- return () => clearInterval(tick)
3041
- }, 'dsh-inner-browser-use: alive')
3042
- try {
3043
- ctx.locale.register(NS, { zh: DICT.zh, 'zh-CN': DICT.zh, en: DICT.en })
3044
- } catch {
3045
- /* one locale service or none */
3046
- }
3047
- ctx.effect(() => {
3048
- const style = document.createElement('style')
3049
- style.id = STYLE_ID
3050
- style.dataset.pluginCss = ID
3051
- style.textContent = CSS
3052
- document.head.appendChild(style)
3053
- return () => style.remove()
3054
- }, 'dsh-inner-browser-use: styles')
3055
-
3056
- /* The strip is a service provided by the sidebar plugin; a page that was
3057
- not injected with it can only read it as undefined, and the ask is lost. */
3058
- ctx.inject(['layout', 'sidebarRight'], (scope) => scope.effect(() => {
3059
- beat({ tabType: registered, phase: 'wired' })
3060
- let source
3061
- /* Two chats asking at once would both press the column and both claim a
3062
- tab, and the second tab lands in the column the first one took. */
3063
- let opening = Promise.resolve()
3064
- /** @param {() => Promise<any>} work - one open. @returns {Promise<any>} its answer. */
3065
- function oneAtATime(work) {
3066
- const next = opening.then(work, work)
3067
- opening = next.then(() => {}, () => {})
3068
- return next
3069
- }
3070
- /* A tab asked for by a page that has since reloaded is not remembered. */
3071
- /* An update of this plugin rebuilds the page, which closes its tab.
3072
- Nothing is opened back: a tab the person did not ask for lands in
3073
- whichever chat is showing, so a tab exists only when a call needs
3074
- one and only for the chat that asked. */
3075
- /* The capture runs in another closure, so this lives at module scope. */
3076
- faceOf = () => {
3077
- try {
3078
- return scope.sidebarRight
3079
- } catch {
3080
- return undefined
3081
- }
3082
- }
3083
- /* A capture may be the first thing a chat asks for, so these exist from
3084
- mount; before, only an ask could create them and a shot had nothing
3085
- to call. */
3086
- column = (open) => {
3087
- try {
3088
- if (NATIVE_MODE === false) scope.layout.openRightbar(open, false) // ui-copy-ignore
3089
- } catch {
3090
- /* nothing to open without a right pane */
3091
- }
3092
- }
3093
- /* The column belongs to the person: a call reads whether one is up
3094
- and never presses anything to bring it on. */
3095
- showColumn = () => {
3096
- try {
3097
- const drawn = Array.from(document.querySelectorAll('[data-sidebar-right-session]'))
3098
- .some((node) => {
3099
- const box = typeof node.getBoundingClientRect === 'function' ? node.getBoundingClientRect() : null
3100
- return box !== null && box.width > 80 && box.height > 80
3101
- })
3102
- return drawn === true ? 'shown' : 'no-column'
3103
- } catch {
3104
- return 'no-column'
3105
- }
3106
- }
3107
- reveal = (asking) => {
3108
- /* A page is only brought forward for a chat that names itself: an ask
3109
- without one is the page on screen being guessed at. */
3110
- if (asking === '') return
3111
- /* Another chat's page is never brought forward. */
3112
- if (asking !== currentSession && currentSession !== '') return
3113
- if (routedSession() !== '' && routedSession() !== asking) return
3114
- showColumn?.()
3115
- try {
3116
- /* A kept-mounted tab stays hidden behind the pane's active tab;
3117
- its own chip brings it forward. Asking the product for a tab
3118
- here would put a second, empty one beside it. */
3119
- const ahead = showTab(asking)
3120
- if (ahead !== true) {
3121
- const last = String(rememberedPage(asking) ?? '')
3122
- if (last !== '') openNativeTab(asking, NATIVE_MODE === true ? NATIVE_KIND : KIND, last)
3123
- }
3124
- } catch {
3125
- /* the capture reports the geometry it saw */
3126
- }
3127
- }
3128
- raise = (asking) => {
3129
- if (asking !== '' && asking !== currentSession && currentSession !== '') return
3130
- if (asking !== '' && routedSession() !== '' && routedSession() !== asking) return
3131
- showColumn?.()
3132
- }
3133
- const onWanted = async (event) => {
3134
- let frame
3135
- try {
3136
- frame = JSON.parse(event.data)
3137
- } catch {
3138
- return
3139
- }
3140
- if (frame?.type !== 'wanted') return
3141
- const sessionId = String(frame.session ?? '')
3142
- if (sessionId === '') return
3143
- /* A page for a chat that is not on screen stays where it is; taking
3144
- the right panel for it would hide what the person is reading. */
3145
- beat({ phase: 'wanted', session: sessionId, panes: paneSessions.size, screen: onScreen.size })
3146
- /* The call waiting on this answer needs a page before the column is
3147
- asked for one, or it is answered "not open" while a page opens. */
3148
- if (NATIVE_MODE === true) {
3149
- /* The page that answers is kept before the column is asked for one:
3150
- a call waiting on this answer would otherwise be told there is
3151
- no page while the column is still opening. */
3152
- setTimeout(() => {
3153
- try {
3154
- wireNative(sessionId, String(frame.url ?? ''))
3155
- } catch {
3156
- /* the ask below opens a page instead */
3157
- }
3158
- }, 0)
3159
- }
3160
- /* The strip is the one this page load was given; kept for asks that
3161
- wait on a column, so a later page load cannot answer for them. */
3162
- const myStrip = faceOf?.()
3163
- /* The chat whose header is mounted is the chat on screen; the address
3164
- is the second word, for a shell that keeps the chat there. */
3165
- const seen = currentSession !== '' ? currentSession : routedSession()
3166
- /* The panel is taken only for the chat on screen, or for a chat that
3167
- already has a page here — opening it for anyone else would put an
3168
- empty column in front of what the person is reading. */
3169
- const hidden = paneSessions.has(sessionId) && onScreen.has(sessionId) !== true
3170
- const take = seen === '' || seen === sessionId || paneSessions.has(sessionId) === true
3171
- /* The panel shows the chat being read, so a call for another chat
3172
- gets a page out of sight instead: it answers nothing on screen and
3173
- takes nothing away. */
3174
- const stashed = () => residents.has(sessionId) === true
3175
- if (!take && NATIVE_MODE !== true && stashed() !== true) {
3176
- keepResident(sessionId)
3177
- for (let wait = 0; wait < 8 && stashed() !== true; wait += 1) {
3178
- await new Promise((resolve) => setTimeout(resolve, 50))
3179
- }
3180
- }
3181
- /* A chat that is not on screen is still asked for by its own id: the
3182
- tab lands in that chat's column, and nothing in front of the person
3183
- changes. */
3184
- if (!take && NATIVE_MODE !== true && stashed() !== true) {
3185
- beat({ tabType: registered, phase: 'open', step: 'other-chat', session: sessionId })
3186
- }
3187
- const bare = take && onScreen.size === 0
3188
- if (take) {
3189
- try {
3190
- /* A mounted page that lost its surface (a closed panel) is shown
3191
- again; the panel opens only when nothing is on screen. */
3192
- if (bare || hidden) if (NATIVE_MODE === false) scope.layout.openRightbar(true, false) // ui-copy-ignore
3193
- /* Opened on the chance this chat's page would appear in it. If the
3194
- page never gets a surface the panel is left as it was found. */
3195
- if (bare) {
3196
- setTimeout(() => {
3197
- /* A page that mounted is kept, visible or not: closing the panel
3198
- again would hide the very page the call is about. */
3199
- if (paneSessions.has(sessionId) || onScreen.has(sessionId) === true) return
3200
- try {
3201
- if (NATIVE_MODE === false) scope.layout.openRightbar(false, false) // ui-copy-ignore
3202
- beat({ tabType: registered, phase: 'open', step: 'selfheal' })
3203
- } catch {
3204
- /* nothing to close */
3205
- }
3206
- }, 3000).unref?.()
3207
- }
3208
- } catch (error) {
3209
- beat({ tabType: registered, phase: 'open', step: 'rightbar', error: String(error?.message ?? error) })
3210
- }
3211
- }
3212
- /* The current chat is the one place a page may open a tab without an id;
3213
- the id path is the fallback for a page showing another chat. */
3214
- let opened = ''
3215
- const strip = scope.sidebarRight
3216
- /* Every open page hears this broadcast, so a page must open the
3217
- asking chat's tab by id. Opening "the current chat" instead is how
3218
- one chat's call used to steer a browser tab in another chat. */
3219
- /* A chat's own pane already holds the page. Opening the tab again
3220
- rebuilds it, which closes the page a call is about to use. */
3221
- if (nativeElement(sessionId) !== null) {
3222
- /* The product's tab is already the page this call wants; the word
3223
- for that is `current`, which the call accepts. */
3224
- opened = 'current'
3225
- } else if (take || NATIVE_MODE === true) {
3226
- /* One tab, holding the page this call is for, so the column shows
3227
- it instead of an address bar waiting for input. A call made
3228
- while someone reads another chat asks by id, and the tab lands
3229
- in this chat's own column, out of sight. */
3230
- const kind = NATIVE_MODE === true ? NATIVE_KIND : KIND
3231
- const address = String(frame.url ?? '') || rememberedPage(sessionId)
3232
- const asked = openNativeTab(sessionId, kind, address, take, myStrip)
3233
- opened = asked === 'asked' ? 'session' : asked
3234
- if (asked === 'asked') {
3235
- let drawn = panelOf(sessionId).length > 0
3236
- for (let wait = 0; wait < 40 && drawn !== true; wait += 1) {
3237
- await new Promise((resolve) => setTimeout(resolve, 100))
3238
- drawn = panelOf(sessionId).length > 0
3239
- }
3240
- if (drawn !== true) opened = 'no-panel'
3241
- }
3242
- }
3243
- if (NATIVE_MODE === true && nativeElement(sessionId) === null) restoreFace(sessionId)
3244
- beat({ tabType: registered, phase: 'open', opened, session: sessionId, req: String(frame.req ?? '') })
3245
- /* The page lives in the product's tab, so the wire is opened for it
3246
- here: a call that arrives next finds a tab to answer it. */
3247
- /* The wire follows a page that is really there: a chat with no
3248
- panel of the product's gets a refusal, never a page of ours. */
3249
- if (take || (NATIVE_MODE === true && nativeElement(sessionId) !== null)) wireNative(sessionId)
3250
- /* An out-of-sight chat has no page to lend: the product's tab lives
3251
- in the column of the chat being read, so nothing is opened here. */
3252
- /* A page the wire already keeps is the one a call will answer from;
3253
- a second page for the same chat would only fight for the answer. */
3254
- keepResident(sessionId)
3255
- }
3256
- const open = () => {
3257
- source?.close()
3258
- source = new EventSource(`${ROUTE}/watch`) // ui-copy-ignore
3259
- source.addEventListener('message', onWanted)
3260
- }
3261
- reopenStream = open
3262
- open()
3263
- return () => {
3264
- reopenStream = null
3265
- source?.close()
3266
- }
3267
- }, 'dsh-inner-browser-use: tab on demand'))
3268
-
3269
- ctx.inject(['sidebarRightTabs', 'workspaces'], (scope) => {
3270
- const tabs = scope.sidebarRightTabs
3271
- const workspaces = scope.workspaces
3272
- workspacesNow = workspaces
3273
- keyForSession = (sessionId) => settledKey(sessionId)
3274
- /* The driven page is the product's own browser tab, so this plugin
3275
- registers no tab of its own: a second browser in the column would be
3276
- a look-alike, not the browser the product ships. */
3277
- registered = true
3278
- beat({ tabType: true, phase: 'registered', native: NATIVE_MODE })
3279
- /* A build that parked its own guest left a column that belongs to
3280
- nobody; it goes before anything else is measured. */
3281
- try {
3282
- document.getElementById('dshib-park')?.remove()
3283
- for (const node of Array.from(document.querySelectorAll('.dshib-stash'))) node.remove()
3284
- } catch {
3285
- /* a page that cannot be cleaned is left to the column's next draw */
3286
- }
3287
-
3288
- scope.slots.inject(SLOT, () => scope.slots.register({
3289
- name: SLOT, // ui-copy-ignore
3290
- key: ID, // ui-copy-ignore
3291
- locale: NS, // ui-copy-ignore
3292
- /* `key` is reserved by React and never reaches the body; the slot
3293
- passes the workspace under its own name. */
3294
- inject: (sessionId) => ({
3295
- sessionId,
3296
- workspace: () => settledKey(sessionId),
3297
- }),
3298
- }, Body))
3299
-
3300
- /* The chat on screen is the one whose header is mounted. Watching it
3301
- here costs no pixels and answers before any page of ours exists. */
3302
- scope.slots.inject(WATCH_SLOT, () => scope.slots.register({
3303
- name: WATCH_SLOT, // ui-copy-ignore
3304
- id: ID, // ui-copy-ignore
3305
- order: 90, // ui-copy-ignore
3306
- locale: NS, // ui-copy-ignore
3307
- }, (props) => {
3308
- const mark = React.useRef(null)
3309
- React.useEffect(() => {
3310
- const id = String(props?.sessionId ?? '')
3311
- const node = mark.current
3312
- /* A chat the person is not looking at may still be kept mounted, so
3313
- being in the page is not enough: the header has to be on screen. */
3314
- /* The cell itself has no size worth measuring, so the nearest
3315
- ancestor that does is what the eye is told to watch. */
3316
- let row = node?.parentElement ?? null
3317
- try {
3318
- while (row !== null && row.getBoundingClientRect().width < 2) row = row.parentElement
3319
- } catch {
3320
- row = null
3321
- }
3322
- if (id === '' || row === null || typeof IntersectionObserver !== 'function') return undefined
3323
- const see = (on) => {
3324
- if (on) {
3325
- currentSession = id
3326
- beat({ tabType: registered, phase: 'seen', session: id })
3327
- return
3328
- }
3329
- if (currentSession === id) currentSession = ''
3330
- beat({ tabType: registered, phase: 'unseen', session: id })
3331
- }
3332
- const watch = new IntersectionObserver((entries) => {
3333
- const last = entries[entries.length - 1]
3334
- if (last !== undefined) see(last.isIntersecting === true)
3335
- })
3336
- watch.observe(row)
3337
- return () => {
3338
- watch.disconnect()
3339
- if (currentSession === id) currentSession = ''
3340
- }
3341
- }, [props?.sessionId])
3342
- return React.createElement('span', { ref: mark, style: { display: 'contents' } })
3343
- }))
3344
-
3345
- scope.slots.inject(TITLE_SLOT, () => scope.slots.register({
3346
- name: TITLE_SLOT, // ui-copy-ignore
3347
- key: ID, // ui-copy-ignore
3348
- locale: NS, // ui-copy-ignore
3349
- }, () => h('span', { className: 'dshib-title' }, L('type.label'))))
3350
- })
3351
- },
3352
- }
3353
- },
3354
- })
551
+ },
552
+ })
3355
553
 
3356
554
  /* census probe */
3357
-