dsh-inner-browser-use 0.11.44 → 0.13.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/README.md +108 -75
- package/README.zh.md +88 -45
- package/client.js +473 -3276
- package/docs/ARCHITECTURE.md +183 -151
- package/docs/BENCH.md +124 -0
- package/docs/COMPATIBILITY.md +72 -102
- package/docs/ROADMAP.md +108 -0
- package/docs/VERIFY.md +80 -417
- package/index.js +227 -1117
- package/locale/en.json +11 -22
- package/locale/zh.json +11 -22
- package/package.json +9 -5
- package/scripts/doctor.mjs +89 -0
- package/sidecar.js +1323 -0
- package/docs/upstream/cookie-persistence.md +0 -52
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
'
|
|
200
|
-
'
|
|
201
|
-
'
|
|
202
|
-
'
|
|
203
|
-
'
|
|
204
|
-
'
|
|
205
|
-
'
|
|
206
|
-
'
|
|
207
|
-
'
|
|
208
|
-
'
|
|
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
|
-
'
|
|
222
|
-
'
|
|
223
|
-
'
|
|
224
|
-
'
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
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
|
-
|
|
248
|
-
'.dshib-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
'.dshib-
|
|
252
|
-
'.dshib-
|
|
253
|
-
'.dshib-
|
|
254
|
-
'.dshib-
|
|
255
|
-
'.dshib-
|
|
256
|
-
'.dshib-
|
|
257
|
-
'
|
|
258
|
-
|
|
259
|
-
'.dshib-
|
|
260
|
-
'.dshib-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
106
|
+
return String(surface?.mounted?.getSnapshot?.() ?? '')
|
|
521
107
|
} catch {
|
|
522
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
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 (
|
|
672
|
-
|
|
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
|
|
138
|
+
/* a column that refuses keeps its own tabs */
|
|
679
139
|
}
|
|
680
140
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
void take(href, name)
|
|
175
|
+
askSource = null
|
|
176
|
+
askWho = ''
|
|
689
177
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
178
|
+
const syncAsk = () => {
|
|
179
|
+
const who = mountedOf()
|
|
180
|
+
if (who === askWho) return
|
|
181
|
+
closeAsk()
|
|
182
|
+
if (who === '') return
|
|
693
183
|
try {
|
|
694
|
-
|
|
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
|
-
/*
|
|
199
|
+
/* a page with no stream keeps its tab closed */
|
|
697
200
|
}
|
|
698
|
-
return url
|
|
699
201
|
}
|
|
700
|
-
|
|
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
|
|
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
|
|
209
|
+
/* a store that cannot be watched is polled instead */
|
|
718
210
|
}
|
|
719
|
-
return
|
|
211
|
+
return null
|
|
720
212
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
*
|
|
243
|
+
* The sidecar's screen in the column.
|
|
857
244
|
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
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
|
-
|
|
1051
|
-
if (hit !== null) return hit
|
|
330
|
+
message = JSON.parse(event.data)
|
|
1052
331
|
} catch {
|
|
1053
|
-
|
|
332
|
+
return
|
|
1054
333
|
}
|
|
1055
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
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
|
-
|