dsh-chat-embed 0.0.0-stage → 0.5.4
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/LICENSE +21 -0
- package/README.md +347 -3
- package/client.js +3683 -0
- package/cordis.patch.yml +11 -0
- package/docs/architecture.md +376 -0
- package/docs/publishing.md +78 -0
- package/icon.svg +14 -0
- package/index.js +25 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/restore-shell-patch.cmd +39 -0
- package/tools/shell-patch/clean-cache.ps1 +76 -0
- package/tools/shell-patch/patch-asar.cjs +272 -0
- package/tools/shell-patch/watchdog.ps1 +124 -0
- package//346/270/205/347/220/206/350/201/212/345/244/251/347/274/223/345/255/230.cmd +12 -0
package/client.js
ADDED
|
@@ -0,0 +1,3683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chat-embed — Client half.
|
|
3
|
+
*
|
|
4
|
+
* A left-navigation entry ("聊天") that opens a **multi-tab AI chat surface**
|
|
5
|
+
* in the central panel of the DeepSeek Harness desktop app: one tab per web
|
|
6
|
+
* chat service (DeepSeek, 豆包, ChatGPT, MiniMax, Kimi, Gemini and Grok by default), each in its own desktop webview and its own storage partition.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a desktop webview instead of an iframe
|
|
9
|
+
*
|
|
10
|
+
* Every service here refuses framing (`frame-ancestors 'none'` or X-Frame
|
|
11
|
+
* Options), so no document may embed it — not the Web GUI, not a browser
|
|
12
|
+
* profile, not this panel. An Electron `<webview>` is a separate top-level
|
|
13
|
+
* WebContents and ignores those directives, which is why the embedded surface
|
|
14
|
+
* only exists in the desktop app.
|
|
15
|
+
*
|
|
16
|
+
* ## Why the lease handshake
|
|
17
|
+
*
|
|
18
|
+
* The desktop shell refuses every `<webview>` it did not hand a lease to
|
|
19
|
+
* (`will-attach-webview`): the element's `src` must be exactly
|
|
20
|
+
* `about:blank#<lease>` and its `partition` must be the one issued with that
|
|
21
|
+
* lease. `window.dshDesktop.browser.acquire(workspace)` is the only source of
|
|
22
|
+
* leases, and client plugin bundles are injected as same-origin classic
|
|
23
|
+
* scripts, so this file runs in the same main world as that bridge.
|
|
24
|
+
*
|
|
25
|
+
* The workspace string is a storage identity, not a secret: the shell caches
|
|
26
|
+
* one partition per identity for the process lifetime. Giving every provider
|
|
27
|
+
* its own identity keeps their logins isolated, and keeps each service signed
|
|
28
|
+
* in for as long as the app runs.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the guest lives in the seat's own layout box
|
|
31
|
+
*
|
|
32
|
+
* Electron destroys a webview's WebContents as soon as the element leaves the
|
|
33
|
+
* DOM, and re-parenting does not help either: a move is a detach plus an
|
|
34
|
+
* attach. So the surface is rendered *inside* the panel or tab body showing
|
|
35
|
+
* it (`.dce-panel.dce-inline`, `width/height:100%`) and the browser owns the
|
|
36
|
+
* geometry - no measurement, no per-frame JS.
|
|
37
|
+
*
|
|
38
|
+
* Two seats can show it: the central panel (`main`) and the right sidebar tab.
|
|
39
|
+
* They are mutually exclusive because the right sidebar belongs to a Session,
|
|
40
|
+
* so whichever seat the user opens claims the guests. Switching seats rebuilds
|
|
41
|
+
* the guest once; the last conversation URL is restored, so no re-login.
|
|
42
|
+
*
|
|
43
|
+
* ## Per-tab lifecycle
|
|
44
|
+
*
|
|
45
|
+
* idle → acquiring → navigating → ready
|
|
46
|
+
* ↘ ↘
|
|
47
|
+
* failed ← (retry acquires a *fresh* lease; a consumed
|
|
48
|
+
* lease can never be reused)
|
|
49
|
+
*
|
|
50
|
+
* A tab's guest is created the first time that tab is activated and then kept
|
|
51
|
+
* for the app run. Activating a tab never touches another tab's guest.
|
|
52
|
+
*
|
|
53
|
+
* ## Adding a service
|
|
54
|
+
*
|
|
55
|
+
* Append one entry to `PROVIDERS`: `id`, `label`, `url`, `origin`, and a unique
|
|
56
|
+
* `workspace` (storage identity). Nothing else is provider-specific.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
window.__ModuleLoader__.load({
|
|
60
|
+
id: 'dsh-chat-embed',
|
|
61
|
+
factory(require) {
|
|
62
|
+
'use strict'
|
|
63
|
+
|
|
64
|
+
// The browser module table publishes React; installing a second copy here
|
|
65
|
+
// would break hooks, so the bundle requires the shared instance.
|
|
66
|
+
const React = require('react')
|
|
67
|
+
const h = React.createElement
|
|
68
|
+
|
|
69
|
+
// ------------------------------------------------------------------
|
|
70
|
+
// Identity
|
|
71
|
+
// ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Locale namespace this plugin's UI strings register under. */
|
|
74
|
+
const NS = 'dshChatEmbed'
|
|
75
|
+
|
|
76
|
+
/** Sidebar entry id; the sidebar dispatches the `main` key of the same name. */
|
|
77
|
+
const PANEL_ID = 'chat-embed'
|
|
78
|
+
|
|
79
|
+
/** Right sidebar: one tab type, its body seat, its chip title seat. */
|
|
80
|
+
const RIGHT_TAB_SLOT = 'sidebar.right.pane.tab'
|
|
81
|
+
const RIGHT_TAB_TITLE_SLOT = 'sidebar.right.pane.tab.title'
|
|
82
|
+
const RIGHT_TAB_ID = 'dsh-chat-embed'
|
|
83
|
+
const RIGHT_TAB_KIND = 'chat-embed'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Right-aligned Session utilities (a list slot). The far-right corner is a
|
|
87
|
+
* single slot already owned by another plugin, and shadowing it would hide
|
|
88
|
+
* that plugin's control, so this entry joins the utilities instead.
|
|
89
|
+
*/
|
|
90
|
+
const DOCK_SLOT = 'conversation.session.header.utilities'
|
|
91
|
+
const DOCK_ID = 'chat-embed-dock'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Right-sidebar wiring state. The right column is optional and may reject a
|
|
95
|
+
* foreign slot or load after this plugin, so every step is attempted
|
|
96
|
+
* independently and reported in the diagnostics card instead of throwing.
|
|
97
|
+
*/
|
|
98
|
+
let rightSeatAttempts = []
|
|
99
|
+
let rightSeatsInstalled = false
|
|
100
|
+
let rightTypeState = { state: 'idle', detail: '' }
|
|
101
|
+
let rightTabTypeDispose
|
|
102
|
+
let rightDisposers = []
|
|
103
|
+
let openRightTabImpl = () => {}
|
|
104
|
+
|
|
105
|
+
/** Build identity, surfaced in the panel so a packaged app can be diagnosed. */
|
|
106
|
+
const BUILD = '0.5.4'
|
|
107
|
+
|
|
108
|
+
/** Where a re-applied instance finds the one it must take over from. */
|
|
109
|
+
const INSTANCE_KEY = '__DSH_CHAT_EMBED_INSTANCE__'
|
|
110
|
+
|
|
111
|
+
/** Where the last session's active tab is remembered. */
|
|
112
|
+
const ACTIVE_TAB_KEY = 'dsh-chat-embed:active-tab'
|
|
113
|
+
|
|
114
|
+
/** Where the user's own tab order is remembered. */
|
|
115
|
+
const TAB_ORDER_KEY = 'dsh-chat-embed:tab-order'
|
|
116
|
+
|
|
117
|
+
/** Where the set of enabled providers is remembered. */
|
|
118
|
+
const ENABLED_KEY = 'dsh-chat-embed:enabled'
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The services this surface can host. Order is the tab order.
|
|
122
|
+
*
|
|
123
|
+
* `workspace` is the shell's storage identity and the partition key: give
|
|
124
|
+
* every provider its own, otherwise two providers share one cookie jar.
|
|
125
|
+
* Keep the value stable — changing it signs the user out.
|
|
126
|
+
*/
|
|
127
|
+
const PROVIDERS = Object.freeze([
|
|
128
|
+
{
|
|
129
|
+
id: 'deepseek',
|
|
130
|
+
label: 'DeepSeek',
|
|
131
|
+
url: 'https://chat.deepseek.com/',
|
|
132
|
+
origin: 'https://chat.deepseek.com',
|
|
133
|
+
workspace: 'dsh-chat-embed:chat:v1',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'doubao',
|
|
137
|
+
label: '豆包',
|
|
138
|
+
url: 'https://www.doubao.com/chat/',
|
|
139
|
+
origin: 'https://www.doubao.com',
|
|
140
|
+
workspace: 'dsh-chat-embed:doubao:v1',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'chatgpt',
|
|
144
|
+
label: 'ChatGPT',
|
|
145
|
+
url: 'https://chatgpt.com/',
|
|
146
|
+
origin: 'https://chatgpt.com',
|
|
147
|
+
workspace: 'dsh-chat-embed:chatgpt:v1',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'minimax',
|
|
151
|
+
label: 'MiniMax',
|
|
152
|
+
url: 'https://chat.minimaxi.com/',
|
|
153
|
+
origin: 'https://chat.minimaxi.com',
|
|
154
|
+
workspace: 'dsh-chat-embed:minimax:v1',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'kimi',
|
|
158
|
+
label: 'Kimi',
|
|
159
|
+
url: 'https://www.kimi.com/',
|
|
160
|
+
origin: 'https://www.kimi.com',
|
|
161
|
+
workspace: 'dsh-chat-embed:kimi:v1',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'gemini',
|
|
165
|
+
label: 'Gemini',
|
|
166
|
+
url: 'https://gemini.google.com/',
|
|
167
|
+
origin: 'https://gemini.google.com',
|
|
168
|
+
workspace: 'dsh-chat-embed:gemini:v1',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'grok',
|
|
172
|
+
label: 'Grok',
|
|
173
|
+
url: 'https://grok.com/',
|
|
174
|
+
origin: 'https://grok.com',
|
|
175
|
+
workspace: 'dsh-chat-embed:grok:v1',
|
|
176
|
+
},
|
|
177
|
+
].map(Object.freeze))
|
|
178
|
+
|
|
179
|
+
const DEFAULT_PROVIDER = 'deepseek'
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Identity providers that refuse to complete OAuth inside an embedded
|
|
183
|
+
* browser. Relaying their popups would only navigate the guest into a blank
|
|
184
|
+
* page, so they are reported instead of followed.
|
|
185
|
+
*/
|
|
186
|
+
const OAUTH_HOSTS = Object.freeze([
|
|
187
|
+
'accounts.google.com',
|
|
188
|
+
'accounts.youtube.com',
|
|
189
|
+
'appleid.apple.com',
|
|
190
|
+
'login.microsoftonline.com',
|
|
191
|
+
'login.live.com',
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------
|
|
195
|
+
// Timing
|
|
196
|
+
// ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** The first `dom-ready` can beat the shell's lease registration. */
|
|
199
|
+
const NAVIGATE_RETRY_MS = 350
|
|
200
|
+
const NAVIGATE_MAX_ATTEMPTS = 5
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How long to wait after an OAuth roundtrip lands back on the site before
|
|
204
|
+
* checking whether the callback page actually rendered. Overridable in the
|
|
205
|
+
* test hook so the suite does not sleep for seconds.
|
|
206
|
+
*/
|
|
207
|
+
const TIMINGS = {
|
|
208
|
+
oauthLandingDelayMs: 1800,
|
|
209
|
+
oauthBlankWatchMs: 2200,
|
|
210
|
+
oauthHandoffProbeMs: 1200,
|
|
211
|
+
oauthRecoveryReloadMs: 700,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const PHASE = {
|
|
215
|
+
IDLE: 'idle',
|
|
216
|
+
ACQUIRING: 'acquiring',
|
|
217
|
+
NAVIGATING: 'navigating',
|
|
218
|
+
READY: 'ready',
|
|
219
|
+
FAILED: 'failed',
|
|
220
|
+
UNSUPPORTED: 'unsupported',
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------
|
|
224
|
+
// Locale
|
|
225
|
+
// ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
const DICTS = {
|
|
228
|
+
en: {
|
|
229
|
+
navLabel: 'Chat',
|
|
230
|
+
statusIdle: 'Idle',
|
|
231
|
+
statusAcquiring: 'Requesting an embedded view…',
|
|
232
|
+
statusNavigating: 'Opening…',
|
|
233
|
+
statusReady: 'Connected',
|
|
234
|
+
statusFailedShort: 'Unavailable',
|
|
235
|
+
statusUnsupportedShort: 'Desktop app required',
|
|
236
|
+
statusFailed: 'This tab could not start',
|
|
237
|
+
statusUnsupported: 'Embedded chat needs the DSH desktop app',
|
|
238
|
+
failedHint:
|
|
239
|
+
'The desktop shell did not hand back a usable webview lease for this tab. Retrying asks for a fresh one.',
|
|
240
|
+
unsupportedHint:
|
|
241
|
+
'These chat services refuse to be framed, so the Web GUI can only link out. Open this panel in the DSH desktop app to chat inside the app.',
|
|
242
|
+
retry: 'Retry',
|
|
243
|
+
reload: 'Reload',
|
|
244
|
+
openInBrowser: 'Open in browser',
|
|
245
|
+
partitionLabel: 'Partition',
|
|
246
|
+
tabHint: 'Open {label}',
|
|
247
|
+
dismiss: 'Dismiss',
|
|
248
|
+
dragHint: 'Drag to reorder',
|
|
249
|
+
settings: 'Settings',
|
|
250
|
+
menuTitle: 'Chat tabs',
|
|
251
|
+
lastTabHint: 'At least one tab must stay enabled',
|
|
252
|
+
logging: 'Debug log',
|
|
253
|
+
loggingHint: 'Print plugin events to the console (off by default)',
|
|
254
|
+
loggingOn: 'on',
|
|
255
|
+
loggingOff: 'off',
|
|
256
|
+
loggingCopy: 'Copy log',
|
|
257
|
+
persistLabel: 'Login across restarts',
|
|
258
|
+
persistOn: 'on (persist: partition)',
|
|
259
|
+
persistOff: 'off (memory partition)',
|
|
260
|
+
persistUnknown: 'not detected yet',
|
|
261
|
+
persistHint: 'Needs the shell patch; a plugin cannot change how the shell names partitions.',
|
|
262
|
+
persistCopy: 'Copy the patch instructions',
|
|
263
|
+
copyDiag: 'Copy diagnostics',
|
|
264
|
+
copied: 'Copied',
|
|
265
|
+
copyFailed: 'Copy failed',
|
|
266
|
+
dockTitle: 'Open chat in the right sidebar',
|
|
267
|
+
rightTabTitle: 'Chat',
|
|
268
|
+
goHome: 'Go to start page',
|
|
269
|
+
diagTitle: 'Tab diagnostics',
|
|
270
|
+
diagUrl: 'URL',
|
|
271
|
+
diagPhase: 'State',
|
|
272
|
+
diagEvent: 'Last event',
|
|
273
|
+
diagHint: 'Send these lines when reporting a broken page.',
|
|
274
|
+
diagHandoff: 'Handoff probe',
|
|
275
|
+
diagRight: 'Right column',
|
|
276
|
+
diagSeat: 'Seat',
|
|
277
|
+
selfTest: 'Self-test',
|
|
278
|
+
rect: 'left {left} top {top} {width}×{height}',
|
|
279
|
+
seatMain: 'center panel',
|
|
280
|
+
seatRight: 'right column',
|
|
281
|
+
seatNone: 'none',
|
|
282
|
+
seatMissing: '{seat} (no host)',
|
|
283
|
+
seatDescribe: '{seat} host={host} (css-owned)',
|
|
284
|
+
rightRegistered: 'registered',
|
|
285
|
+
rightPending: 'pending registration',
|
|
286
|
+
rightFailed: 'registration failed',
|
|
287
|
+
rightUnavailable: 'unavailable',
|
|
288
|
+
rightSeatBody: 'body',
|
|
289
|
+
rightSeatTitle: 'title',
|
|
290
|
+
rightSeatDock: 'dock',
|
|
291
|
+
rightDetail: ' · {detail}',
|
|
292
|
+
seatListSep: ', ',
|
|
293
|
+
selfTestInline: 'inline seat={seat} host={host} width=100% (layout owns the box; no measurement, no polling)',
|
|
294
|
+
selfTestNoSeat: 'no inline seat',
|
|
295
|
+
selfTestLostHost: 'the inline seat lost its host',
|
|
296
|
+
eventStopLoading: 'page stopped loading',
|
|
297
|
+
eventOauthDeadEnd: 'sign-in page dead end',
|
|
298
|
+
eventOauthProbeFailed: 'sign-in dead end (probe failed)',
|
|
299
|
+
eventRendererGone: 'renderer gone',
|
|
300
|
+
oauthBlockedTitle: 'Google sign-in cannot finish here',
|
|
301
|
+
oauthBlockedBody:
|
|
302
|
+
'A normal browser tab works because the site opens a real popup and Google posts the credential back to it through window.opener. A panel cannot open that popup, so the flow stops on Google’s gsi/transform page. Use the site’s own email sign-in instead.',
|
|
303
|
+
},
|
|
304
|
+
zh: {
|
|
305
|
+
navLabel: '聊天',
|
|
306
|
+
statusIdle: '空闲',
|
|
307
|
+
statusAcquiring: '正在申请内嵌视图…',
|
|
308
|
+
statusNavigating: '正在打开…',
|
|
309
|
+
statusReady: '已连接',
|
|
310
|
+
statusFailedShort: '不可用',
|
|
311
|
+
statusUnsupportedShort: '需要桌面版',
|
|
312
|
+
statusFailed: '该标签页启动失败',
|
|
313
|
+
statusUnsupported: '内嵌聊天需要 DSH 桌面版',
|
|
314
|
+
failedHint: '桌面壳没有为这个标签页返回可用的 webview 租约。点击重试会重新申请一张新租约。',
|
|
315
|
+
unsupportedHint:
|
|
316
|
+
'这些 AI 站点都禁止被 frame 嵌入,Web 端只能外开。请在 DSH 桌面版中打开本面板,直接在内嵌页里聊天。',
|
|
317
|
+
retry: '重试',
|
|
318
|
+
reload: '重新加载',
|
|
319
|
+
openInBrowser: '在浏览器打开',
|
|
320
|
+
partitionLabel: '分区',
|
|
321
|
+
tabHint: '打开 {label}',
|
|
322
|
+
dismiss: '知道了',
|
|
323
|
+
dragHint: '拖动可排序',
|
|
324
|
+
settings: '设置',
|
|
325
|
+
menuTitle: '对话窗口',
|
|
326
|
+
lastTabHint: '至少保留一个对话窗口',
|
|
327
|
+
logging: '调试日志',
|
|
328
|
+
loggingHint: '在控制台输出插件事件(默认关闭)',
|
|
329
|
+
loggingOn: '开',
|
|
330
|
+
loggingOff: '关',
|
|
331
|
+
loggingCopy: '复制日志',
|
|
332
|
+
persistLabel: '跨重启保持登录',
|
|
333
|
+
persistOn: '已开启(persist: 分区)',
|
|
334
|
+
persistOff: '未开启(内存分区)',
|
|
335
|
+
persistUnknown: '尚未检测',
|
|
336
|
+
persistHint: '需要桌面壳补丁;插件无法改变壳的分区命名。',
|
|
337
|
+
persistCopy: '复制补丁说明',
|
|
338
|
+
copyDiag: '复制诊断信息',
|
|
339
|
+
copied: '已复制',
|
|
340
|
+
copyFailed: '复制失败',
|
|
341
|
+
dockTitle: '在右侧栏打开聊天',
|
|
342
|
+
rightTabTitle: '聊天',
|
|
343
|
+
goHome: '回到首页',
|
|
344
|
+
diagTitle: '标签页诊断',
|
|
345
|
+
diagUrl: '地址',
|
|
346
|
+
diagPhase: '状态',
|
|
347
|
+
diagEvent: '最近事件',
|
|
348
|
+
diagHint: '反馈问题时把这几行发出来即可。',
|
|
349
|
+
diagHandoff: '凭据探测',
|
|
350
|
+
diagRight: '右栏',
|
|
351
|
+
diagSeat: '占位',
|
|
352
|
+
selfTest: '自检',
|
|
353
|
+
rect: '左{left} 上{top} {width}×{height}',
|
|
354
|
+
seatMain: '中央面板',
|
|
355
|
+
seatRight: '右栏',
|
|
356
|
+
seatNone: '无',
|
|
357
|
+
seatMissing: '{seat}(无宿主)',
|
|
358
|
+
seatDescribe: '{seat} 宿主={host}(CSS 决定)',
|
|
359
|
+
rightRegistered: '已注册',
|
|
360
|
+
rightPending: '等待注册',
|
|
361
|
+
rightFailed: '注册失败',
|
|
362
|
+
rightUnavailable: '不可用',
|
|
363
|
+
rightSeatBody: '主体',
|
|
364
|
+
rightSeatTitle: '标题',
|
|
365
|
+
rightSeatDock: '按钮',
|
|
366
|
+
rightDetail: '({detail})',
|
|
367
|
+
seatListSep: ',',
|
|
368
|
+
selfTestInline: '内联席位={seat} 宿主={host} 宽度=100%(布局决定,无测量、无轮询)',
|
|
369
|
+
selfTestNoSeat: '没有内联席位',
|
|
370
|
+
selfTestLostHost: '内联席位丢失宿主',
|
|
371
|
+
eventStopLoading: '页面已停止加载',
|
|
372
|
+
eventOauthDeadEnd: '登录页陷入死路',
|
|
373
|
+
eventOauthProbeFailed: '登录页死路(探测失败)',
|
|
374
|
+
eventRendererGone: '渲染进程退出',
|
|
375
|
+
oauthBlockedTitle: 'Google 登录在内嵌面板里无法完成',
|
|
376
|
+
oauthBlockedBody:
|
|
377
|
+
'普通浏览器标签页能成功,是因为站点开了一个真正的弹窗,Google 通过 window.opener 把凭据交回给它;内嵌面板开不出这个弹窗,流程就停在 Google 的 gsi/transform 页。请改用站点自己的「使用电子邮箱继续」。',
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Locale keys for diagnostic values that are not free text. */
|
|
382
|
+
const SEAT_KEY = { main: 'seatMain', right: 'seatRight', none: 'seatNone' }
|
|
383
|
+
const RIGHT_STATE_KEY = {
|
|
384
|
+
registered: 'rightRegistered',
|
|
385
|
+
pending: 'rightPending',
|
|
386
|
+
failed: 'rightFailed',
|
|
387
|
+
unavailable: 'rightUnavailable',
|
|
388
|
+
}
|
|
389
|
+
const RIGHT_SEAT_KEY = { body: 'rightSeatBody', title: 'rightSeatTitle', dock: 'rightSeatDock' }
|
|
390
|
+
const EVENT_KEY = {
|
|
391
|
+
'did-stop-loading': 'eventStopLoading',
|
|
392
|
+
'oauth-dead-end': 'eventOauthDeadEnd',
|
|
393
|
+
'oauth-dead-end (probe failed)': 'eventOauthProbeFailed',
|
|
394
|
+
'renderer-gone': 'eventRendererGone',
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {Record<string, string>} map @param {string} value @returns {string} */
|
|
398
|
+
function valueText(t, map, value) {
|
|
399
|
+
const key = map[value]
|
|
400
|
+
return key !== undefined ? t(key) : value
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Fill {name} placeholders in a translated template. */
|
|
404
|
+
function fill(template, params) {
|
|
405
|
+
if (params === undefined) return template
|
|
406
|
+
return String(template).replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Phase → locale key for the status text. */
|
|
410
|
+
const STATUS_KEY = {
|
|
411
|
+
[PHASE.IDLE]: 'statusIdle',
|
|
412
|
+
[PHASE.ACQUIRING]: 'statusAcquiring',
|
|
413
|
+
[PHASE.NAVIGATING]: 'statusNavigating',
|
|
414
|
+
[PHASE.READY]: 'statusReady',
|
|
415
|
+
[PHASE.FAILED]: 'statusFailedShort',
|
|
416
|
+
[PHASE.UNSUPPORTED]: 'statusUnsupportedShort',
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @param {(key: string) => string} t @param {string} phase @returns {string} */
|
|
420
|
+
function phaseText(t, phase) {
|
|
421
|
+
const key = STATUS_KEY[phase]
|
|
422
|
+
return key !== undefined ? t(key) : phase
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Debug-only translator: probe readouts stay Chinese. */
|
|
426
|
+
const probeT = (key, params) => fill(DICTS.zh[key] !== undefined ? DICTS.zh[key] : key, params)
|
|
427
|
+
|
|
428
|
+
// ------------------------------------------------------------------
|
|
429
|
+
// Pure helpers
|
|
430
|
+
// ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Drop the desktop shell's own User-Agent tokens.
|
|
434
|
+
*
|
|
435
|
+
* chat.deepseek.com and friends overlay a "使用环境异常" risk-control dialog
|
|
436
|
+
* when they recognise an Electron UA, so every guest presents as plain
|
|
437
|
+
* Chrome. The raw UA is read at mount time, which keeps this correct across
|
|
438
|
+
* shell upgrades without pinning a Chrome version here.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} raw
|
|
441
|
+
* @returns {string}
|
|
442
|
+
*/
|
|
443
|
+
function normalizedUserAgent(raw) {
|
|
444
|
+
const source = typeof raw === 'string' ? raw : ''
|
|
445
|
+
const kept = source
|
|
446
|
+
.split(/\s+/)
|
|
447
|
+
.filter(Boolean)
|
|
448
|
+
.filter((token) => !/^(electron|dsh-desktop|deepseek-harness)\//i.test(token))
|
|
449
|
+
return kept.join(' ')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A plain-Chrome User-Agent used when the renderer exposes none. */
|
|
453
|
+
const FALLBACK_USER_AGENT =
|
|
454
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36'
|
|
455
|
+
|
|
456
|
+
/** @returns {string} the guest User-Agent for this renderer. */
|
|
457
|
+
function currentUserAgent() {
|
|
458
|
+
const raw = typeof navigator === 'undefined' || navigator === null ? '' : navigator.userAgent
|
|
459
|
+
const normalized = normalizedUserAgent(raw)
|
|
460
|
+
return normalized === '' ? FALLBACK_USER_AGENT : normalized
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The desktop shell's leased-browser bridge, when this page runs inside it.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} [scope] override for tests.
|
|
467
|
+
* @returns {{ acquire: Function, release: Function } | undefined}
|
|
468
|
+
*/
|
|
469
|
+
function desktopBrowser(scope) {
|
|
470
|
+
const shell = scope === undefined ? window : scope
|
|
471
|
+
const bridge = shell === undefined || shell === null ? undefined : shell.dshDesktop
|
|
472
|
+
const browser = bridge === undefined || bridge === null ? undefined : bridge.browser
|
|
473
|
+
if (browser === undefined || browser === null) return undefined
|
|
474
|
+
if (typeof browser.acquire !== 'function' || typeof browser.release !== 'function') return undefined
|
|
475
|
+
return browser
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** @returns {boolean} whether this page can host a leased webview. */
|
|
479
|
+
function isEmbedSupported() {
|
|
480
|
+
return desktopBrowser() !== undefined
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** @returns {Promise<void>} */
|
|
484
|
+
function delay(ms) {
|
|
485
|
+
return new Promise((resolve) => {
|
|
486
|
+
setTimeout(resolve, ms)
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Best-effort human text for an unknown thrown value. */
|
|
491
|
+
function messageOf(error) {
|
|
492
|
+
if (error === undefined || error === null) return ''
|
|
493
|
+
if (typeof error === 'string') return error
|
|
494
|
+
const message = error.message
|
|
495
|
+
return typeof message === 'string' ? message : String(error)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Last four characters of an opaque id, for the diagnostic readout. */
|
|
499
|
+
function shortId(value) {
|
|
500
|
+
if (typeof value !== 'string' || value === '') return '—'
|
|
501
|
+
return value.length <= 4 ? value : value.slice(-4)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The plain-text diagnostics report behind the copy button. Kept pure so it
|
|
506
|
+
* can be checked without a renderer or a clipboard.
|
|
507
|
+
*
|
|
508
|
+
* @param {{ labels: Record<string, string>, url: string, frame: any, phase: string, detail: string, partition: string, mounts: number, lastEvent: string, handoff: string, build: string }} input
|
|
509
|
+
* @returns {string}
|
|
510
|
+
*/
|
|
511
|
+
function formatDiagReport(input) {
|
|
512
|
+
const labels = input.labels
|
|
513
|
+
const lines = [labels.diagTitle]
|
|
514
|
+
lines.push(labels.diagUrl + ': ' + (input.url === '' ? '—' : input.url))
|
|
515
|
+
lines.push(labels.diagPhase + ': ' + input.phase + (input.detail === '' ? '' : ' · ' + input.detail))
|
|
516
|
+
lines.push(labels.partitionLabel + ': ' + input.partition + ' · #' + input.mounts)
|
|
517
|
+
lines.push(labels.diagEvent + ': ' + (input.lastEvent === '' ? '—' : input.lastEvent))
|
|
518
|
+
if (input.handoff !== '') lines.push(labels.diagHandoff + ': ' + input.handoff)
|
|
519
|
+
if (labels.diagRight !== undefined && input.right !== undefined && input.right !== '') {
|
|
520
|
+
lines.push(labels.diagRight + ': ' + input.right)
|
|
521
|
+
}
|
|
522
|
+
if (labels.diagSeat !== undefined && input.seat !== undefined && input.seat !== '') {
|
|
523
|
+
lines.push(labels.diagSeat + ': ' + input.seat)
|
|
524
|
+
}
|
|
525
|
+
if (labels.selfTest !== undefined && input.selfTest !== undefined && input.selfTest !== '') {
|
|
526
|
+
lines.push(labels.selfTest + ': ' + input.selfTest)
|
|
527
|
+
}
|
|
528
|
+
lines.push('dsh-chat-embed ' + input.build)
|
|
529
|
+
return lines.join('\n')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Copy text to the clipboard, with an execCommand fallback for renderers
|
|
534
|
+
* where the async clipboard API is unavailable.
|
|
535
|
+
*
|
|
536
|
+
* @param {string} text
|
|
537
|
+
* @returns {Promise<boolean>}
|
|
538
|
+
*/
|
|
539
|
+
function copyText(text) {
|
|
540
|
+
const api = typeof navigator !== 'undefined' && navigator !== null ? navigator.clipboard : undefined
|
|
541
|
+
if (api !== undefined && api !== null && typeof api.writeText === 'function') {
|
|
542
|
+
return api.writeText(text).then(
|
|
543
|
+
() => true,
|
|
544
|
+
() => fallbackCopy(text),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
return Promise.resolve(fallbackCopy(text))
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** @param {string} text */
|
|
551
|
+
function fallbackCopy(text) {
|
|
552
|
+
try {
|
|
553
|
+
if (typeof document === 'undefined' || document === null) return false
|
|
554
|
+
if (typeof document.createElement !== 'function') return false
|
|
555
|
+
const area = document.createElement('textarea')
|
|
556
|
+
area.value = text
|
|
557
|
+
area.setAttribute('readonly', 'true')
|
|
558
|
+
area.style.position = 'fixed'
|
|
559
|
+
area.style.top = '-1000px'
|
|
560
|
+
const body = document.body
|
|
561
|
+
if (body !== null && body !== undefined) body.appendChild(area)
|
|
562
|
+
if (typeof area.select === 'function') area.select()
|
|
563
|
+
const ok = typeof document.execCommand === 'function' ? document.execCommand('copy') === true : false
|
|
564
|
+
if (body !== null && body !== undefined) body.removeChild(area)
|
|
565
|
+
return ok
|
|
566
|
+
} catch (error) {
|
|
567
|
+
return false
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Whether a URL belongs to an identity provider that blocks embedded OAuth.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} url
|
|
575
|
+
* @returns {boolean}
|
|
576
|
+
*/
|
|
577
|
+
function isOAuthUrl(url) {
|
|
578
|
+
if (typeof url !== 'string') return false
|
|
579
|
+
try {
|
|
580
|
+
const parsed = new URL(url)
|
|
581
|
+
return OAUTH_HOSTS.indexOf(parsed.hostname) !== -1
|
|
582
|
+
} catch (error) {
|
|
583
|
+
return false
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Google Identity Services ends a popup sign-in on a `gsi/transform` page
|
|
589
|
+
* whose only job is `window.opener.postMessage(...)` followed by close.
|
|
590
|
+
* Replayed in a normal tab it has no opener to talk to and never finishes,
|
|
591
|
+
* so it is a dead end rather than a blank page to retry.
|
|
592
|
+
*
|
|
593
|
+
* @param {string} url
|
|
594
|
+
* @returns {boolean}
|
|
595
|
+
*/
|
|
596
|
+
function isOAuthDeadEnd(url) {
|
|
597
|
+
if (typeof url !== 'string' || url === '') return false
|
|
598
|
+
return url.indexOf('accounts.google.com/gsi/') !== -1
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* @param {string} url
|
|
603
|
+
* @returns {string | undefined}
|
|
604
|
+
*/
|
|
605
|
+
function hostOf(url) {
|
|
606
|
+
if (typeof url !== 'string' || url === '') return undefined
|
|
607
|
+
try {
|
|
608
|
+
return new URL(url).hostname
|
|
609
|
+
} catch (error) {
|
|
610
|
+
return undefined
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @param {string} id */
|
|
615
|
+
function findProvider(id) { for (const provider of PROVIDERS) if (provider.id === id) return provider
|
|
616
|
+
return undefined
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Whether a guest URL means "the page really opened". Any http(s) document
|
|
621
|
+
* counts: a provider is free to redirect to its own login host without that
|
|
622
|
+
* being a failure.
|
|
623
|
+
*
|
|
624
|
+
* @param {string} url
|
|
625
|
+
*/
|
|
626
|
+
function isLiveUrl(url) {
|
|
627
|
+
return typeof url === 'string' && url.indexOf('https://') === 0
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ------------------------------------------------------------------
|
|
631
|
+
// Store: one revision counter for status, surface and active tab
|
|
632
|
+
// ------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
// ------------------------------------------------------------------
|
|
635
|
+
// Debug log
|
|
636
|
+
//
|
|
637
|
+
// Off by default: nothing is printed and nothing is buffered until the
|
|
638
|
+
// user turns it on from the settings menu (one click) or from a probe.
|
|
639
|
+
// While on, events go to the console and to a small ring buffer that the
|
|
640
|
+
// diagnostics card can copy, which is what makes a bug report useful.
|
|
641
|
+
// ------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
/** localStorage key for the on/off switch. */
|
|
644
|
+
const LOG_KEY = 'dsh-chat-embed:log'
|
|
645
|
+
|
|
646
|
+
/** localStorage key remembering how the last guest was partitioned. */
|
|
647
|
+
const PERSIST_KEY = 'dsh-chat-embed:persist'
|
|
648
|
+
|
|
649
|
+
/** How many entries the ring buffer keeps. */
|
|
650
|
+
const LOG_LIMIT = 200
|
|
651
|
+
|
|
652
|
+
/** @returns {boolean} the persisted switch; off unless the user opted in */
|
|
653
|
+
function readStoredLogging() {
|
|
654
|
+
try {
|
|
655
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return false
|
|
656
|
+
return localStorage.getItem(LOG_KEY) === '1'
|
|
657
|
+
} catch (error) {
|
|
658
|
+
void error
|
|
659
|
+
return false
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let logEnabled = readStoredLogging()
|
|
664
|
+
|
|
665
|
+
/** @type {{ seq: number, time: string, level: string, message: string, detail: any }[]} */
|
|
666
|
+
let logEntries = []
|
|
667
|
+
|
|
668
|
+
let logSeq = 0
|
|
669
|
+
|
|
670
|
+
/** @returns {boolean} whether the debug log is on */
|
|
671
|
+
function getLogging() {
|
|
672
|
+
return logEnabled
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** @param {boolean} enabled @returns {boolean} the resulting switch */
|
|
676
|
+
function setLogging(enabled) {
|
|
677
|
+
const next = enabled === true
|
|
678
|
+
logEnabled = next
|
|
679
|
+
try {
|
|
680
|
+
if (typeof localStorage !== 'undefined' && localStorage !== null) localStorage.setItem(LOG_KEY, next ? '1' : '0')
|
|
681
|
+
} catch (error) {
|
|
682
|
+
void error
|
|
683
|
+
}
|
|
684
|
+
if (next) writeLog('info', 'debug log enabled', undefined)
|
|
685
|
+
notify()
|
|
686
|
+
return logEnabled
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function clearLog() {
|
|
690
|
+
logEntries = []
|
|
691
|
+
logSeq = 0
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** @returns {typeof logEntries} a copy of the ring buffer */
|
|
695
|
+
function readLogEntries() {
|
|
696
|
+
return logEntries.slice()
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Record one event; a no-op while the switch is off.
|
|
701
|
+
*
|
|
702
|
+
* @param {string} level
|
|
703
|
+
* @param {string} message
|
|
704
|
+
* @param {any} [detail]
|
|
705
|
+
*/
|
|
706
|
+
function log(level, message, detail) {
|
|
707
|
+
if (logEnabled !== true) return
|
|
708
|
+
writeLog(level, message, detail)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function writeLog(level, message, detail) {
|
|
712
|
+
logSeq += 1
|
|
713
|
+
const entry = {
|
|
714
|
+
seq: logSeq,
|
|
715
|
+
time: new Date().toISOString(),
|
|
716
|
+
level,
|
|
717
|
+
message,
|
|
718
|
+
detail: detail === undefined ? null : detail,
|
|
719
|
+
}
|
|
720
|
+
logEntries.push(entry)
|
|
721
|
+
if (logEntries.length > LOG_LIMIT) logEntries = logEntries.slice(logEntries.length - LOG_LIMIT)
|
|
722
|
+
const text = '[dsh-chat-embed] ' + entry.time + ' ' + entry.level.toUpperCase() + ' ' + entry.message
|
|
723
|
+
try {
|
|
724
|
+
if (typeof console === 'undefined' || console === null) return
|
|
725
|
+
if (level === 'error') console.error(text, entry.detail === null ? '' : entry.detail)
|
|
726
|
+
else if (level === 'warn') console.warn(text, entry.detail === null ? '' : entry.detail)
|
|
727
|
+
else console.log(text, entry.detail === null ? '' : entry.detail)
|
|
728
|
+
} catch (error) {
|
|
729
|
+
void error
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const watchers = new Set()
|
|
733
|
+
|
|
734
|
+
function notify() {
|
|
735
|
+
for (const watcher of Array.from(watchers)) {
|
|
736
|
+
try {
|
|
737
|
+
watcher()
|
|
738
|
+
} catch (error) {
|
|
739
|
+
void error
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* @param {() => void} watcher
|
|
746
|
+
* @returns {() => void}
|
|
747
|
+
*/
|
|
748
|
+
function watch(watcher) {
|
|
749
|
+
watchers.add(watcher)
|
|
750
|
+
return () => {
|
|
751
|
+
watchers.delete(watcher)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ------------------------------------------------------------------
|
|
756
|
+
// Active tab
|
|
757
|
+
// ------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
function readStoredActive() {
|
|
760
|
+
try {
|
|
761
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return DEFAULT_PROVIDER
|
|
762
|
+
const stored = localStorage.getItem(ACTIVE_TAB_KEY)
|
|
763
|
+
return findProvider(stored) === undefined ? DEFAULT_PROVIDER : stored
|
|
764
|
+
} catch (error) {
|
|
765
|
+
return DEFAULT_PROVIDER
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @param {string} id */
|
|
770
|
+
function storeActive(id) {
|
|
771
|
+
try {
|
|
772
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return
|
|
773
|
+
localStorage.setItem(ACTIVE_TAB_KEY, id)
|
|
774
|
+
} catch (error) {
|
|
775
|
+
void error
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ------------------------------------------------------------------
|
|
780
|
+
// Tab order (drag to reorder)
|
|
781
|
+
// ------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
/** @param {string[]} list */
|
|
784
|
+
function normalizeOrder(list) {
|
|
785
|
+
const known = new Set(PROVIDERS.map((provider) => provider.id))
|
|
786
|
+
const seen = new Set()
|
|
787
|
+
const result = []
|
|
788
|
+
for (const id of list) {
|
|
789
|
+
if (!known.has(id) || seen.has(id)) continue
|
|
790
|
+
seen.add(id)
|
|
791
|
+
result.push(id)
|
|
792
|
+
}
|
|
793
|
+
for (const provider of PROVIDERS) {
|
|
794
|
+
if (!seen.has(provider.id)) result.push(provider.id)
|
|
795
|
+
}
|
|
796
|
+
return result
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** @returns {string[]} */
|
|
800
|
+
function readStoredOrder() {
|
|
801
|
+
try {
|
|
802
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return []
|
|
803
|
+
const raw = localStorage.getItem(TAB_ORDER_KEY)
|
|
804
|
+
if (raw === null) return []
|
|
805
|
+
const parsed = JSON.parse(raw)
|
|
806
|
+
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
|
|
807
|
+
} catch (error) {
|
|
808
|
+
return []
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Provider ids in the user's order. */
|
|
813
|
+
let tabOrder = normalizeOrder(readStoredOrder())
|
|
814
|
+
|
|
815
|
+
/** @returns {object[]} every provider in the user's order (enabled or not). */
|
|
816
|
+
function providersInOrder() {
|
|
817
|
+
const result = []
|
|
818
|
+
for (const id of tabOrder) {
|
|
819
|
+
const provider = findProvider(id)
|
|
820
|
+
if (provider !== undefined) result.push(provider)
|
|
821
|
+
}
|
|
822
|
+
return result
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** @returns {object[]} the providers whose tabs are currently shown. */
|
|
826
|
+
function orderedProviders() {
|
|
827
|
+
return providersInOrder().filter((provider) => isEnabled(provider.id))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** @returns {string[]} */
|
|
831
|
+
function getTabOrder() {
|
|
832
|
+
return tabOrder.slice()
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ------------------------------------------------------------------
|
|
836
|
+
// Enabled providers (settings menu)
|
|
837
|
+
// ------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Keep only known ids, drop duplicates, and fall back to "all enabled" so a
|
|
841
|
+
* corrupt or empty preference never leaves the panel without tabs.
|
|
842
|
+
*
|
|
843
|
+
* @param {string[]} list
|
|
844
|
+
* @returns {string[]}
|
|
845
|
+
*/
|
|
846
|
+
function normalizeEnabled(list) {
|
|
847
|
+
const known = new Set(PROVIDERS.map((provider) => provider.id))
|
|
848
|
+
const seen = new Set()
|
|
849
|
+
const result = []
|
|
850
|
+
for (const id of list) {
|
|
851
|
+
if (!known.has(id) || seen.has(id)) continue
|
|
852
|
+
seen.add(id)
|
|
853
|
+
result.push(id)
|
|
854
|
+
}
|
|
855
|
+
return result.length === 0 ? PROVIDERS.map((provider) => provider.id) : result
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** @returns {string[]} */
|
|
859
|
+
function readStoredEnabled() {
|
|
860
|
+
try {
|
|
861
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return []
|
|
862
|
+
const raw = localStorage.getItem(ENABLED_KEY)
|
|
863
|
+
if (raw === null) return []
|
|
864
|
+
const parsed = JSON.parse(raw)
|
|
865
|
+
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
|
|
866
|
+
} catch (error) {
|
|
867
|
+
return []
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Enabled provider ids, normalised once at startup. */
|
|
872
|
+
let enabledIds = normalizeEnabled(readStoredEnabled())
|
|
873
|
+
|
|
874
|
+
/** @param {string} id */
|
|
875
|
+
function isEnabled(id) {
|
|
876
|
+
return enabledIds.indexOf(id) !== -1
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** @returns {string[]} */
|
|
880
|
+
function getEnabled() {
|
|
881
|
+
return enabledIds.slice()
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** @returns {string} */
|
|
885
|
+
function firstEnabled() {
|
|
886
|
+
return enabledIds.length === 0 ? DEFAULT_PROVIDER : enabledIds[0]
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function storeEnabled() {
|
|
890
|
+
try {
|
|
891
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return
|
|
892
|
+
localStorage.setItem(ENABLED_KEY, JSON.stringify(enabledIds))
|
|
893
|
+
} catch (error) {
|
|
894
|
+
void error
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Show or hide one provider's tab. The last enabled provider cannot be
|
|
900
|
+
* hidden; hiding the active one moves the panel to the next enabled tab.
|
|
901
|
+
*
|
|
902
|
+
* @param {string} id
|
|
903
|
+
* @param {boolean} on
|
|
904
|
+
* @returns {boolean} whether the change was applied
|
|
905
|
+
*/
|
|
906
|
+
function setProviderEnabled(id, on) {
|
|
907
|
+
const provider = findProvider(id)
|
|
908
|
+
if (provider === undefined) return false
|
|
909
|
+
const currently = isEnabled(id)
|
|
910
|
+
if (currently === on) return true
|
|
911
|
+
log('info', 'provider toggled', { provider: id, on: on === true })
|
|
912
|
+
if (on === false && enabledIds.length <= 1) return false
|
|
913
|
+
|
|
914
|
+
if (on) {
|
|
915
|
+
// Re-insert following the user's tab order, not at the end.
|
|
916
|
+
enabledIds = normalizeEnabled(tabOrder.filter((entryId) => entryId === id || isEnabled(entryId)))
|
|
917
|
+
} else {
|
|
918
|
+
enabledIds = enabledIds.filter((entryId) => entryId !== id)
|
|
919
|
+
const record = guestRecord(id)
|
|
920
|
+
if (record.element !== undefined && record.element !== null) dropGuest(record)
|
|
921
|
+
}
|
|
922
|
+
storeEnabled()
|
|
923
|
+
|
|
924
|
+
if (on === false && state.active === id) {
|
|
925
|
+
const next = firstEnabled()
|
|
926
|
+
state.active = next
|
|
927
|
+
storeActive(next)
|
|
928
|
+
syncLayers()
|
|
929
|
+
notify()
|
|
930
|
+
void ensureActiveGuest()
|
|
931
|
+
return true
|
|
932
|
+
}
|
|
933
|
+
notify()
|
|
934
|
+
if (on) void ensureActiveGuest()
|
|
935
|
+
return true
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Move the active tab to a still-enabled provider when it was hidden. */
|
|
939
|
+
function reconcileActive() {
|
|
940
|
+
if (isEnabled(state.active)) return
|
|
941
|
+
const next = findProvider(firstEnabled())
|
|
942
|
+
if (next === undefined) return
|
|
943
|
+
state.active = next.id
|
|
944
|
+
storeActive(next.id)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Move a tab to a new index and remember the order.
|
|
949
|
+
*
|
|
950
|
+
* @param {string} fromId
|
|
951
|
+
* @param {number} toIndex
|
|
952
|
+
*/
|
|
953
|
+
function moveProvider(fromId, toIndex) {
|
|
954
|
+
const from = tabOrder.indexOf(fromId)
|
|
955
|
+
if (from === -1) return
|
|
956
|
+
const next = tabOrder.filter((id) => id !== fromId)
|
|
957
|
+
const index = Math.max(0, Math.min(next.length, toIndex))
|
|
958
|
+
next.splice(index, 0, fromId)
|
|
959
|
+
if (next.join('\u0000') === tabOrder.join('\u0000')) return
|
|
960
|
+
tabOrder = next
|
|
961
|
+
try {
|
|
962
|
+
if (typeof localStorage !== 'undefined' && localStorage !== null) {
|
|
963
|
+
localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabOrder))
|
|
964
|
+
}
|
|
965
|
+
} catch (error) {
|
|
966
|
+
void error
|
|
967
|
+
}
|
|
968
|
+
notify()
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** @type {{ active: string, surface: { host: any } }} */
|
|
972
|
+
const state = {
|
|
973
|
+
active: readStoredActive(),
|
|
974
|
+
surface: { host: null },
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
reconcileActive()
|
|
978
|
+
|
|
979
|
+
/** @returns {object} the active provider record. */
|
|
980
|
+
function activeProvider() {
|
|
981
|
+
const provider = findProvider(state.active)
|
|
982
|
+
if (provider !== undefined && isEnabled(provider.id)) return provider
|
|
983
|
+
const fallback = findProvider(firstEnabled())
|
|
984
|
+
return fallback !== undefined ? fallback : PROVIDERS[0]
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ------------------------------------------------------------------
|
|
988
|
+
// Guest registry (one guest per provider)
|
|
989
|
+
// ------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* @typedef {object} GuestRecord
|
|
993
|
+
* @property {any} element
|
|
994
|
+
* @property {string | undefined} lease
|
|
995
|
+
* @property {string | undefined} partition
|
|
996
|
+
* @property {any} layer
|
|
997
|
+
* @property {boolean} mounting
|
|
998
|
+
* @property {number} generation
|
|
999
|
+
* @property {number} mounts
|
|
1000
|
+
* @property {boolean} navigated
|
|
1001
|
+
* @property {string} phase
|
|
1002
|
+
* @property {string} detail
|
|
1003
|
+
* @property {{ ready: Function, failed: Function } | undefined} handlers
|
|
1004
|
+
* @property {(() => void) | undefined} popupOff
|
|
1005
|
+
* @property {boolean} oauthPending
|
|
1006
|
+
* @property {any} recoveryTimer
|
|
1007
|
+
* @property {string} lastUrl
|
|
1008
|
+
* @property {string} lastEvent
|
|
1009
|
+
* @property {boolean} diagOpen
|
|
1010
|
+
* @property {boolean} oauthBlocked
|
|
1011
|
+
* @property {any} reloadTimer
|
|
1012
|
+
* @property {string} handoff
|
|
1013
|
+
*/
|
|
1014
|
+
|
|
1015
|
+
/** @type {Map<string, GuestRecord>} */
|
|
1016
|
+
const guests = new Map()
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The last page each service was on, kept outside the guest record so it
|
|
1020
|
+
* survives a guest teardown: closing and reopening the tab then returns to
|
|
1021
|
+
* the same conversation instead of the service home page.
|
|
1022
|
+
*
|
|
1023
|
+
* @type {Map<string, string>}
|
|
1024
|
+
*/
|
|
1025
|
+
const lastUrls = new Map()
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Where a freshly built guest should open.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {any} provider
|
|
1031
|
+
* @returns {string}
|
|
1032
|
+
*/
|
|
1033
|
+
function startUrlFor(provider) {
|
|
1034
|
+
const remembered = lastUrls.get(provider.id)
|
|
1035
|
+
if (typeof remembered !== 'string' || remembered === '') return provider.url
|
|
1036
|
+
if (!remembered.startsWith(provider.origin)) return provider.url
|
|
1037
|
+
if (!isLiveUrl(remembered)) return provider.url
|
|
1038
|
+
if (isOAuthDeadEnd(remembered)) return provider.url
|
|
1039
|
+
return remembered
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** @returns {GuestRecord} */
|
|
1043
|
+
function guestRecord(id) {
|
|
1044
|
+
let record = guests.get(id)
|
|
1045
|
+
if (record === undefined) {
|
|
1046
|
+
record = {
|
|
1047
|
+
element: undefined,
|
|
1048
|
+
lease: undefined,
|
|
1049
|
+
partition: undefined,
|
|
1050
|
+
layer: undefined,
|
|
1051
|
+
mounting: false,
|
|
1052
|
+
generation: 0,
|
|
1053
|
+
mounts: 0,
|
|
1054
|
+
navigated: false,
|
|
1055
|
+
phase: PHASE.IDLE,
|
|
1056
|
+
detail: '',
|
|
1057
|
+
handlers: undefined,
|
|
1058
|
+
popupOff: undefined,
|
|
1059
|
+
oauthPending: false,
|
|
1060
|
+
recoveryTimer: undefined,
|
|
1061
|
+
lastUrl: '',
|
|
1062
|
+
lastEvent: '',
|
|
1063
|
+
diagOpen: false,
|
|
1064
|
+
oauthBlocked: false,
|
|
1065
|
+
reloadTimer: undefined,
|
|
1066
|
+
handoff: '',
|
|
1067
|
+
selfTest: '',
|
|
1068
|
+
}
|
|
1069
|
+
guests.set(id, record)
|
|
1070
|
+
}
|
|
1071
|
+
return record
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** @returns {{ phase: string, detail: string }} */
|
|
1075
|
+
function getStatus() {
|
|
1076
|
+
const record = guestRecord(state.active)
|
|
1077
|
+
return { phase: record.phase, detail: record.detail }
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* @param {string} id
|
|
1082
|
+
* @param {string} phase
|
|
1083
|
+
* @param {string} [detail]
|
|
1084
|
+
*/
|
|
1085
|
+
function setPhase(id, phase, detail) {
|
|
1086
|
+
const record = guestRecord(id)
|
|
1087
|
+
if (record.phase === phase && record.detail === (detail === undefined ? '' : detail)) return
|
|
1088
|
+
record.phase = phase
|
|
1089
|
+
record.detail = detail === undefined ? '' : detail
|
|
1090
|
+
notify()
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Hand a lease back to the shell. Failures are swallowed on purpose: a
|
|
1095
|
+
* rejected release must never leave the panel stuck in a failed state.
|
|
1096
|
+
*
|
|
1097
|
+
* @param {string | undefined} lease
|
|
1098
|
+
*/
|
|
1099
|
+
function releaseLease(lease) {
|
|
1100
|
+
const bridge = desktopBrowser()
|
|
1101
|
+
if (bridge === undefined || typeof lease !== 'string' || lease === '') return
|
|
1102
|
+
try {
|
|
1103
|
+
const pending = bridge.release(lease)
|
|
1104
|
+
if (pending !== undefined && pending !== null && typeof pending.catch === 'function') {
|
|
1105
|
+
pending.catch(() => {})
|
|
1106
|
+
}
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
void error
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** @param {GuestRecord} record */
|
|
1113
|
+
function dropGuest(record) {
|
|
1114
|
+
const element = record.element
|
|
1115
|
+
const lease = record.lease
|
|
1116
|
+
const handlers = record.handlers
|
|
1117
|
+
const popupOff = record.popupOff
|
|
1118
|
+
|
|
1119
|
+
record.element = undefined
|
|
1120
|
+
record.lease = undefined
|
|
1121
|
+
record.partition = undefined
|
|
1122
|
+
record.navigated = false
|
|
1123
|
+
record.handlers = undefined
|
|
1124
|
+
record.popupOff = undefined
|
|
1125
|
+
record.oauthPending = false
|
|
1126
|
+
record.lastUrl = ''
|
|
1127
|
+
record.lastEvent = ''
|
|
1128
|
+
record.diagOpen = false
|
|
1129
|
+
record.selfTest = ''
|
|
1130
|
+
// `oauthBlocked` is deliberately kept: the explanation must survive the
|
|
1131
|
+
// recovery reload that replaces the stuck guest.
|
|
1132
|
+
|
|
1133
|
+
if (record.reloadTimer !== undefined) {
|
|
1134
|
+
try {
|
|
1135
|
+
clearTimeout(record.reloadTimer)
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
void error
|
|
1138
|
+
}
|
|
1139
|
+
record.reloadTimer = undefined
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (record.recoveryTimer !== undefined) {
|
|
1143
|
+
try {
|
|
1144
|
+
clearTimeout(record.recoveryTimer)
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
void error
|
|
1147
|
+
}
|
|
1148
|
+
record.recoveryTimer = undefined
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (typeof popupOff === 'function') {
|
|
1152
|
+
try {
|
|
1153
|
+
popupOff()
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
void error
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (element !== undefined && element !== null) {
|
|
1160
|
+
try {
|
|
1161
|
+
if (handlers !== undefined) {
|
|
1162
|
+
element.removeEventListener('dom-ready', handlers.ready)
|
|
1163
|
+
element.removeEventListener('did-fail-load', handlers.failed)
|
|
1164
|
+
element.removeEventListener('did-navigate', handlers.navigate)
|
|
1165
|
+
element.removeEventListener('did-navigate-in-page', handlers.navigateInPage)
|
|
1166
|
+
element.removeEventListener('did-stop-loading', handlers.stop)
|
|
1167
|
+
element.removeEventListener('render-process-gone', handlers.crash)
|
|
1168
|
+
element.removeEventListener('crashed', handlers.crash)
|
|
1169
|
+
}
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
void error
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
if (element.parentNode !== null && element.parentNode !== undefined) {
|
|
1175
|
+
element.parentNode.removeChild(element)
|
|
1176
|
+
} else if (typeof element.remove === 'function') {
|
|
1177
|
+
element.remove()
|
|
1178
|
+
}
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
void error
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (lease !== undefined) releaseLease(lease)
|
|
1184
|
+
|
|
1185
|
+
const layer = record.layer
|
|
1186
|
+
record.layer = undefined
|
|
1187
|
+
if (layer !== undefined && layer !== null && layer.isConnected !== false) {
|
|
1188
|
+
try {
|
|
1189
|
+
if (layer.parentNode !== null && layer.parentNode !== undefined) {
|
|
1190
|
+
layer.parentNode.removeChild(layer)
|
|
1191
|
+
}
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
void error
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* @param {any} element
|
|
1200
|
+
* @returns {string}
|
|
1201
|
+
*/
|
|
1202
|
+
function guestUrl(element) {
|
|
1203
|
+
if (element === undefined || element === null || typeof element.getURL !== 'function') return ''
|
|
1204
|
+
try {
|
|
1205
|
+
const url = element.getURL()
|
|
1206
|
+
return typeof url === 'string' ? url : ''
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
return ''
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** @param {object} provider */
|
|
1213
|
+
function layerFor(provider) {
|
|
1214
|
+
const record = guestRecord(provider.id)
|
|
1215
|
+
if (record.layer !== undefined && record.layer !== null && record.layer.isConnected !== false) {
|
|
1216
|
+
return record.layer
|
|
1217
|
+
}
|
|
1218
|
+
const host = state.surface.host
|
|
1219
|
+
if (host === null || host === undefined) return undefined
|
|
1220
|
+
const layer = document.createElement('div')
|
|
1221
|
+
layer.className = 'dce-guest-layer'
|
|
1222
|
+
layer.setAttribute('data-provider', provider.id)
|
|
1223
|
+
layer.setAttribute('data-active', provider.id === state.active ? 'true' : 'false')
|
|
1224
|
+
host.appendChild(layer)
|
|
1225
|
+
record.layer = layer
|
|
1226
|
+
return layer
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Show only the active provider's layer; hidden layers keep their guest. */
|
|
1230
|
+
function syncLayers() {
|
|
1231
|
+
for (const provider of PROVIDERS) {
|
|
1232
|
+
const record = guests.get(provider.id)
|
|
1233
|
+
if (record === undefined || record.layer === undefined || record.layer === null) continue
|
|
1234
|
+
const want = provider.id === state.active ? 'true' : 'false'
|
|
1235
|
+
if (record.layer.getAttribute('data-active') !== want) record.layer.setAttribute('data-active', want)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Create a provider's guest the first time its tab is shown (idempotent).
|
|
1241
|
+
*
|
|
1242
|
+
* @param {string} id
|
|
1243
|
+
* @returns {Promise<void>}
|
|
1244
|
+
*/
|
|
1245
|
+
async function mountGuest(id) {
|
|
1246
|
+
const provider = findProvider(id)
|
|
1247
|
+
if (provider === undefined) return
|
|
1248
|
+
const record = guestRecord(id)
|
|
1249
|
+
|
|
1250
|
+
if (record.mounting === true) return
|
|
1251
|
+
if (record.element !== undefined && record.element !== null) {
|
|
1252
|
+
if (record.element.isConnected !== false) return
|
|
1253
|
+
// The host was torn down underneath the guest; rebuild it.
|
|
1254
|
+
dropGuest(record)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const layer = layerFor(provider)
|
|
1258
|
+
if (layer === undefined) return
|
|
1259
|
+
|
|
1260
|
+
record.mounting = true
|
|
1261
|
+
record.generation += 1
|
|
1262
|
+
const generation = record.generation
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
const bridge = desktopBrowser()
|
|
1266
|
+
if (bridge === undefined) {
|
|
1267
|
+
setPhase(id, PHASE.UNSUPPORTED, '')
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
setPhase(id, PHASE.ACQUIRING, '')
|
|
1272
|
+
let lease
|
|
1273
|
+
let partition
|
|
1274
|
+
try {
|
|
1275
|
+
const acquired = await bridge.acquire(provider.workspace)
|
|
1276
|
+
lease = acquired === null || acquired === undefined ? undefined : acquired.lease
|
|
1277
|
+
partition = acquired === null || acquired === undefined ? undefined : acquired.partition
|
|
1278
|
+
if (typeof lease !== 'string' || lease === '') throw new Error('desktop shell returned no lease')
|
|
1279
|
+
if (typeof partition !== 'string' || partition === '') {
|
|
1280
|
+
throw new Error('desktop shell returned no partition')
|
|
1281
|
+
}
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
if (generation === record.generation) setPhase(id, PHASE.FAILED, messageOf(error))
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
log('info', 'lease acquired', { provider: id, partition: shortId(partition) })
|
|
1288
|
+
|
|
1289
|
+
// Teardown or reload happened while the lease was in flight.
|
|
1290
|
+
if (generation !== record.generation) {
|
|
1291
|
+
releaseLease(lease)
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const element = document.createElement('webview')
|
|
1296
|
+
element.className = 'dce-guest'
|
|
1297
|
+
element.setAttribute('src', 'about:blank#' + lease)
|
|
1298
|
+
element.setAttribute('partition', partition)
|
|
1299
|
+
element.setAttribute('useragent', currentUserAgent())
|
|
1300
|
+
// OAuth buttons ("Continue with Google") are popups. Without this the
|
|
1301
|
+
// request dies inside the guest and the site reports a generic sign-in
|
|
1302
|
+
// error; with it, the shell relays the URL to us instead of creating a
|
|
1303
|
+
// window, and `onGuestPopup` continues the flow in place.
|
|
1304
|
+
element.setAttribute('allowpopups', '')
|
|
1305
|
+
element.style.cssText = 'display:inline-flex;width:100%;height:100%;border:0;background:transparent;'
|
|
1306
|
+
|
|
1307
|
+
const handlers = {
|
|
1308
|
+
ready: (event) => onGuestReady(id, event),
|
|
1309
|
+
failed: (event) => onGuestFailed(id, event),
|
|
1310
|
+
navigate: (event) => onGuestNavigated(id, event !== undefined && event !== null ? event.url : ''),
|
|
1311
|
+
navigateInPage: (event) => onGuestLocation(id, event !== undefined && event !== null ? event.url : ''),
|
|
1312
|
+
stop: () => onGuestStopped(id),
|
|
1313
|
+
crash: () => onGuestCrashed(id),
|
|
1314
|
+
}
|
|
1315
|
+
element.addEventListener('dom-ready', handlers.ready)
|
|
1316
|
+
element.addEventListener('did-fail-load', handlers.failed)
|
|
1317
|
+
element.addEventListener('did-navigate', handlers.navigate)
|
|
1318
|
+
element.addEventListener('did-navigate-in-page', handlers.navigateInPage)
|
|
1319
|
+
element.addEventListener('did-stop-loading', handlers.stop)
|
|
1320
|
+
element.addEventListener('render-process-gone', handlers.crash)
|
|
1321
|
+
element.addEventListener('crashed', handlers.crash)
|
|
1322
|
+
|
|
1323
|
+
record.element = element
|
|
1324
|
+
record.lease = lease
|
|
1325
|
+
record.partition = partition
|
|
1326
|
+
record.navigated = false
|
|
1327
|
+
record.handlers = handlers
|
|
1328
|
+
record.mounts += 1
|
|
1329
|
+
|
|
1330
|
+
try {
|
|
1331
|
+
layer.appendChild(element)
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
dropGuest(record)
|
|
1334
|
+
setPhase(id, PHASE.FAILED, messageOf(error))
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Popup requests are relayed per lease; keep the disposer with the guest
|
|
1339
|
+
// so a reload or teardown cannot leave a listener behind.
|
|
1340
|
+
if (typeof bridge.onOpenRequested === 'function') {
|
|
1341
|
+
try {
|
|
1342
|
+
record.popupOff = bridge.onOpenRequested(lease, (url) => onGuestPopup(id, url))
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
record.popupOff = undefined
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
setPhase(id, PHASE.NAVIGATING, '')
|
|
1348
|
+
} finally {
|
|
1349
|
+
record.mounting = false
|
|
1350
|
+
syncLayers()
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Navigate a guest to its provider's entry URL.
|
|
1356
|
+
*
|
|
1357
|
+
* `loadURL()` and `src` writes are ignored after attachment; only a
|
|
1358
|
+
* document-level navigation from inside the guest moves it. Retries cover
|
|
1359
|
+
* the first `dom-ready` arriving before the shell registered the lease.
|
|
1360
|
+
*
|
|
1361
|
+
* @param {string} id
|
|
1362
|
+
* @param {any} element
|
|
1363
|
+
* @returns {Promise<void>}
|
|
1364
|
+
*/
|
|
1365
|
+
async function navigateGuest(id, element) {
|
|
1366
|
+
const provider = findProvider(id)
|
|
1367
|
+
if (provider === undefined) return
|
|
1368
|
+
const record = guestRecord(id)
|
|
1369
|
+
|
|
1370
|
+
for (let attempt = 0; attempt < NAVIGATE_MAX_ATTEMPTS; attempt += 1) {
|
|
1371
|
+
if (element !== record.element) return
|
|
1372
|
+
try {
|
|
1373
|
+
await element.executeJavaScript('location.href = ' + JSON.stringify(startUrlFor(provider)) + ';')
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
// The guest document is not executing yet; keep trying.
|
|
1376
|
+
}
|
|
1377
|
+
await delay(NAVIGATE_RETRY_MS)
|
|
1378
|
+
if (element !== record.element) return
|
|
1379
|
+
const landed = guestUrl(element)
|
|
1380
|
+
if (isLiveUrl(landed)) {
|
|
1381
|
+
if (typeof landed === 'string' && landed !== '') lastUrls.set(id, landed)
|
|
1382
|
+
setPhase(id, PHASE.READY, '')
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (element !== record.element) return
|
|
1387
|
+
setPhase(id, PHASE.FAILED, 'navigation-timeout')
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* @param {string} id
|
|
1392
|
+
* @param {any} event
|
|
1393
|
+
*/
|
|
1394
|
+
function onGuestReady(id, event) {
|
|
1395
|
+
const record = guestRecord(id)
|
|
1396
|
+
const element =
|
|
1397
|
+
event !== undefined && event !== null && event.currentTarget !== undefined ? event.currentTarget : record.element
|
|
1398
|
+
if (element === undefined || element !== record.element) return
|
|
1399
|
+
if (record.navigated) return
|
|
1400
|
+
record.navigated = true
|
|
1401
|
+
void navigateGuest(id, element)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* @param {string} id
|
|
1406
|
+
* @param {any} event
|
|
1407
|
+
*/
|
|
1408
|
+
function onGuestFailed(id, event) {
|
|
1409
|
+
const record = guestRecord(id)
|
|
1410
|
+
const element =
|
|
1411
|
+
event !== undefined && event !== null && event.currentTarget !== undefined ? event.currentTarget : record.element
|
|
1412
|
+
if (element === undefined || element !== record.element) return
|
|
1413
|
+
if (event !== undefined && event !== null && event.errorCode === -3) return
|
|
1414
|
+
setPhase(id, PHASE.FAILED, 'load-failed')
|
|
1415
|
+
log('warn', 'guest failed to load', { provider: id })
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Remember where the guest is (in-page navigations included).
|
|
1420
|
+
*
|
|
1421
|
+
* @param {string} id
|
|
1422
|
+
* @param {string} url
|
|
1423
|
+
*/
|
|
1424
|
+
function onGuestLocation(id, url) {
|
|
1425
|
+
const record = guestRecord(id)
|
|
1426
|
+
if (typeof url === 'string' && url !== '') {
|
|
1427
|
+
record.lastUrl = url
|
|
1428
|
+
lastUrls.set(id, url)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Describe — never dump — the credential Google left on the handoff page.
|
|
1434
|
+
*
|
|
1435
|
+
* Records only the shape (hash/query length and top-level key names) so the
|
|
1436
|
+
* diagnostics card can answer "is interception even possible here?" without
|
|
1437
|
+
* copying a live token anywhere.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {any} element
|
|
1440
|
+
* @param {GuestRecord} record
|
|
1441
|
+
* @returns {Promise<void>}
|
|
1442
|
+
*/
|
|
1443
|
+
async function inspectOAuthHandoff(element, record) {
|
|
1444
|
+
try {
|
|
1445
|
+
const raw = await Promise.race([
|
|
1446
|
+
element.executeJavaScript(
|
|
1447
|
+
'(function(){try{var h=String(location.hash||"");var q=String(location.search||"");var keys=[];' +
|
|
1448
|
+
'if(h.length>1){try{var o=JSON.parse(decodeURIComponent(h.slice(1)));if(o&&typeof o==="object")keys=Object.keys(o)}catch(e){}}' +
|
|
1449
|
+
'return JSON.stringify({hash:h.length,search:q.length,keys:keys.slice(0,10)})}catch(e){return ""}})()',
|
|
1450
|
+
),
|
|
1451
|
+
delay(TIMINGS.oauthHandoffProbeMs).then(() => ''),
|
|
1452
|
+
])
|
|
1453
|
+
record.lastEvent =
|
|
1454
|
+
typeof raw === 'string' && raw !== '' ? 'oauth-dead-end ' + raw : 'oauth-dead-end (no js response)'
|
|
1455
|
+
// Kept on the record (not reset with the guest) so the diagnostics card
|
|
1456
|
+
// still explains the handoff after the recovery reload.
|
|
1457
|
+
record.handoff = typeof raw === 'string' && raw !== '' ? raw : '(无 JS 返回)'
|
|
1458
|
+
notify()
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
record.lastEvent = 'oauth-dead-end (probe failed)'
|
|
1461
|
+
record.handoff = '(探测失败)'
|
|
1462
|
+
notify()
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* A load finished. Every load gets one blank inspection — this is what turns
|
|
1468
|
+
* an unreadable white page into a card with buttons, even when the OAuth
|
|
1469
|
+
* heuristic above never armed.
|
|
1470
|
+
*
|
|
1471
|
+
* @param {string} id
|
|
1472
|
+
*/
|
|
1473
|
+
function onGuestStopped(id) {
|
|
1474
|
+
const record = guestRecord(id)
|
|
1475
|
+
const element = record.element
|
|
1476
|
+
if (element === undefined || element === null) return
|
|
1477
|
+
const url = guestUrl(element)
|
|
1478
|
+
record.lastUrl = url
|
|
1479
|
+
if (typeof url === 'string' && url !== '' && isLiveUrl(url)) lastUrls.set(id, url)
|
|
1480
|
+
record.lastEvent = 'did-stop-loading'
|
|
1481
|
+
|
|
1482
|
+
// Google's popup-handoff page cannot finish without an opener: return to
|
|
1483
|
+
// a usable page and explain, instead of inspecting a white box.
|
|
1484
|
+
if (isOAuthDeadEnd(url)) {
|
|
1485
|
+
record.lastEvent = 'oauth-dead-end'
|
|
1486
|
+
record.oauthBlocked = true
|
|
1487
|
+
record.oauthPending = false
|
|
1488
|
+
notify()
|
|
1489
|
+
// Best effort only: describe the handoff page for the diagnostics card.
|
|
1490
|
+
void inspectOAuthHandoff(element, record)
|
|
1491
|
+
// Recovery must not depend on that page executing scripts — replace the
|
|
1492
|
+
// guest instead, which is the only reliable way out of a dead document.
|
|
1493
|
+
if (record.reloadTimer !== undefined) {
|
|
1494
|
+
try {
|
|
1495
|
+
clearTimeout(record.reloadTimer)
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
void error
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
record.reloadTimer = setTimeout(() => {
|
|
1501
|
+
record.reloadTimer = undefined
|
|
1502
|
+
if (record.element !== element) return
|
|
1503
|
+
void reloadGuest(id)
|
|
1504
|
+
}, TIMINGS.oauthRecoveryReloadMs)
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// The initial `about:blank#<lease>` load is blank by design.
|
|
1509
|
+
if (url.indexOf('about:') === 0) return
|
|
1510
|
+
notify()
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* The guest's renderer died — surface it instead of leaving a white box.
|
|
1515
|
+
*
|
|
1516
|
+
* @param {string} id
|
|
1517
|
+
*/
|
|
1518
|
+
function onGuestCrashed(id) {
|
|
1519
|
+
const record = guestRecord(id)
|
|
1520
|
+
if (record.element === undefined || record.element === null) return
|
|
1521
|
+
setPhase(id, PHASE.FAILED, 'renderer-gone')
|
|
1522
|
+
record.lastEvent = 'renderer-gone'
|
|
1523
|
+
log('error', 'renderer gone', { provider: id })
|
|
1524
|
+
notify()
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* A guest asked to open a window (OAuth button, "open in new window" link).
|
|
1529
|
+
*
|
|
1530
|
+
* The desktop shell never creates that window: it validates the URL against
|
|
1531
|
+
* its own navigation policy and hands it here instead. A plugin cannot spawn
|
|
1532
|
+
* a real popup, so the flow continues **in place** — that is exactly what an
|
|
1533
|
+
* OAuth redirect expects, and it keeps the sign-in inside the same partition.
|
|
1534
|
+
*
|
|
1535
|
+
* @param {string} id
|
|
1536
|
+
* @param {string} url
|
|
1537
|
+
*/
|
|
1538
|
+
function onGuestPopup(id, url) {
|
|
1539
|
+
const record = guestRecord(id)
|
|
1540
|
+
const element = record.element
|
|
1541
|
+
if (element === undefined || element === null) return
|
|
1542
|
+
if (typeof url !== 'string' || url.indexOf('https://') !== 0) return
|
|
1543
|
+
|
|
1544
|
+
// Google/Apple complete sign-in but their callback page cannot finish in
|
|
1545
|
+
// an embedded WebContents, so arm the dead-end tracking from here.
|
|
1546
|
+
if (isOAuthUrl(url)) {
|
|
1547
|
+
record.oauthPending = true
|
|
1548
|
+
notify()
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
navigateInPlace(element, url)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Track where a guest lands.
|
|
1556
|
+
*
|
|
1557
|
+
* OAuth can start as a popup (handled in `onGuestPopup`) *or* as a plain
|
|
1558
|
+
* full-page redirect, so the identity-provider hop is detected here too.
|
|
1559
|
+
* When the roundtrip comes back to the site's own origin, recovery is
|
|
1560
|
+
* scheduled.
|
|
1561
|
+
*
|
|
1562
|
+
* @param {string} id
|
|
1563
|
+
* @param {string} url
|
|
1564
|
+
*/
|
|
1565
|
+
function onGuestNavigated(id, url) {
|
|
1566
|
+
const record = guestRecord(id)
|
|
1567
|
+
const provider = findProvider(id)
|
|
1568
|
+
if (provider === undefined) return
|
|
1569
|
+
const host = hostOf(url)
|
|
1570
|
+
if (host === undefined) return
|
|
1571
|
+
|
|
1572
|
+
if (isOAuthUrl(url)) {
|
|
1573
|
+
if (record.oauthPending !== true) {
|
|
1574
|
+
record.oauthPending = true
|
|
1575
|
+
notify()
|
|
1576
|
+
}
|
|
1577
|
+
return
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (record.oauthPending !== true) return
|
|
1581
|
+
if (host !== hostOf(provider.origin)) return
|
|
1582
|
+
record.oauthPending = false
|
|
1583
|
+
scheduleOAuthRecovery(id, provider)
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** @param {GuestRecord} record */
|
|
1587
|
+
function clearRecoveryTimer(record) {
|
|
1588
|
+
if (record.recoveryTimer === undefined) return
|
|
1589
|
+
try {
|
|
1590
|
+
clearTimeout(record.recoveryTimer)
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
void error
|
|
1593
|
+
}
|
|
1594
|
+
record.recoveryTimer = undefined
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Continue a document in place — the only navigation a leased guest honors.
|
|
1599
|
+
*
|
|
1600
|
+
* @param {any} element
|
|
1601
|
+
* @param {string} url
|
|
1602
|
+
*/
|
|
1603
|
+
function navigateInPlace(element, url) {
|
|
1604
|
+
try {
|
|
1605
|
+
const pending = element.executeJavaScript('location.href = ' + JSON.stringify(url) + ';')
|
|
1606
|
+
if (pending !== undefined && pending !== null && typeof pending.catch === 'function') pending.catch(() => {})
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
void error
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Recover from a blank OAuth callback in one bounded step.
|
|
1614
|
+
*
|
|
1615
|
+
* The callback route is written for a popup that reports back through
|
|
1616
|
+
* `window.opener`; replayed as a normal tab it can render nothing even
|
|
1617
|
+
* though the sign-in cookie already landed. Loading the site's entry URL
|
|
1618
|
+
* once (same partition) therefore usually lands the user signed in. No
|
|
1619
|
+
* recovery card: the page is left to the site itself.
|
|
1620
|
+
*
|
|
1621
|
+
* @param {string} id
|
|
1622
|
+
* @param {object} provider
|
|
1623
|
+
*/
|
|
1624
|
+
function scheduleOAuthRecovery(id, provider) {
|
|
1625
|
+
const record = guestRecord(id)
|
|
1626
|
+
clearRecoveryTimer(record)
|
|
1627
|
+
|
|
1628
|
+
record.recoveryTimer = setTimeout(() => {
|
|
1629
|
+
record.recoveryTimer = undefined
|
|
1630
|
+
const element = record.element
|
|
1631
|
+
if (element === undefined || element === null) return
|
|
1632
|
+
void (async () => {
|
|
1633
|
+
if ((await isGuestBlank(element)) !== true) return
|
|
1634
|
+
if (element !== record.element) return
|
|
1635
|
+
navigateInPlace(element, provider.url)
|
|
1636
|
+
record.notice = ''
|
|
1637
|
+
notify()
|
|
1638
|
+
})()
|
|
1639
|
+
}, TIMINGS.oauthLandingDelayMs)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Ask the guest whether its document is effectively empty.
|
|
1644
|
+
*
|
|
1645
|
+
* Text-only on purpose: a stuck callback can still carry the site's
|
|
1646
|
+
* `<title>`, so a title check would miss exactly the case this recovers.
|
|
1647
|
+
*
|
|
1648
|
+
* @param {any} element
|
|
1649
|
+
* @returns {Promise<boolean>}
|
|
1650
|
+
*/
|
|
1651
|
+
async function isGuestBlank(element) {
|
|
1652
|
+
try {
|
|
1653
|
+
const raw = await element.executeJavaScript(
|
|
1654
|
+
'(function(){try{var b=document.body;var t=b?String(b.innerText||b.textContent||""):"";' +
|
|
1655
|
+
'return JSON.stringify({text:t.trim().slice(0,160)})}' +
|
|
1656
|
+
'catch(e){return ""}})()',
|
|
1657
|
+
)
|
|
1658
|
+
if (typeof raw !== 'string' || raw === '') return false
|
|
1659
|
+
const parsed = JSON.parse(raw)
|
|
1660
|
+
return String(parsed.text || '').length < 20
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
return false
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Replace one provider's guest with a fresh lease.
|
|
1668
|
+
*
|
|
1669
|
+
* @param {string} id
|
|
1670
|
+
* @returns {Promise<void>}
|
|
1671
|
+
*/
|
|
1672
|
+
async function reloadGuest(id) {
|
|
1673
|
+
const record = guestRecord(id)
|
|
1674
|
+
log('info', 'reload requested', { provider: id })
|
|
1675
|
+
record.generation += 1
|
|
1676
|
+
dropGuest(record)
|
|
1677
|
+
setPhase(id, PHASE.IDLE, '')
|
|
1678
|
+
await mountGuest(id)
|
|
1679
|
+
}
|
|
1680
|
+
/** Release every guest; used on plugin unload. */
|
|
1681
|
+
function destroyGuests() {
|
|
1682
|
+
for (const [id, record] of guests) {
|
|
1683
|
+
record.generation += 1
|
|
1684
|
+
dropGuest(record)
|
|
1685
|
+
setPhase(id, PHASE.IDLE, '')
|
|
1686
|
+
if (record.layer !== undefined && record.layer !== null) {
|
|
1687
|
+
try {
|
|
1688
|
+
if (record.layer.parentNode !== null && record.layer.parentNode !== undefined) {
|
|
1689
|
+
record.layer.parentNode.removeChild(record.layer)
|
|
1690
|
+
}
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
void error
|
|
1693
|
+
}
|
|
1694
|
+
record.layer = undefined
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** @param {{left:number,top:number,width:number,height:number}} rect @returns {string} */
|
|
1700
|
+
function formatRect(rect, t) {
|
|
1701
|
+
if (rect === undefined || rect === null) return '—'
|
|
1702
|
+
return t('rect', {
|
|
1703
|
+
left: Math.round(rect.left),
|
|
1704
|
+
top: Math.round(rect.top),
|
|
1705
|
+
width: Math.round(rect.width),
|
|
1706
|
+
height: Math.round(rect.height),
|
|
1707
|
+
})
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
// ------------------------------------------------------------------
|
|
1713
|
+
// Seats (central panel and the right sidebar tab)
|
|
1714
|
+
// ------------------------------------------------------------------
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Seat preference order. The preferred seat is tried first; the other one
|
|
1718
|
+
* is only a fallback, and only while it is actually painted.
|
|
1719
|
+
*/
|
|
1720
|
+
const SEAT_ORDER = ['right', 'main']
|
|
1721
|
+
|
|
1722
|
+
/** @param {any} node */
|
|
1723
|
+
function isLiveSeat(node) {
|
|
1724
|
+
return node !== null && node !== undefined && node.isConnected !== false
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
// ------------------------------------------------------------------
|
|
1733
|
+
// Inline hosting
|
|
1734
|
+
//
|
|
1735
|
+
// The surface is rendered *inside* the panel's own layout box, so the
|
|
1736
|
+
// browser resizes it while a splitter is dragged — exactly how the shell's
|
|
1737
|
+
// terminal pane behaves. No measurement, no per-frame JS.
|
|
1738
|
+
// ------------------------------------------------------------------
|
|
1739
|
+
|
|
1740
|
+
/** @type {{ main: any, right: any }} the mounted inline viewport per seat */
|
|
1741
|
+
const inlineHosts = { main: null, right: null }
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* The seat the user opened most recently. Clicking the left navigation entry
|
|
1745
|
+
* must show the chat *there*: a hint page is not an entry.
|
|
1746
|
+
*
|
|
1747
|
+
* @type {'main' | 'right' | undefined}
|
|
1748
|
+
*/
|
|
1749
|
+
let preferredSeat = undefined
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Pick the seat that owns the guests: the user's most recent choice wins, and
|
|
1753
|
+
* the right tab is the fallback because its body stays mounted while another
|
|
1754
|
+
* tab or panel is selected.
|
|
1755
|
+
*
|
|
1756
|
+
* @returns {any} the chosen viewport element, or null
|
|
1757
|
+
*/
|
|
1758
|
+
/**
|
|
1759
|
+
* A guest element cannot move between hosts (Electron destroys the
|
|
1760
|
+
* WebContents with its DOM), so guests that are not inside the new host are
|
|
1761
|
+
* dropped and rebuilt there.
|
|
1762
|
+
*
|
|
1763
|
+
* @param {any} host
|
|
1764
|
+
*/
|
|
1765
|
+
function dropGuestsOutside(host) {
|
|
1766
|
+
for (const provider of PROVIDERS) {
|
|
1767
|
+
const record = guests.get(provider.id)
|
|
1768
|
+
if (record === undefined || record.element === undefined || record.element === null) continue
|
|
1769
|
+
const element = record.element
|
|
1770
|
+
const inside = typeof host.contains === 'function' ? host.contains(element) : false
|
|
1771
|
+
if (inside) continue
|
|
1772
|
+
dropGuest(record)
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** @returns {{ host: any }} the live guest viewport */
|
|
1777
|
+
function getSurface() {
|
|
1778
|
+
return state.surface
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** @param {any} host */
|
|
1782
|
+
function setHost(host) {
|
|
1783
|
+
state.surface.host = host
|
|
1784
|
+
notify()
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Compatibility entry for the tests and probes: only the guest viewport is
|
|
1789
|
+
* tracked, the browser owns the geometry.
|
|
1790
|
+
*
|
|
1791
|
+
* @param {{ host?: any }} patch
|
|
1792
|
+
*/
|
|
1793
|
+
function updateSurface(patch) {
|
|
1794
|
+
if (patch !== undefined && patch !== null && patch.host !== undefined) setHost(patch.host)
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function resolveInlineHost() {
|
|
1798
|
+
const seat = inlineSeat()
|
|
1799
|
+
const chosen = seat === undefined ? null : inlineHosts[seat]
|
|
1800
|
+
if (chosen !== state.surface.host) log('info', 'host resolved', { seat: seat === undefined ? 'none' : seat })
|
|
1801
|
+
setHost(chosen)
|
|
1802
|
+
if (chosen === null) destroyGuests()
|
|
1803
|
+
else {
|
|
1804
|
+
dropGuestsOutside(chosen)
|
|
1805
|
+
void ensureActiveGuest()
|
|
1806
|
+
}
|
|
1807
|
+
return chosen
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** @param {'main' | 'right'} seat @returns {boolean} */
|
|
1811
|
+
function hostsInline(seat) {
|
|
1812
|
+
return inlineSeat() === seat
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Hand the guests to another seat; that seat's surface becomes the host and
|
|
1817
|
+
* the previous one shows a pointer instead of an empty frame.
|
|
1818
|
+
*
|
|
1819
|
+
* @param {'main' | 'right'} seat
|
|
1820
|
+
*/
|
|
1821
|
+
function preferSeat(seat) {
|
|
1822
|
+
preferredSeat = seat
|
|
1823
|
+
log('info', 'seat preferred', { seat })
|
|
1824
|
+
resolveInlineHost()
|
|
1825
|
+
notify()
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/** @returns {string | undefined} the seat that currently owns the guests */
|
|
1829
|
+
/** @param {any} host @returns {number} painted area, 0 while hidden */
|
|
1830
|
+
function hostArea(host) {
|
|
1831
|
+
if (host === null || host === undefined || typeof host.getBoundingClientRect !== 'function') return 0
|
|
1832
|
+
const box = host.getBoundingClientRect()
|
|
1833
|
+
return Math.max(0, Math.round(box.width)) * Math.max(0, Math.round(box.height))
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function inlineSeat() {
|
|
1837
|
+
const order = preferredSeat === undefined ? SEAT_ORDER : [preferredSeat, ...SEAT_ORDER]
|
|
1838
|
+
let hidden
|
|
1839
|
+
for (let index = 0; index < order.length; index += 1) {
|
|
1840
|
+
const seat = order[index]
|
|
1841
|
+
const host = inlineHosts[seat]
|
|
1842
|
+
if (host === null || host === undefined) continue
|
|
1843
|
+
if (hidden === undefined) hidden = seat
|
|
1844
|
+
// A freshly mounted center panel has a zero rect for one commit, so the
|
|
1845
|
+
// *main* choice may win before it is measured. The right column is a
|
|
1846
|
+
// different story: a collapsed tab body stays at 0×0 forever, and moving
|
|
1847
|
+
// the page there would paint it into nothing.
|
|
1848
|
+
const chosen = preferredSeat !== undefined && index === 0 && seat === preferredSeat
|
|
1849
|
+
const zeroSized = hostArea(host) <= 0
|
|
1850
|
+
if (!zeroSized || (chosen && seat === 'main')) return seat
|
|
1851
|
+
}
|
|
1852
|
+
return hidden
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* The right sidebar tab type. `multiple: false` keeps a single chat tab per
|
|
1857
|
+
* pane; the guide entry is what makes the tab reachable from the sidebar's
|
|
1858
|
+
* own launcher.
|
|
1859
|
+
*
|
|
1860
|
+
* @param {string} title
|
|
1861
|
+
* @returns {object}
|
|
1862
|
+
*/
|
|
1863
|
+
function rightTabType(title) {
|
|
1864
|
+
return {
|
|
1865
|
+
id: RIGHT_TAB_ID,
|
|
1866
|
+
kind: RIGHT_TAB_KIND,
|
|
1867
|
+
multiple: false,
|
|
1868
|
+
// Keep the body mounted while another tab is selected, exactly like the
|
|
1869
|
+
// shell's own browser type, so this tab keeps its guest and scroll
|
|
1870
|
+
// position while the user looks at another tab.
|
|
1871
|
+
keepMounted: true,
|
|
1872
|
+
title: () => title,
|
|
1873
|
+
guide: [
|
|
1874
|
+
{
|
|
1875
|
+
id: 'chat',
|
|
1876
|
+
commandId: 'chat-embed.open',
|
|
1877
|
+
order: 40,
|
|
1878
|
+
title: () => title,
|
|
1879
|
+
description: () => title,
|
|
1880
|
+
},
|
|
1881
|
+
],
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function openRightTab() {
|
|
1886
|
+
try {
|
|
1887
|
+
openRightTabImpl()
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
void error
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** @returns {string} a live self-test of the hosting seat and its box. */
|
|
1894
|
+
function runSeatSelfTest(t) {
|
|
1895
|
+
const translate = typeof t === 'function' ? t : probeT
|
|
1896
|
+
const seat = inlineSeat()
|
|
1897
|
+
if (seat !== undefined) {
|
|
1898
|
+
const host = inlineHosts[seat]
|
|
1899
|
+
if (host === null || host === undefined) return translate('selfTestLostHost')
|
|
1900
|
+
const box = typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null
|
|
1901
|
+
return translate('selfTestInline', {
|
|
1902
|
+
seat: valueText(translate, SEAT_KEY, seat),
|
|
1903
|
+
host: box === null ? '—' : formatRect(box, translate),
|
|
1904
|
+
})
|
|
1905
|
+
}
|
|
1906
|
+
return translate('selfTestNoSeat')
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/** @returns {object} structured live geometry for the Inspect probe. */
|
|
1910
|
+
function seatReportData() {
|
|
1911
|
+
const rect = (value) =>
|
|
1912
|
+
value === undefined || value === null
|
|
1913
|
+
? undefined
|
|
1914
|
+
: {
|
|
1915
|
+
left: Math.round(value.left),
|
|
1916
|
+
top: Math.round(value.top),
|
|
1917
|
+
width: Math.round(value.width),
|
|
1918
|
+
height: Math.round(value.height),
|
|
1919
|
+
}
|
|
1920
|
+
const seat = inlineSeat()
|
|
1921
|
+
const host = seat === undefined ? undefined : inlineHosts[seat]
|
|
1922
|
+
return {
|
|
1923
|
+
build: BUILD,
|
|
1924
|
+
mode: 'inline',
|
|
1925
|
+
seat: seat || 'none',
|
|
1926
|
+
host: rect(host === undefined || host === null ? undefined : host.getBoundingClientRect()),
|
|
1927
|
+
visible: true,
|
|
1928
|
+
guest: (() => {
|
|
1929
|
+
if (host === undefined || host === null || typeof host.querySelectorAll !== 'function') {
|
|
1930
|
+
return { present: false, reason: 'no inline host' }
|
|
1931
|
+
}
|
|
1932
|
+
const webviews = host.querySelectorAll('webview')
|
|
1933
|
+
const layers = host.querySelectorAll('.dce-guest-layer')
|
|
1934
|
+
const active = host.querySelectorAll('.dce-guest-layer[data-active="true"]')
|
|
1935
|
+
const inner = document.querySelector('.dce-inline')
|
|
1936
|
+
return {
|
|
1937
|
+
present: webviews.length > 0,
|
|
1938
|
+
webviews: webviews.length,
|
|
1939
|
+
layers: layers.length,
|
|
1940
|
+
active: active.length,
|
|
1941
|
+
surfaceMounted: inner !== null && inner !== undefined,
|
|
1942
|
+
hostBox: rect(host.getBoundingClientRect()),
|
|
1943
|
+
}
|
|
1944
|
+
})(),
|
|
1945
|
+
right: rightSidebarStatus(),
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Register a read-only Client Inspect provider so the Host can read the live
|
|
1951
|
+
* geometry through `cordis_inspect_query`, with no UI interaction.
|
|
1952
|
+
*
|
|
1953
|
+
* @param {any} ctx
|
|
1954
|
+
* @returns {(() => void) | undefined}
|
|
1955
|
+
*/
|
|
1956
|
+
function installInspect(ctx, translate) {
|
|
1957
|
+
const inspect = typeof ctx.get === 'function' ? ctx.get('cordisInspect') : undefined
|
|
1958
|
+
if (inspect === undefined || inspect === null || typeof inspect.register !== 'function') return undefined
|
|
1959
|
+
try {
|
|
1960
|
+
return inspect.register({
|
|
1961
|
+
manifest: {
|
|
1962
|
+
id: 'dsh-chat-embed',
|
|
1963
|
+
description: 'Live seat and host report for the embedded chat surface.',
|
|
1964
|
+
methods: [
|
|
1965
|
+
{
|
|
1966
|
+
name: 'seatReport',
|
|
1967
|
+
description: 'Return the hosting seat, its host box, guest state and a self-test verdict.',
|
|
1968
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1969
|
+
outputSchema: { type: 'object' },
|
|
1970
|
+
},
|
|
1971
|
+
{
|
|
1972
|
+
name: 'openPanel',
|
|
1973
|
+
description: 'Select this plugin central panel, the same thing the left entry does.',
|
|
1974
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1975
|
+
outputSchema: { type: 'object' },
|
|
1976
|
+
},
|
|
1977
|
+
{
|
|
1978
|
+
name: 'dragProbe',
|
|
1979
|
+
description: 'Drag the right column divider with synthetic pointers and report whether the inline surface followed.',
|
|
1980
|
+
inputSchema: {
|
|
1981
|
+
type: 'object',
|
|
1982
|
+
properties: { dx: { type: 'number', description: 'Horizontal drag distance; defaults to -48.' } },
|
|
1983
|
+
additionalProperties: false,
|
|
1984
|
+
},
|
|
1985
|
+
outputSchema: { type: 'object' },
|
|
1986
|
+
},
|
|
1987
|
+
{
|
|
1988
|
+
name: 'handles',
|
|
1989
|
+
description: 'List the resize handles the frame currently renders, with their boxes.',
|
|
1990
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1991
|
+
outputSchema: { type: 'object' },
|
|
1992
|
+
},
|
|
1993
|
+
{
|
|
1994
|
+
name: 'layout',
|
|
1995
|
+
description: 'Report the layout service state and the left navigation entries.',
|
|
1996
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1997
|
+
outputSchema: { type: 'object' },
|
|
1998
|
+
},
|
|
1999
|
+
{
|
|
2000
|
+
name: 'log',
|
|
2001
|
+
description: 'Read the debug log ring buffer; set enabled to switch logging on or off.',
|
|
2002
|
+
inputSchema: {
|
|
2003
|
+
type: 'object',
|
|
2004
|
+
properties: {
|
|
2005
|
+
enabled: { type: 'boolean', description: 'Switch the debug log on or off.' },
|
|
2006
|
+
clear: { type: 'boolean', description: 'Drop the buffered entries first.' },
|
|
2007
|
+
},
|
|
2008
|
+
additionalProperties: false,
|
|
2009
|
+
},
|
|
2010
|
+
outputSchema: { type: 'object' },
|
|
2011
|
+
},
|
|
2012
|
+
{
|
|
2013
|
+
name: 'locale',
|
|
2014
|
+
description: 'Report the active UI locale and the panel copy it currently resolves to.',
|
|
2015
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
2016
|
+
outputSchema: { type: 'object' },
|
|
2017
|
+
},
|
|
2018
|
+
{
|
|
2019
|
+
name: 'prefer',
|
|
2020
|
+
description: 'Hand the chat to a seat (main or right) and report the resulting host seat.',
|
|
2021
|
+
inputSchema: {
|
|
2022
|
+
type: 'object',
|
|
2023
|
+
properties: { seat: { type: 'string', description: 'main or right' } },
|
|
2024
|
+
additionalProperties: false,
|
|
2025
|
+
},
|
|
2026
|
+
outputSchema: { type: 'object' },
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
name: 'dom',
|
|
2030
|
+
description: 'Report which chat surfaces and guests are actually in the document right now.',
|
|
2031
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
2032
|
+
outputSchema: { type: 'object' },
|
|
2033
|
+
},
|
|
2034
|
+
{
|
|
2035
|
+
name: 'openRight',
|
|
2036
|
+
description: 'Open the right sidebar chat tab, then report the resulting host seat.',
|
|
2037
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
2038
|
+
outputSchema: { type: 'object' },
|
|
2039
|
+
},
|
|
2040
|
+
],
|
|
2041
|
+
},
|
|
2042
|
+
query(method, input) {
|
|
2043
|
+
if (method === 'locale') {
|
|
2044
|
+
const face = typeof ctx.locale.getLocale === 'function' ? ctx.locale.getLocale() : undefined
|
|
2045
|
+
const locales =
|
|
2046
|
+
face !== undefined && Array.isArray(face.locales) ? face.locales.map((entry) => entry.id) : []
|
|
2047
|
+
return {
|
|
2048
|
+
active: face !== undefined ? face.active : undefined,
|
|
2049
|
+
locales,
|
|
2050
|
+
htmlLang:
|
|
2051
|
+
typeof document !== 'undefined' &&
|
|
2052
|
+
document.documentElement !== null &&
|
|
2053
|
+
document.documentElement !== undefined
|
|
2054
|
+
? document.documentElement.lang
|
|
2055
|
+
: '',
|
|
2056
|
+
sample: translate('persistLabel'),
|
|
2057
|
+
zhSample: DICTS.zh.persistLabel,
|
|
2058
|
+
enSample: DICTS.en.persistLabel,
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (method === 'log') {
|
|
2062
|
+
const patch = input !== undefined && input !== null ? input : {}
|
|
2063
|
+
if (typeof patch.enabled === 'boolean') setLogging(patch.enabled)
|
|
2064
|
+
if (patch.clear === true) clearLog()
|
|
2065
|
+
return { enabled: logEnabled, entries: readLogEntries().slice(-50) }
|
|
2066
|
+
}
|
|
2067
|
+
if (method === 'layout') {
|
|
2068
|
+
const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
|
|
2069
|
+
const out = { service: layout !== undefined && layout !== null, keys: [], hasMain: undefined, activePanelId: undefined, nav: [] }
|
|
2070
|
+
if (out.service) {
|
|
2071
|
+
out.keys = Object.keys(layout)
|
|
2072
|
+
if (typeof layout.hasMainPanel === 'function') {
|
|
2073
|
+
try {
|
|
2074
|
+
out.hasMain = layout.hasMainPanel(PANEL_ID)
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
out.hasMain = 'throw: ' + messageOf(error)
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const read = (source, label) => {
|
|
2080
|
+
if (source === undefined || source === null) return
|
|
2081
|
+
try {
|
|
2082
|
+
let value = source
|
|
2083
|
+
if (typeof source.get === 'function') value = source.get()
|
|
2084
|
+
else if (typeof source.getState === 'function') value = source.getState()
|
|
2085
|
+
out[label] = value !== null && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
out[label] = 'throw: ' + messageOf(error)
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
read(layout.panelInfo, 'panelInfo')
|
|
2091
|
+
read(layout.navigation, 'navigation')
|
|
2092
|
+
const store = layout.panels
|
|
2093
|
+
if (store !== undefined && store !== null) {
|
|
2094
|
+
out.storeKeys = Object.keys(store)
|
|
2095
|
+
read(store, 'panelStore')
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
try {
|
|
2099
|
+
if (typeof document.querySelectorAll === 'function') {
|
|
2100
|
+
for (const node of document.querySelectorAll('[data-panel-id]')) {
|
|
2101
|
+
out.nav.push({ id: node.getAttribute('data-panel-id'), tag: node.tagName })
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
} catch (error) {
|
|
2105
|
+
out.navError = messageOf(error)
|
|
2106
|
+
}
|
|
2107
|
+
return out
|
|
2108
|
+
}
|
|
2109
|
+
if (method === 'dom') {
|
|
2110
|
+
const count = (selector) => {
|
|
2111
|
+
try {
|
|
2112
|
+
return typeof document.querySelectorAll === 'function' ? document.querySelectorAll(selector).length : -1
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
return -1
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return {
|
|
2118
|
+
inlineSurfaces: count('.dce-panel.dce-inline'),
|
|
2119
|
+
logToggles: count('[data-log-toggle]'),
|
|
2120
|
+
toolbarButtons: count('.dce-tab-actions .dce-icon-button'),
|
|
2121
|
+
logEnabled,
|
|
2122
|
+
persistence: persistenceState(),
|
|
2123
|
+
webviews: count('webview'),
|
|
2124
|
+
selectedPanel: (() => {
|
|
2125
|
+
const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
|
|
2126
|
+
if (layout === undefined || layout === null || typeof layout.getPanel !== 'function') return undefined
|
|
2127
|
+
try {
|
|
2128
|
+
return layout.getPanel()
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
return undefined
|
|
2131
|
+
}
|
|
2132
|
+
})(),
|
|
2133
|
+
hosts: { main: inlineHosts.main !== null, right: inlineHosts.right !== null },
|
|
2134
|
+
preferred: preferredSeat || 'none',
|
|
2135
|
+
seat: inlineSeat() || 'none',
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (method === 'openRight') {
|
|
2139
|
+
const sidebar = typeof ctx.get === 'function' ? ctx.get('sidebarRight') : undefined
|
|
2140
|
+
const report = { steps: [], expanded: undefined }
|
|
2141
|
+
if (sidebar === undefined || sidebar === null) {
|
|
2142
|
+
report.steps.push('no sidebarRight service')
|
|
2143
|
+
} else {
|
|
2144
|
+
try {
|
|
2145
|
+
report.expanded = typeof sidebar.isExpanded === 'function' ? sidebar.isExpanded() : undefined
|
|
2146
|
+
if (report.expanded !== true && typeof sidebar.toggleExpanded === 'function') {
|
|
2147
|
+
sidebar.toggleExpanded()
|
|
2148
|
+
report.steps.push('expanded')
|
|
2149
|
+
}
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
report.steps.push('expand: ' + messageOf(error))
|
|
2152
|
+
}
|
|
2153
|
+
try {
|
|
2154
|
+
if (typeof sidebar.openTab === 'function') sidebar.openTab(RIGHT_TAB_KIND, {})
|
|
2155
|
+
else report.steps.push('no openTab on the service')
|
|
2156
|
+
report.steps.push('openTab')
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
report.steps.push('openTab: ' + messageOf(error))
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return { ...report, requested: RIGHT_TAB_KIND, seat: inlineSeat() || 'none' }
|
|
2162
|
+
}
|
|
2163
|
+
if (method === 'prefer') {
|
|
2164
|
+
const seat = input !== undefined && input !== null && input.seat === 'right' ? 'right' : 'main'
|
|
2165
|
+
preferSeat(seat)
|
|
2166
|
+
return { seat: inlineSeat() || 'none', host: seatReportData().host }
|
|
2167
|
+
}
|
|
2168
|
+
if (method === 'handles') {
|
|
2169
|
+
const nodes = typeof document.querySelectorAll === 'function' ? document.querySelectorAll('[class*="andle"]') : []
|
|
2170
|
+
const seatsNow = { main: inlineHosts.main !== null, right: inlineHosts.right !== null }
|
|
2171
|
+
return {
|
|
2172
|
+
seats: seatsNow,
|
|
2173
|
+
handles: [...nodes].map((node) => ({
|
|
2174
|
+
cls: typeof node.className === 'string' ? node.className : '',
|
|
2175
|
+
side: typeof node.getAttribute === 'function' ? node.getAttribute('data-side') : null,
|
|
2176
|
+
box: (() => {
|
|
2177
|
+
const box = node.getBoundingClientRect()
|
|
2178
|
+
return {
|
|
2179
|
+
left: Math.round(box.left),
|
|
2180
|
+
top: Math.round(box.top),
|
|
2181
|
+
width: Math.round(box.width),
|
|
2182
|
+
height: Math.round(box.height),
|
|
2183
|
+
}
|
|
2184
|
+
})(),
|
|
2185
|
+
})),
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
if (method === 'dragProbe') {
|
|
2189
|
+
const seat = inlineSeat()
|
|
2190
|
+
const host = seat === undefined ? null : inlineHosts[seat]
|
|
2191
|
+
const divider =
|
|
2192
|
+
typeof document.querySelector === 'function'
|
|
2193
|
+
? document.querySelector('[data-side="rightbar"]') || document.querySelector('[data-dockkit-divider]')
|
|
2194
|
+
: null
|
|
2195
|
+
if (divider === null || divider === undefined) return { ok: false, reason: 'no divider in the document' }
|
|
2196
|
+
if (host === null || host === undefined) return { ok: false, reason: 'no inline host' }
|
|
2197
|
+
const dividerInfo = {
|
|
2198
|
+
cls: typeof divider.className === 'string' ? divider.className : '',
|
|
2199
|
+
side: typeof divider.getAttribute === 'function' ? divider.getAttribute('data-side') : null,
|
|
2200
|
+
}
|
|
2201
|
+
const box = divider.getBoundingClientRect()
|
|
2202
|
+
const x = box.left + box.width / 2
|
|
2203
|
+
const y = box.top + box.height / 2
|
|
2204
|
+
// The handle captures a real pointer; a synthetic one has no active
|
|
2205
|
+
// pointer to capture, so the capture calls are stubbed while the
|
|
2206
|
+
// probe runs and restored afterwards.
|
|
2207
|
+
const savedCapture = {
|
|
2208
|
+
set: divider.setPointerCapture,
|
|
2209
|
+
has: divider.hasPointerCapture,
|
|
2210
|
+
release: divider.releasePointerCapture,
|
|
2211
|
+
}
|
|
2212
|
+
try {
|
|
2213
|
+
divider.setPointerCapture = () => {}
|
|
2214
|
+
divider.hasPointerCapture = () => true
|
|
2215
|
+
divider.releasePointerCapture = () => {}
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
void error
|
|
2218
|
+
}
|
|
2219
|
+
const fire = (target, type, clientX) => {
|
|
2220
|
+
if (typeof PointerEvent !== 'function') return false
|
|
2221
|
+
try {
|
|
2222
|
+
target.dispatchEvent(
|
|
2223
|
+
new PointerEvent(type, {
|
|
2224
|
+
bubbles: true,
|
|
2225
|
+
cancelable: true,
|
|
2226
|
+
clientX,
|
|
2227
|
+
clientY: y,
|
|
2228
|
+
pointerId: 7,
|
|
2229
|
+
pointerType: 'mouse',
|
|
2230
|
+
isPrimary: true,
|
|
2231
|
+
buttons: type === 'pointerup' ? 0 : 1,
|
|
2232
|
+
}),
|
|
2233
|
+
)
|
|
2234
|
+
return true
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
return false
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const widthOf = () => Math.round(host.getBoundingClientRect().width)
|
|
2240
|
+
const dx = input !== undefined && input !== null && typeof input.dx === 'number' && Number.isFinite(input.dx) ? input.dx : -48
|
|
2241
|
+
const guest = typeof host.querySelector === 'function' ? host.querySelector('webview') : null
|
|
2242
|
+
const sweep = (distance, done) => {
|
|
2243
|
+
fire(divider, 'pointerdown', x)
|
|
2244
|
+
for (let step = 1; step <= 8; step += 1) fire(divider, 'pointermove', x + (distance * step) / 8)
|
|
2245
|
+
fire(divider, 'pointerup', x + distance)
|
|
2246
|
+
if (typeof setTimeout === 'function') setTimeout(done, 220)
|
|
2247
|
+
else done()
|
|
2248
|
+
}
|
|
2249
|
+
const before = widthOf()
|
|
2250
|
+
const guestWidth = () => {
|
|
2251
|
+
if (guest === null || guest === undefined) return undefined
|
|
2252
|
+
const box = guest.getBoundingClientRect()
|
|
2253
|
+
return Math.round(box.width)
|
|
2254
|
+
}
|
|
2255
|
+
const guestBefore = guestWidth()
|
|
2256
|
+
return new Promise((resolve) => {
|
|
2257
|
+
sweep(dx, () => {
|
|
2258
|
+
const dragged = widthOf()
|
|
2259
|
+
const guestDragged = guestWidth()
|
|
2260
|
+
const moved = dragged !== before
|
|
2261
|
+
// Put the column back where it started: the probe must not
|
|
2262
|
+
// leave the user's layout changed.
|
|
2263
|
+
sweep(-dx, () => {
|
|
2264
|
+
const restored = widthOf()
|
|
2265
|
+
const guestRestored = guestWidth()
|
|
2266
|
+
try {
|
|
2267
|
+
divider.setPointerCapture = savedCapture.set
|
|
2268
|
+
divider.hasPointerCapture = savedCapture.has
|
|
2269
|
+
divider.releasePointerCapture = savedCapture.release
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
void error
|
|
2272
|
+
}
|
|
2273
|
+
resolve({
|
|
2274
|
+
ok: moved,
|
|
2275
|
+
reason: moved
|
|
2276
|
+
? 'the inline surface followed a real divider drag'
|
|
2277
|
+
: 'the divider did not move (the handle ignored synthetic pointers)',
|
|
2278
|
+
divider: dividerInfo,
|
|
2279
|
+
before,
|
|
2280
|
+
dragged,
|
|
2281
|
+
restored,
|
|
2282
|
+
delta: dragged - before,
|
|
2283
|
+
guest: guest !== null && guest !== undefined,
|
|
2284
|
+
guestWidths: { before: guestBefore, dragged: guestDragged, restored: guestRestored },
|
|
2285
|
+
guestFollowed: guestBefore !== undefined && guestDragged !== undefined && guestDragged !== guestBefore,
|
|
2286
|
+
})
|
|
2287
|
+
})
|
|
2288
|
+
})
|
|
2289
|
+
})
|
|
2290
|
+
}
|
|
2291
|
+
if (method === 'seatReport') return { ...seatReportData(), selfTest: runSeatSelfTest(probeT) }
|
|
2292
|
+
if (method === 'openPanel') {
|
|
2293
|
+
// The left navigation entry is the center panel; open that surface
|
|
2294
|
+
// so a probe can verify the seat the user actually clicks.
|
|
2295
|
+
const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
|
|
2296
|
+
if (layout === undefined || layout === null || typeof layout.selectPanel !== 'function') {
|
|
2297
|
+
return { opened: false, reason: 'layout service is unavailable' }
|
|
2298
|
+
}
|
|
2299
|
+
layout.selectPanel(PANEL_ID)
|
|
2300
|
+
return { opened: true, panel: PANEL_ID }
|
|
2301
|
+
}
|
|
2302
|
+
throw new Error('unknown dsh-chat-embed inspect method "' + method + '"')
|
|
2303
|
+
},
|
|
2304
|
+
})
|
|
2305
|
+
} catch (error) {
|
|
2306
|
+
return undefined
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** @returns {string} the live seat geometry, shown in the diagnostics card. */
|
|
2311
|
+
function describeSeat(t) {
|
|
2312
|
+
const translate = typeof t === 'function' ? t : probeT
|
|
2313
|
+
const seat = inlineSeat() || 'none'
|
|
2314
|
+
const name = valueText(translate, SEAT_KEY, seat)
|
|
2315
|
+
const host = seat === 'none' ? null : inlineHosts[seat]
|
|
2316
|
+
if (host === null || host === undefined || typeof host.getBoundingClientRect !== 'function') {
|
|
2317
|
+
return translate('seatMissing', { seat: name })
|
|
2318
|
+
}
|
|
2319
|
+
return translate('seatDescribe', { seat: name, host: formatRect(host.getBoundingClientRect(), translate) })
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Whether the mounted guests use a persistent shell partition.
|
|
2324
|
+
*
|
|
2325
|
+
* The shell names the partition, so the only honest answer comes from the
|
|
2326
|
+
* partition a guest already received: `persist:` is the patched, disk-backed
|
|
2327
|
+
* case, anything else is memory-only. The answer is remembered so the
|
|
2328
|
+
* settings menu can show it right after a restart, before any guest mounts.
|
|
2329
|
+
*
|
|
2330
|
+
* @returns {'on' | 'off' | 'unknown'}
|
|
2331
|
+
*/
|
|
2332
|
+
function persistenceState() {
|
|
2333
|
+
for (const provider of PROVIDERS) {
|
|
2334
|
+
const record = guests.get(provider.id)
|
|
2335
|
+
if (record === undefined || typeof record.partition !== 'string' || record.partition === '') continue
|
|
2336
|
+
const state = record.partition.startsWith('persist:') ? 'on' : 'off'
|
|
2337
|
+
storePersistence(state)
|
|
2338
|
+
return state
|
|
2339
|
+
}
|
|
2340
|
+
try {
|
|
2341
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return 'unknown'
|
|
2342
|
+
const stored = localStorage.getItem(PERSIST_KEY)
|
|
2343
|
+
return stored === 'on' || stored === 'off' ? stored : 'unknown'
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
void error
|
|
2346
|
+
return 'unknown'
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** @param {'on' | 'off'} state */
|
|
2351
|
+
function storePersistence(state) {
|
|
2352
|
+
try {
|
|
2353
|
+
if (typeof localStorage === 'undefined' || localStorage === null) return
|
|
2354
|
+
localStorage.setItem(PERSIST_KEY, state)
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
void error
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/** @returns {{ state: string, detail: string, seats: any[] }} */
|
|
2361
|
+
function rightSidebarStatus() {
|
|
2362
|
+
return { state: rightTypeState.state, detail: rightTypeState.detail, seats: rightSeatAttempts.slice() }
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/** @returns {string} a one-line right-sidebar status for the diagnostics card. */
|
|
2366
|
+
function describeRightSidebar(t) {
|
|
2367
|
+
const status = rightSidebarStatus()
|
|
2368
|
+
const translate = typeof t === 'function' ? t : probeT
|
|
2369
|
+
const seats = status.seats
|
|
2370
|
+
.map((seat) => valueText(translate, RIGHT_SEAT_KEY, seat.label) + '=' + (seat.ok === true ? '✓' : '✗'))
|
|
2371
|
+
.join(translate('seatListSep'))
|
|
2372
|
+
const head =
|
|
2373
|
+
valueText(translate, RIGHT_STATE_KEY, status.state) +
|
|
2374
|
+
(status.detail === '' ? '' : translate('rightDetail', { detail: status.detail }))
|
|
2375
|
+
return seats === '' ? head : head + ' · ' + seats
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/**
|
|
2379
|
+
* Wire the optional right column: three seats plus the tab type.
|
|
2380
|
+
*
|
|
2381
|
+
* The slots are session-scoped and the registry belongs to another plugin,
|
|
2382
|
+
* so each step can be rejected or simply be absent at apply time. Every
|
|
2383
|
+
* attempt is isolated — the rest of the plugin must survive a failure here.
|
|
2384
|
+
*
|
|
2385
|
+
* @param {any} ctx
|
|
2386
|
+
* @param {(key: string) => string} t
|
|
2387
|
+
*/
|
|
2388
|
+
function installRightSidebar(ctx, t) {
|
|
2389
|
+
const attempt = (label, run) => {
|
|
2390
|
+
try {
|
|
2391
|
+
const dispose = run()
|
|
2392
|
+
rightSeatAttempts.push({ label, ok: true })
|
|
2393
|
+
if (typeof dispose === 'function') rightDisposers.push(dispose)
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
rightSeatAttempts.push({ label, ok: false, detail: messageOf(error) })
|
|
2396
|
+
log('warn', 'right sidebar step failed', { step: label, detail: messageOf(error) })
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (rightSeatsInstalled !== true) {
|
|
2401
|
+
rightSeatsInstalled = true
|
|
2402
|
+
attempt('body', () =>
|
|
2403
|
+
ctx.slots.inject(RIGHT_TAB_SLOT, () =>
|
|
2404
|
+
ctx.slots.register(
|
|
2405
|
+
{ name: RIGHT_TAB_SLOT, key: RIGHT_TAB_ID, locale: NS, registrant: 'dsh-chat-embed' },
|
|
2406
|
+
ChatRightBody,
|
|
2407
|
+
),
|
|
2408
|
+
),
|
|
2409
|
+
)
|
|
2410
|
+
attempt('title', () =>
|
|
2411
|
+
ctx.slots.inject(RIGHT_TAB_TITLE_SLOT, () =>
|
|
2412
|
+
ctx.slots.register(
|
|
2413
|
+
{ name: RIGHT_TAB_TITLE_SLOT, key: RIGHT_TAB_ID, locale: NS, registrant: 'dsh-chat-embed' },
|
|
2414
|
+
ChatRightTitle,
|
|
2415
|
+
),
|
|
2416
|
+
),
|
|
2417
|
+
)
|
|
2418
|
+
attempt('dock', () =>
|
|
2419
|
+
ctx.slots.inject(DOCK_SLOT, () =>
|
|
2420
|
+
ctx.slots.register(
|
|
2421
|
+
{ name: DOCK_SLOT, id: DOCK_ID, order: 20, locale: NS, registrant: 'dsh-chat-embed' },
|
|
2422
|
+
ChatDockButton,
|
|
2423
|
+
),
|
|
2424
|
+
),
|
|
2425
|
+
)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const connect = () => {
|
|
2429
|
+
if (rightTabTypeDispose !== undefined) return true
|
|
2430
|
+
const read = (name) => (typeof ctx.get === 'function' ? ctx.get(name) : undefined)
|
|
2431
|
+
const tabs = read('sidebarRightTabs')
|
|
2432
|
+
const controller = read('sidebarRight')
|
|
2433
|
+
if (tabs === undefined || tabs === null || typeof tabs.register !== 'function') {
|
|
2434
|
+
rightTypeState = { state: 'waiting', detail: 'sidebarRightTabs is not available yet' }
|
|
2435
|
+
return false
|
|
2436
|
+
}
|
|
2437
|
+
try {
|
|
2438
|
+
const dispose = tabs.register(rightTabType(t('rightTabTitle')))
|
|
2439
|
+
rightTabTypeDispose = typeof dispose === 'function' ? dispose : () => {}
|
|
2440
|
+
rightTypeState = { state: 'registered', detail: '' }
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
rightTypeState = { state: 'failed', detail: messageOf(error) }
|
|
2443
|
+
return false
|
|
2444
|
+
}
|
|
2445
|
+
openRightTabImpl =
|
|
2446
|
+
controller !== undefined && controller !== null && typeof controller.openTab === 'function'
|
|
2447
|
+
? () => {
|
|
2448
|
+
controller.openTab(RIGHT_TAB_KIND, {})
|
|
2449
|
+
}
|
|
2450
|
+
: () => {}
|
|
2451
|
+
return true
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
try {
|
|
2455
|
+
if (connect()) return
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
rightTypeState = { state: 'failed', detail: messageOf(error) }
|
|
2458
|
+
return
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// The right column may be composed after this plugin: retry briefly.
|
|
2462
|
+
if (typeof setTimeout !== 'function') return
|
|
2463
|
+
let left = 5
|
|
2464
|
+
const retry = () => {
|
|
2465
|
+
left -= 1
|
|
2466
|
+
let connected = false
|
|
2467
|
+
try {
|
|
2468
|
+
connected = connect()
|
|
2469
|
+
} catch (error) {
|
|
2470
|
+
rightTypeState = { state: 'failed', detail: messageOf(error) }
|
|
2471
|
+
return
|
|
2472
|
+
}
|
|
2473
|
+
if (!connected && left > 0) setTimeout(retry, 500)
|
|
2474
|
+
}
|
|
2475
|
+
setTimeout(retry, 400)
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* Create the active tab's guest once its host viewport exists.
|
|
2480
|
+
*
|
|
2481
|
+
* @returns {Promise<void>}
|
|
2482
|
+
*/
|
|
2483
|
+
function ensureActiveGuest() {
|
|
2484
|
+
const provider = activeProvider()
|
|
2485
|
+
const record = guestRecord(provider.id)
|
|
2486
|
+
// A guest whose element was detached with its seat is gone (Electron drops
|
|
2487
|
+
// the WebContents with the DOM), so a live element is the only reason to
|
|
2488
|
+
// skip the rebuild.
|
|
2489
|
+
const element = record.element
|
|
2490
|
+
if (element !== undefined && element !== null && element.isConnected !== false) return Promise.resolve()
|
|
2491
|
+
if (record.mounting === true) return Promise.resolve()
|
|
2492
|
+
// The host viewport inside the panel's layout is the only requirement.
|
|
2493
|
+
if (state.surface.host === null || state.surface.host === undefined) return Promise.resolve()
|
|
2494
|
+
return mountGuest(provider.id)
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* Switch tabs: remember the choice and make sure its guest exists. Other
|
|
2499
|
+
* tabs are untouched — their guests keep running in hidden layers.
|
|
2500
|
+
*
|
|
2501
|
+
* @param {string} id
|
|
2502
|
+
* @returns {Promise<void>}
|
|
2503
|
+
*/
|
|
2504
|
+
function setActive(id) {
|
|
2505
|
+
const provider = findProvider(id)
|
|
2506
|
+
if (provider === undefined) return Promise.resolve()
|
|
2507
|
+
if (!isEnabled(provider.id)) return Promise.resolve()
|
|
2508
|
+
if (state.active !== provider.id) {
|
|
2509
|
+
state.active = provider.id
|
|
2510
|
+
storeActive(provider.id)
|
|
2511
|
+
syncLayers()
|
|
2512
|
+
}
|
|
2513
|
+
notify()
|
|
2514
|
+
return ensureActiveGuest()
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// ------------------------------------------------------------------
|
|
2518
|
+
// Styles
|
|
2519
|
+
// ------------------------------------------------------------------
|
|
2520
|
+
|
|
2521
|
+
const STYLE_ID = 'dsh-chat-embed-styles'
|
|
2522
|
+
|
|
2523
|
+
// Theme values come from the host's own tokens so the panel follows the
|
|
2524
|
+
// active theme instead of asserting a palette of its own.
|
|
2525
|
+
const CSS = [
|
|
2526
|
+
'.dce-panel{position:relative;display:flex;flex:1 1 auto;flex-direction:column;min-width:0;min-height:0;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1f2329);overflow:hidden}',
|
|
2527
|
+
// Inline mode: the surface is a child of the panel's own layout box. Its
|
|
2528
|
+
// width and height are CSS facts, so dragging a splitter needs no JS.
|
|
2529
|
+
'.dce-inline{width:100%;height:100%;min-width:0;min-height:0}',
|
|
2530
|
+
'.dce-inline.dce-panel{flex:1 1 auto}',
|
|
2531
|
+
'.dce-inline>.dce-surface{position:absolute;inset:0}',
|
|
2532
|
+
'.dce-hotzone{position:absolute;top:0;left:0;right:0;height:10px;z-index:3}',
|
|
2533
|
+
'.dce-tabs{position:absolute;top:0;left:0;right:0;z-index:4;display:flex;align-items:stretch;padding:2px 4px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));background:var(--dsw-alias-bg-base,#fff);box-shadow:0 2px 10px rgba(0,0,0,.14);transform:translateY(-110%);opacity:0;pointer-events:none;transition:transform 140ms ease,opacity 140ms ease}',
|
|
2534
|
+
'.dce-tabs[data-visible="true"]{transform:translateY(0);opacity:1;pointer-events:auto}',
|
|
2535
|
+
'.dce-tab-list{display:flex;align-items:stretch;gap:2px;flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}',
|
|
2536
|
+
'.dce-tab-list::-webkit-scrollbar{display:none}',
|
|
2537
|
+
'.dce-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);font:inherit;font-size:12px;line-height:28px;padding:0 10px;cursor:pointer;white-space:nowrap;border-radius:6px 6px 0 0}',
|
|
2538
|
+
'.dce-tab:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
2539
|
+
'.dce-tab:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:-2px}',
|
|
2540
|
+
'.dce-tab[data-active="true"]{color:var(--dsw-alias-label-primary,#1f2329);border-bottom-color:var(--dsw-alias-button-primary-fill,#4c6ef5);font-weight:600}',
|
|
2541
|
+
'.dce-tab[data-failed="true"]{color:var(--dsw-alias-state-error-primary,#d33)}',
|
|
2542
|
+
'.dce-tab{cursor:pointer;user-select:none}',
|
|
2543
|
+
'.dce-tab[data-dragging="true"]{opacity:.55;cursor:grabbing}',
|
|
2544
|
+
'.dce-tab[data-drop="before"]{box-shadow:inset 2px 0 0 var(--dsw-alias-button-primary-fill,#4c6ef5)}',
|
|
2545
|
+
'.dce-tab[data-drop="after"]{box-shadow:inset -2px 0 0 var(--dsw-alias-button-primary-fill,#4c6ef5)}',
|
|
2546
|
+
'.dce-tab-actions{display:flex;align-items:center;gap:2px;flex:0 0 auto;padding:0 2px 2px}',
|
|
2547
|
+
'.dce-icon-button{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);cursor:pointer}',
|
|
2548
|
+
'.dce-icon-button[data-on="true"]{color:var(--dsw-alias-label-accent,#4c7dff);background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
|
|
2549
|
+
'.dce-icon-button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
2550
|
+
'.dce-icon-button:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:-2px}',
|
|
2551
|
+
'.dce-status-bar{position:absolute;top:0;left:0;right:0;z-index:2;display:flex;align-items:center;gap:8px;box-sizing:border-box;padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));background:var(--dsw-alias-bg-base,#fff);font-size:12px}',
|
|
2552
|
+
'.dce-dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#9aa0a6)}',
|
|
2553
|
+
'.dce-dot[data-phase="ready"]{background:var(--dsw-alias-state-success-primary,#2ea043)}',
|
|
2554
|
+
'.dce-dot[data-phase="failed"]{background:var(--dsw-alias-state-error-primary,#d33)}',
|
|
2555
|
+
'.dce-dot[data-phase="acquiring"],.dce-dot[data-phase="navigating"]{background:var(--dsw-alias-state-warn-primary,#c98a00);animation:dce-pulse 1.4s ease-in-out infinite}',
|
|
2556
|
+
'@keyframes dce-pulse{0%,100%{opacity:.35}50%{opacity:1}}',
|
|
2557
|
+
'.dce-status{color:var(--dsw-alias-label-secondary,#5f6368);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
2558
|
+
'.dce-diag{color:var(--dsw-alias-label-tertiary,#9aa0a6);white-space:nowrap;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px;overflow:hidden;text-overflow:ellipsis}',
|
|
2559
|
+
'.dce-spacer{flex:1 1 auto}',
|
|
2560
|
+
'.dce-button{appearance:none;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);border-radius:6px;font:inherit;font-size:12px;line-height:20px;padding:2px 10px;cursor:pointer}',
|
|
2561
|
+
'.dce-button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
2562
|
+
'.dce-button:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:2px}',
|
|
2563
|
+
'.dce-button-primary{border-color:currentColor;color:var(--dsw-alias-button-primary-fill,#4c6ef5)}',
|
|
2564
|
+
'.dce-surface{position:absolute;inset:0;display:flex;min-height:0}',
|
|
2565
|
+
'.dce-viewport{position:absolute;inset:0;display:flex;min-width:0;min-height:0}',
|
|
2566
|
+
'.dce-guest-layer{position:absolute;inset:0;display:none;min-width:0;min-height:0}',
|
|
2567
|
+
'.dce-guest-layer[data-active="true"]{display:flex}',
|
|
2568
|
+
'.dce-guest{display:inline-flex;flex:1 1 auto;width:100%;height:100%;border:0;background:transparent}',
|
|
2569
|
+
'.dce-card{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;gap:10px;box-sizing:border-box;width:auto;max-width:460px;height:max-content;margin:auto;padding:20px 22px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 12px 32px rgba(0,0,0,.12);font-size:13px}',
|
|
2570
|
+
'.dce-card-title{font-size:14px;font-weight:600}',
|
|
2571
|
+
'.dce-card-body{color:var(--dsw-alias-label-secondary,#5f6368);line-height:1.6}',
|
|
2572
|
+
'.dce-card-detail{font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa0a6);word-break:break-all}',
|
|
2573
|
+
'.dce-card-actions{display:flex;gap:8px;justify-content:flex-end}',
|
|
2574
|
+
'.dce-menu-scrim{position:absolute;inset:0;z-index:5}',
|
|
2575
|
+
'.dce-menu{position:absolute;top:34px;right:6px;z-index:6;min-width:196px;box-sizing:border-box;padding:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 12px 32px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:1px}',
|
|
2576
|
+
'.dce-menu-title{font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa0a6);padding:6px 8px 4px;letter-spacing:.04em}',
|
|
2577
|
+
'.dce-menu-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:5px 8px;border-radius:6px;font-size:13px;color:var(--dsw-alias-label-primary,#1f2329)}',
|
|
2578
|
+
'.dce-menu-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
2579
|
+
'.dce-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
2580
|
+
'.dce-menu-value{flex:0 0 auto;font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a6)}',
|
|
2581
|
+
'.dce-menu-sep{margin:5px 0 3px;border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24))}',
|
|
2582
|
+
'.dce-menu-item{display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:13px;text-align:left;cursor:pointer}',
|
|
2583
|
+
'.dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
2584
|
+
'.dce-right-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
2585
|
+
'.dce-right-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary,#19a15f);flex:0 0 auto}',
|
|
2586
|
+
'.dce-right-dot[data-phase="failed"]{background:var(--dsw-alias-state-error-primary,#d33)}',
|
|
2587
|
+
'.dce-right-dot[data-phase="acquiring"],.dce-right-dot[data-phase="navigating"]{background:var(--dsw-alias-state-warning-primary,#d9a514)}',
|
|
2588
|
+
'.dce-switch{appearance:none;border:0;padding:0;width:34px;height:20px;flex:0 0 auto;border-radius:10px;background:var(--dsw-alias-fill-3,rgba(127,127,127,.45));position:relative;cursor:pointer;transition:background 120ms ease}',
|
|
2589
|
+
'.dce-switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform 120ms ease}',
|
|
2590
|
+
'.dce-switch[data-on="true"]{background:var(--dsw-alias-button-primary-fill,#4c6ef5)}',
|
|
2591
|
+
'.dce-switch[data-on="true"]::after{transform:translateX(14px)}',
|
|
2592
|
+
'.dce-switch:disabled{opacity:.45;cursor:not-allowed}',
|
|
2593
|
+
].join('\n')
|
|
2594
|
+
|
|
2595
|
+
/** @returns {any} the plugin's stylesheet element. */
|
|
2596
|
+
function injectStyles() {
|
|
2597
|
+
const existing = document.getElementById(STYLE_ID)
|
|
2598
|
+
if (existing !== null && existing !== undefined) return existing
|
|
2599
|
+
const element = document.createElement('style')
|
|
2600
|
+
element.id = STYLE_ID
|
|
2601
|
+
element.textContent = CSS
|
|
2602
|
+
const parent = document.head !== undefined && document.head !== null ? document.head : document.body
|
|
2603
|
+
parent.appendChild(element)
|
|
2604
|
+
return element
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// ------------------------------------------------------------------
|
|
2608
|
+
// React components
|
|
2609
|
+
// ------------------------------------------------------------------
|
|
2610
|
+
|
|
2611
|
+
// Fall back to useEffect on a React build without layout effects.
|
|
2612
|
+
const useMountEffect = typeof React.useLayoutEffect === 'function' ? React.useLayoutEffect : React.useEffect
|
|
2613
|
+
|
|
2614
|
+
/**
|
|
2615
|
+
* The locale-bound translator installed by apply. Components render through
|
|
2616
|
+
* it, so the panel follows the DSH language setting; a locale/change event
|
|
2617
|
+
* re-renders them immediately, no reload needed.
|
|
2618
|
+
*
|
|
2619
|
+
* @type {((key: string, params?: Record<string, string | number>) => string) | undefined}
|
|
2620
|
+
*/
|
|
2621
|
+
let uiT
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* @returns {(key: string, params?: Record<string, string | number>) => string}
|
|
2625
|
+
*/
|
|
2626
|
+
function translator() {
|
|
2627
|
+
return (key, params) => {
|
|
2628
|
+
if (typeof uiT === 'function') {
|
|
2629
|
+
const value = uiT(key, params)
|
|
2630
|
+
if (typeof value === 'string' && value !== '' && value !== key) return value
|
|
2631
|
+
}
|
|
2632
|
+
const local = DICTS.zh[key]
|
|
2633
|
+
return local !== undefined ? fill(local, params) : key
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/** Re-render on any store change, then read module state directly. */
|
|
2638
|
+
function useStore() {
|
|
2639
|
+
const [, force] = React.useState(0)
|
|
2640
|
+
React.useEffect(() => {
|
|
2641
|
+
let alive = true
|
|
2642
|
+
const bump = () => {
|
|
2643
|
+
if (alive) force((value) => value + 1)
|
|
2644
|
+
}
|
|
2645
|
+
const unsubscribe = watch(bump)
|
|
2646
|
+
bump()
|
|
2647
|
+
return () => {
|
|
2648
|
+
alive = false
|
|
2649
|
+
unsubscribe()
|
|
2650
|
+
}
|
|
2651
|
+
}, [])
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* @param {string} title
|
|
2656
|
+
* @param {string} body
|
|
2657
|
+
* @param {(key: string) => string} t
|
|
2658
|
+
* @param {{ phase: string, detail: string }} snapshot
|
|
2659
|
+
* @param {() => void} [onRetry]
|
|
2660
|
+
*/
|
|
2661
|
+
function hintCard(title, body, t, snapshot, onRetry) {
|
|
2662
|
+
return h(
|
|
2663
|
+
'div',
|
|
2664
|
+
{ className: 'dce-card', role: 'status' },
|
|
2665
|
+
h('div', { className: 'dce-card-title' }, title),
|
|
2666
|
+
h('div', { className: 'dce-card-body' }, body),
|
|
2667
|
+
snapshot.detail === '' ? null : h('div', { className: 'dce-card-detail' }, snapshot.detail),
|
|
2668
|
+
h(
|
|
2669
|
+
'div',
|
|
2670
|
+
{ className: 'dce-card-actions' },
|
|
2671
|
+
onRetry === undefined
|
|
2672
|
+
? null
|
|
2673
|
+
: h('button', { type: 'button', className: 'dce-button dce-button-primary', onClick: onRetry }, t('retry')),
|
|
2674
|
+
h('button', { type: 'button', className: 'dce-button', onClick: openInBrowser }, t('openInBrowser')),
|
|
2675
|
+
),
|
|
2676
|
+
)
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/** Open the active service in the system browser. */
|
|
2680
|
+
function openInBrowser() {
|
|
2681
|
+
try {
|
|
2682
|
+
window.open(activeProvider().url, '_blank', 'noopener,noreferrer')
|
|
2683
|
+
} catch (error) {
|
|
2684
|
+
void error
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/** @returns {any} a refresh glyph for the tab strip. */
|
|
2689
|
+
function reloadIcon() {
|
|
2690
|
+
return h(
|
|
2691
|
+
'svg',
|
|
2692
|
+
{
|
|
2693
|
+
width: 14,
|
|
2694
|
+
height: 14,
|
|
2695
|
+
viewBox: '0 0 24 24',
|
|
2696
|
+
fill: 'none',
|
|
2697
|
+
stroke: 'currentColor',
|
|
2698
|
+
strokeWidth: 1.9,
|
|
2699
|
+
strokeLinecap: 'round',
|
|
2700
|
+
strokeLinejoin: 'round',
|
|
2701
|
+
'aria-hidden': true,
|
|
2702
|
+
focusable: 'false',
|
|
2703
|
+
},
|
|
2704
|
+
h('path', { d: 'M20 12a8 8 0 1 1-2.34-5.66' }),
|
|
2705
|
+
h('path', { d: 'M20 4.5V9h-4.5' }),
|
|
2706
|
+
)
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/** @returns {any} an external-link glyph for the tab strip. */
|
|
2710
|
+
function externalIcon() {
|
|
2711
|
+
return h(
|
|
2712
|
+
'svg',
|
|
2713
|
+
{
|
|
2714
|
+
width: 14,
|
|
2715
|
+
height: 14,
|
|
2716
|
+
viewBox: '0 0 24 24',
|
|
2717
|
+
fill: 'none',
|
|
2718
|
+
stroke: 'currentColor',
|
|
2719
|
+
strokeWidth: 1.9,
|
|
2720
|
+
strokeLinecap: 'round',
|
|
2721
|
+
strokeLinejoin: 'round',
|
|
2722
|
+
'aria-hidden': true,
|
|
2723
|
+
focusable: 'false',
|
|
2724
|
+
},
|
|
2725
|
+
h('path', { d: 'M13.5 4H20v6.5' }),
|
|
2726
|
+
h('path', { d: 'M20 4l-8.5 8.5' }),
|
|
2727
|
+
h('path', { d: 'M18.5 14.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V6.5A1.5 1.5 0 0 1 5 5h4.5' }),
|
|
2728
|
+
)
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/** @returns {any} an info glyph for the tab strip. */
|
|
2732
|
+
function infoIcon() {
|
|
2733
|
+
return h(
|
|
2734
|
+
'svg',
|
|
2735
|
+
{
|
|
2736
|
+
width: 14,
|
|
2737
|
+
height: 14,
|
|
2738
|
+
viewBox: '0 0 24 24',
|
|
2739
|
+
fill: 'none',
|
|
2740
|
+
stroke: 'currentColor',
|
|
2741
|
+
strokeWidth: 1.9,
|
|
2742
|
+
strokeLinecap: 'round',
|
|
2743
|
+
strokeLinejoin: 'round',
|
|
2744
|
+
'aria-hidden': true,
|
|
2745
|
+
focusable: 'false',
|
|
2746
|
+
},
|
|
2747
|
+
h('circle', { cx: 12, cy: 12, r: 8.5 }),
|
|
2748
|
+
h('path', { d: 'M12 11.2v5' }),
|
|
2749
|
+
h('path', { d: 'M12 7.8v.6' }),
|
|
2750
|
+
)
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function dockIcon() {
|
|
2754
|
+
return h(
|
|
2755
|
+
'svg',
|
|
2756
|
+
{
|
|
2757
|
+
width: 14,
|
|
2758
|
+
height: 14,
|
|
2759
|
+
viewBox: '0 0 24 24',
|
|
2760
|
+
fill: 'none',
|
|
2761
|
+
stroke: 'currentColor',
|
|
2762
|
+
strokeWidth: 1.9,
|
|
2763
|
+
strokeLinecap: 'round',
|
|
2764
|
+
strokeLinejoin: 'round',
|
|
2765
|
+
'aria-hidden': true,
|
|
2766
|
+
focusable: 'false',
|
|
2767
|
+
},
|
|
2768
|
+
h('rect', { x: 3, y: 4.5, width: 18, height: 15, rx: 3 }),
|
|
2769
|
+
h('path', { d: 'M15 4.5v15' }),
|
|
2770
|
+
)
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
function settingsIcon() { return h(
|
|
2774
|
+
'svg',
|
|
2775
|
+
{
|
|
2776
|
+
width: 14,
|
|
2777
|
+
height: 14,
|
|
2778
|
+
viewBox: '0 0 24 24',
|
|
2779
|
+
fill: 'none',
|
|
2780
|
+
stroke: 'currentColor',
|
|
2781
|
+
strokeWidth: 1.9,
|
|
2782
|
+
strokeLinecap: 'round',
|
|
2783
|
+
strokeLinejoin: 'round',
|
|
2784
|
+
'aria-hidden': true,
|
|
2785
|
+
focusable: 'false',
|
|
2786
|
+
},
|
|
2787
|
+
h('circle', { cx: 12, cy: 12, r: 3 }),
|
|
2788
|
+
h('path', {
|
|
2789
|
+
d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z',
|
|
2790
|
+
}),
|
|
2791
|
+
)
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
/**
|
|
2797
|
+
* The right sidebar tab body. `keepMounted` keeps it alive while another tab
|
|
2798
|
+
* is selected, so the guest survives a tab switch; the shell hides bodies it
|
|
2799
|
+
* is not showing, and a hidden `<webview>` keeps its guest running.
|
|
2800
|
+
*/
|
|
2801
|
+
function ChatRightBody(props) {
|
|
2802
|
+
const active = props === undefined || props === null || props.active !== false
|
|
2803
|
+
return h(ChatSurface, { seat: 'right', active })
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* The left-navigation page: the chat itself, hosted by the center panel.
|
|
2808
|
+
*
|
|
2809
|
+
* The center panel is torn down whenever another panel is selected, so this
|
|
2810
|
+
* seat is suitable while the user keeps the chat open; the right tab (below)
|
|
2811
|
+
* is the seat that survives panel switches.
|
|
2812
|
+
*/
|
|
2813
|
+
function ChatCenterPage() {
|
|
2814
|
+
return h(ChatSurface, { seat: 'main' })
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/** The right sidebar tab's chip title. */
|
|
2818
|
+
function ChatRightTitle() {
|
|
2819
|
+
useStore()
|
|
2820
|
+
const provider = activeProvider()
|
|
2821
|
+
const phase = getStatus().phase
|
|
2822
|
+
return h(
|
|
2823
|
+
'span',
|
|
2824
|
+
{ className: 'dce-right-title' },
|
|
2825
|
+
h('span', { className: 'dce-right-dot', 'data-phase': phase, 'aria-hidden': 'true' }),
|
|
2826
|
+
h('span', null, provider.label),
|
|
2827
|
+
)
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/** Session-header control that opens (and focuses) the right sidebar tab. */
|
|
2831
|
+
function ChatDockButton() {
|
|
2832
|
+
const t = translator({})
|
|
2833
|
+
return h(
|
|
2834
|
+
'button',
|
|
2835
|
+
{
|
|
2836
|
+
type: 'button',
|
|
2837
|
+
className: 'dce-icon-button',
|
|
2838
|
+
title: t('dockTitle'),
|
|
2839
|
+
'aria-label': t('dockTitle'),
|
|
2840
|
+
onClick: () => {
|
|
2841
|
+
openRightTab()
|
|
2842
|
+
},
|
|
2843
|
+
},
|
|
2844
|
+
dockIcon(),
|
|
2845
|
+
)
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* One seat's chat surface: tab strip, toolbar, status line and the guest
|
|
2850
|
+
* viewport. It renders inline, so the seat's own layout box sizes it.
|
|
2851
|
+
*/
|
|
2852
|
+
function ChatSurface(props) {
|
|
2853
|
+
const t = translator(props)
|
|
2854
|
+
useStore()
|
|
2855
|
+
const snapshot = getStatus()
|
|
2856
|
+
|
|
2857
|
+
const provider = activeProvider()
|
|
2858
|
+
const hostRef = React.useRef(null)
|
|
2859
|
+
const listRef = React.useRef(null)
|
|
2860
|
+
/** @type {React.MutableRefObject<{ id: string, startX: number, active: boolean } | undefined>} */
|
|
2861
|
+
const dragRef = React.useRef(undefined)
|
|
2862
|
+
/** @type {React.MutableRefObject<{ id: string, side: string } | undefined>} */
|
|
2863
|
+
const dropRef = React.useRef(undefined)
|
|
2864
|
+
const suppressClickRef = React.useRef(false)
|
|
2865
|
+
const [, bumpDrag] = React.useState(0)
|
|
2866
|
+
const repaintDrag = () => bumpDrag((value) => value + 1)
|
|
2867
|
+
|
|
2868
|
+
const seatName = props !== undefined && props !== null && props.seat === 'right' ? 'right' : 'main'
|
|
2869
|
+
|
|
2870
|
+
// Mounting a seat is the user opening the chat there: only one of the two
|
|
2871
|
+
// seats can exist at a time (the right sidebar needs a Session surface),
|
|
2872
|
+
// so the seat that just rendered is the one that owns the guests.
|
|
2873
|
+
useMountEffect(() => {
|
|
2874
|
+
const visible = seatName === 'main' || props.active !== false
|
|
2875
|
+
if (visible) preferSeat(seatName)
|
|
2876
|
+
return undefined
|
|
2877
|
+
}, [])
|
|
2878
|
+
|
|
2879
|
+
// `preferredSeat === seatName` must count as hosting even before this seat
|
|
2880
|
+
// is registered: the registration effect only runs once the viewport is
|
|
2881
|
+
// committed, so requiring a registered host first would deadlock.
|
|
2882
|
+
const hostingSeat = inlineSeat()
|
|
2883
|
+
const hosting =
|
|
2884
|
+
hostingSeat === undefined ||
|
|
2885
|
+
hostingSeat === seatName ||
|
|
2886
|
+
preferredSeat === seatName ||
|
|
2887
|
+
// A selected right tab is a user request too: it must render its
|
|
2888
|
+
// viewport so it can register and take the guests over.
|
|
2889
|
+
(seatName === 'right' && props.active === true)
|
|
2890
|
+
React.useEffect(() => {
|
|
2891
|
+
const node = hostRef.current
|
|
2892
|
+
if (hosting !== true || node === null || node === undefined) return undefined
|
|
2893
|
+
inlineHosts[seatName] = node
|
|
2894
|
+
if (preferredSeat === undefined) preferredSeat = seatName
|
|
2895
|
+
resolveInlineHost()
|
|
2896
|
+
notify()
|
|
2897
|
+
// A collapsed right tab is 0×0 and must not steal the page; when the user
|
|
2898
|
+
// expands the column the tab gets a box, and that is the moment to hand
|
|
2899
|
+
// the guests over. The observer only re-runs the seat choice.
|
|
2900
|
+
let ro
|
|
2901
|
+
if (typeof ResizeObserver === 'function' && preferredSeat === seatName) {
|
|
2902
|
+
ro = new ResizeObserver(() => {
|
|
2903
|
+
if (preferredSeat === seatName) resolveInlineHost()
|
|
2904
|
+
})
|
|
2905
|
+
ro.observe(node)
|
|
2906
|
+
}
|
|
2907
|
+
return () => {
|
|
2908
|
+
if (ro !== undefined) ro.disconnect()
|
|
2909
|
+
if (inlineHosts[seatName] === node) inlineHosts[seatName] = null
|
|
2910
|
+
if (preferredSeat === seatName) preferredSeat = undefined
|
|
2911
|
+
resolveInlineHost()
|
|
2912
|
+
notify()
|
|
2913
|
+
}
|
|
2914
|
+
}, [hosting])
|
|
2915
|
+
|
|
2916
|
+
// Selecting the right tab hands the chat to that column, so the panel the
|
|
2917
|
+
// user is actually looking at is the one that shows it.
|
|
2918
|
+
React.useEffect(() => {
|
|
2919
|
+
if (seatName !== 'right' || props.active === false) return undefined
|
|
2920
|
+
if (preferredSeat !== 'right') preferSeat('right')
|
|
2921
|
+
return undefined
|
|
2922
|
+
}, [props.active])
|
|
2923
|
+
|
|
2924
|
+
// Pointer-driven tab reordering. Dragging is armed on pointerdown and
|
|
2925
|
+
// only starts after a small threshold, so a plain click still selects.
|
|
2926
|
+
useMountEffect(() => {
|
|
2927
|
+
const findTab = (x, y) => {
|
|
2928
|
+
const element = typeof document.elementFromPoint === 'function' ? document.elementFromPoint(x, y) : null
|
|
2929
|
+
if (element === null || element === undefined) return null
|
|
2930
|
+
return typeof element.closest === 'function' ? element.closest('[data-tab-id]') : null
|
|
2931
|
+
}
|
|
2932
|
+
const onMove = (event) => {
|
|
2933
|
+
const drag = dragRef.current
|
|
2934
|
+
if (drag === undefined) return
|
|
2935
|
+
if (drag.active !== true) {
|
|
2936
|
+
if (Math.abs(event.clientX - drag.startX) < 6) return
|
|
2937
|
+
drag.active = true
|
|
2938
|
+
}
|
|
2939
|
+
const tab = findTab(event.clientX, event.clientY)
|
|
2940
|
+
let next
|
|
2941
|
+
if (tab !== null) {
|
|
2942
|
+
const rect = tab.getBoundingClientRect()
|
|
2943
|
+
next = {
|
|
2944
|
+
id: tab.getAttribute('data-tab-id'),
|
|
2945
|
+
side: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after',
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
const current = dropRef.current
|
|
2949
|
+
const unchanged =
|
|
2950
|
+
current === next ||
|
|
2951
|
+
(current !== undefined &&
|
|
2952
|
+
next !== undefined &&
|
|
2953
|
+
current.id === next.id &&
|
|
2954
|
+
current.side === next.side)
|
|
2955
|
+
if (unchanged) return
|
|
2956
|
+
dropRef.current = next
|
|
2957
|
+
repaintDrag()
|
|
2958
|
+
}
|
|
2959
|
+
const onUp = () => {
|
|
2960
|
+
const drag = dragRef.current
|
|
2961
|
+
const drop = dropRef.current
|
|
2962
|
+
dragRef.current = undefined
|
|
2963
|
+
dropRef.current = undefined
|
|
2964
|
+
repaintDrag()
|
|
2965
|
+
if (drag === undefined || drag.active !== true) return
|
|
2966
|
+
// Swallow the click that follows a drag so it does not also switch tabs.
|
|
2967
|
+
suppressClickRef.current = true
|
|
2968
|
+
if (drop === undefined || drop.id === drag.id) return
|
|
2969
|
+
const targetIndex = tabOrder.indexOf(drop.id)
|
|
2970
|
+
const fromIndex = tabOrder.indexOf(drag.id)
|
|
2971
|
+
if (targetIndex === -1 || fromIndex === -1) return
|
|
2972
|
+
let insertAt = drop.side === 'after' ? targetIndex + 1 : targetIndex
|
|
2973
|
+
if (fromIndex < insertAt) insertAt -= 1
|
|
2974
|
+
moveProvider(drag.id, insertAt)
|
|
2975
|
+
}
|
|
2976
|
+
window.addEventListener('pointermove', onMove)
|
|
2977
|
+
window.addEventListener('pointerup', onUp)
|
|
2978
|
+
window.addEventListener('pointercancel', onUp)
|
|
2979
|
+
return () => {
|
|
2980
|
+
window.removeEventListener('pointermove', onMove)
|
|
2981
|
+
window.removeEventListener('pointerup', onUp)
|
|
2982
|
+
window.removeEventListener('pointercancel', onUp)
|
|
2983
|
+
}
|
|
2984
|
+
}, [])
|
|
2985
|
+
|
|
2986
|
+
const beginTabDrag = (id, event) => {
|
|
2987
|
+
if (event.button !== 0) return
|
|
2988
|
+
dragRef.current = { id, startX: event.clientX, active: false }
|
|
2989
|
+
dropRef.current = undefined
|
|
2990
|
+
}
|
|
2991
|
+
const onTabWheel = (event) => {
|
|
2992
|
+
const list = event.currentTarget
|
|
2993
|
+
if (list === null || list === undefined) return
|
|
2994
|
+
if (list.scrollWidth <= list.clientWidth) return
|
|
2995
|
+
if (event.deltaY === 0) return
|
|
2996
|
+
list.scrollLeft += event.deltaY
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// Auto-hiding tab bar: hidden until the pointer reaches the top hot zone.
|
|
3000
|
+
const [barOpen, setBarOpen] = React.useState(false)
|
|
3001
|
+
const [menuOpen, setMenuOpen] = React.useState(false)
|
|
3002
|
+
const [copyState, setCopyState] = React.useState('')
|
|
3003
|
+
const barTimerRef = React.useRef(0)
|
|
3004
|
+
const cancelBarTimer = () => {
|
|
3005
|
+
if (barTimerRef.current !== 0) {
|
|
3006
|
+
clearTimeout(barTimerRef.current)
|
|
3007
|
+
barTimerRef.current = 0
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
const openBar = () => {
|
|
3011
|
+
cancelBarTimer()
|
|
3012
|
+
setBarOpen(true)
|
|
3013
|
+
}
|
|
3014
|
+
const closeBarNow = () => {
|
|
3015
|
+
cancelBarTimer()
|
|
3016
|
+
setBarOpen(false)
|
|
3017
|
+
}
|
|
3018
|
+
const scheduleCloseBar = () => {
|
|
3019
|
+
cancelBarTimer()
|
|
3020
|
+
barTimerRef.current = window.setTimeout(() => {
|
|
3021
|
+
barTimerRef.current = 0
|
|
3022
|
+
const drag = dragRef.current
|
|
3023
|
+
if (drag !== undefined && drag.active === true) {
|
|
3024
|
+
// Never yank the bar away while a tab is being dragged.
|
|
3025
|
+
scheduleCloseBar()
|
|
3026
|
+
return
|
|
3027
|
+
}
|
|
3028
|
+
setBarOpen(false)
|
|
3029
|
+
}, 400)
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
const copyDiagnostics = () => {
|
|
3033
|
+
const report = formatDiagReport({
|
|
3034
|
+
labels: {
|
|
3035
|
+
diagTitle: t('diagTitle'),
|
|
3036
|
+
diagUrl: t('diagUrl'),
|
|
3037
|
+
diagPhase: t('diagPhase'),
|
|
3038
|
+
partitionLabel: t('partitionLabel'),
|
|
3039
|
+
diagEvent: t('diagEvent'),
|
|
3040
|
+
diagHandoff: t('diagHandoff'),
|
|
3041
|
+
diagRight: t('diagRight'),
|
|
3042
|
+
diagSeat: t('diagSeat'),
|
|
3043
|
+
selfTest: t('selfTest'),
|
|
3044
|
+
},
|
|
3045
|
+
right: describeRightSidebar(t),
|
|
3046
|
+
seat: describeSeat(t),
|
|
3047
|
+
selfTest: record.selfTest,
|
|
3048
|
+
url: record.lastUrl === '' ? guestUrl(record.element) || '' : record.lastUrl,
|
|
3049
|
+
phase: phaseText(t, snapshot.phase),
|
|
3050
|
+
detail: snapshot.detail,
|
|
3051
|
+
partition: shortId(record.partition),
|
|
3052
|
+
mounts: record.mounts,
|
|
3053
|
+
lastEvent: record.lastEvent === '' ? '' : valueText(t, EVENT_KEY, record.lastEvent),
|
|
3054
|
+
handoff: record.handoff,
|
|
3055
|
+
build: BUILD,
|
|
3056
|
+
})
|
|
3057
|
+
void copyText(report).then((ok) => {
|
|
3058
|
+
setCopyState(ok ? 'copied' : 'failed')
|
|
3059
|
+
window.setTimeout(() => {
|
|
3060
|
+
setCopyState('')
|
|
3061
|
+
}, 1600)
|
|
3062
|
+
})
|
|
3063
|
+
}
|
|
3064
|
+
const onReload = () => {
|
|
3065
|
+
void reloadGuest(provider.id)
|
|
3066
|
+
}
|
|
3067
|
+
const record = guestRecord(provider.id)
|
|
3068
|
+
|
|
3069
|
+
// The status strip only earns its space while something is happening or
|
|
3070
|
+
// wrong; a healthy tab shows nothing but its tabs.
|
|
3071
|
+
const showStatus = snapshot.phase !== PHASE.READY && snapshot.phase !== PHASE.IDLE
|
|
3072
|
+
// A problem keeps the bar reachable without hunting for it.
|
|
3073
|
+
const barVisible =
|
|
3074
|
+
barOpen ||
|
|
3075
|
+
menuOpen ||
|
|
3076
|
+
record.diagOpen ||
|
|
3077
|
+
record.oauthBlocked ||
|
|
3078
|
+
snapshot.phase === PHASE.FAILED
|
|
3079
|
+
const reloadTitle =
|
|
3080
|
+
t('reload') +
|
|
3081
|
+
(record.partition === undefined
|
|
3082
|
+
? ''
|
|
3083
|
+
: ' · ' + t('partitionLabel') + ' ' + shortId(record.partition) + ' · #' + record.mounts)
|
|
3084
|
+
|
|
3085
|
+
return h(
|
|
3086
|
+
'div',
|
|
3087
|
+
{
|
|
3088
|
+
className: 'dce-panel dce-inline',
|
|
3089
|
+
'data-visible': 'true',
|
|
3090
|
+
'data-mode': 'inline',
|
|
3091
|
+
'data-seat': seatName,
|
|
3092
|
+
'data-build': BUILD,
|
|
3093
|
+
'aria-hidden': 'false',
|
|
3094
|
+
},
|
|
3095
|
+
h(
|
|
3096
|
+
'div',
|
|
3097
|
+
{ className: 'dce-panel', onPointerLeave: closeBarNow },
|
|
3098
|
+
h('div', { className: 'dce-hotzone', onPointerEnter: openBar, 'aria-hidden': 'true' }),
|
|
3099
|
+
h(
|
|
3100
|
+
'div',
|
|
3101
|
+
{
|
|
3102
|
+
className: 'dce-tabs',
|
|
3103
|
+
'data-visible': barVisible ? 'true' : 'false',
|
|
3104
|
+
onPointerEnter: cancelBarTimer,
|
|
3105
|
+
onPointerLeave: scheduleCloseBar,
|
|
3106
|
+
},
|
|
3107
|
+
h(
|
|
3108
|
+
'div',
|
|
3109
|
+
{
|
|
3110
|
+
className: 'dce-tab-list',
|
|
3111
|
+
role: 'tablist',
|
|
3112
|
+
'aria-label': 'AI chat services',
|
|
3113
|
+
ref: listRef,
|
|
3114
|
+
onWheel: onTabWheel,
|
|
3115
|
+
},
|
|
3116
|
+
orderedProviders().map((entry) =>
|
|
3117
|
+
h(
|
|
3118
|
+
'button',
|
|
3119
|
+
{
|
|
3120
|
+
key: entry.id,
|
|
3121
|
+
type: 'button',
|
|
3122
|
+
role: 'tab',
|
|
3123
|
+
className: 'dce-tab',
|
|
3124
|
+
'data-tab-id': entry.id,
|
|
3125
|
+
'data-active': entry.id === provider.id ? 'true' : 'false',
|
|
3126
|
+
'data-failed': guestRecord(entry.id).phase === PHASE.FAILED ? 'true' : 'false',
|
|
3127
|
+
'data-dragging':
|
|
3128
|
+
dragRef.current !== undefined &&
|
|
3129
|
+
dragRef.current.active === true &&
|
|
3130
|
+
dragRef.current.id === entry.id
|
|
3131
|
+
? 'true'
|
|
3132
|
+
: 'false',
|
|
3133
|
+
'data-drop':
|
|
3134
|
+
dropRef.current !== undefined && dropRef.current.id === entry.id
|
|
3135
|
+
? dropRef.current.side
|
|
3136
|
+
: 'none',
|
|
3137
|
+
'aria-selected': entry.id === provider.id ? 'true' : 'false',
|
|
3138
|
+
title: entry.url + ' · ' + t('dragHint'),
|
|
3139
|
+
onPointerDown: (event) => beginTabDrag(entry.id, event),
|
|
3140
|
+
onClick: () => {
|
|
3141
|
+
if (suppressClickRef.current === true) {
|
|
3142
|
+
suppressClickRef.current = false
|
|
3143
|
+
return
|
|
3144
|
+
}
|
|
3145
|
+
void setActive(entry.id)
|
|
3146
|
+
},
|
|
3147
|
+
},
|
|
3148
|
+
entry.label,
|
|
3149
|
+
),
|
|
3150
|
+
),
|
|
3151
|
+
),
|
|
3152
|
+
h(
|
|
3153
|
+
'div',
|
|
3154
|
+
{ className: 'dce-tab-actions' },
|
|
3155
|
+
h(
|
|
3156
|
+
'button',
|
|
3157
|
+
{
|
|
3158
|
+
type: 'button',
|
|
3159
|
+
className: 'dce-icon-button',
|
|
3160
|
+
'aria-label': t('reload'),
|
|
3161
|
+
title: reloadTitle,
|
|
3162
|
+
onClick: onReload,
|
|
3163
|
+
},
|
|
3164
|
+
reloadIcon(),
|
|
3165
|
+
),
|
|
3166
|
+
h(
|
|
3167
|
+
'button',
|
|
3168
|
+
{
|
|
3169
|
+
type: 'button',
|
|
3170
|
+
className: 'dce-icon-button',
|
|
3171
|
+
'aria-label': t('openInBrowser'),
|
|
3172
|
+
title: t('openInBrowser'),
|
|
3173
|
+
onClick: openInBrowser,
|
|
3174
|
+
},
|
|
3175
|
+
externalIcon(),
|
|
3176
|
+
),
|
|
3177
|
+
|
|
3178
|
+
|
|
3179
|
+
h(
|
|
3180
|
+
'button',
|
|
3181
|
+
{
|
|
3182
|
+
type: 'button',
|
|
3183
|
+
className: 'dce-icon-button',
|
|
3184
|
+
'aria-label': t('settings'),
|
|
3185
|
+
title: t('settings'),
|
|
3186
|
+
'aria-expanded': menuOpen ? 'true' : 'false',
|
|
3187
|
+
onClick: () => {
|
|
3188
|
+
setMenuOpen((value) => !value)
|
|
3189
|
+
openBar()
|
|
3190
|
+
},
|
|
3191
|
+
},
|
|
3192
|
+
settingsIcon(),
|
|
3193
|
+
),
|
|
3194
|
+
),
|
|
3195
|
+
),
|
|
3196
|
+
menuOpen
|
|
3197
|
+
? h('div', {
|
|
3198
|
+
className: 'dce-menu-scrim',
|
|
3199
|
+
onPointerDown: () => {
|
|
3200
|
+
setMenuOpen(false)
|
|
3201
|
+
},
|
|
3202
|
+
})
|
|
3203
|
+
: null,
|
|
3204
|
+
menuOpen
|
|
3205
|
+
? h(
|
|
3206
|
+
'div',
|
|
3207
|
+
{ className: 'dce-menu', role: 'menu' },
|
|
3208
|
+
h('div', { className: 'dce-menu-title' }, t('menuTitle')),
|
|
3209
|
+
providersInOrder().map((entry) => {
|
|
3210
|
+
const on = isEnabled(entry.id)
|
|
3211
|
+
const lastOne = on && enabledIds.length <= 1
|
|
3212
|
+
return h(
|
|
3213
|
+
'div',
|
|
3214
|
+
{ key: entry.id, className: 'dce-menu-row' },
|
|
3215
|
+
h('span', { className: 'dce-menu-label' }, entry.label),
|
|
3216
|
+
h('button', {
|
|
3217
|
+
type: 'button',
|
|
3218
|
+
role: 'switch',
|
|
3219
|
+
className: 'dce-switch',
|
|
3220
|
+
'data-on': on ? 'true' : 'false',
|
|
3221
|
+
'aria-checked': on ? 'true' : 'false',
|
|
3222
|
+
'aria-label': entry.label,
|
|
3223
|
+
title: lastOne ? t('lastTabHint') : entry.label,
|
|
3224
|
+
disabled: lastOne,
|
|
3225
|
+
onClick: () => {
|
|
3226
|
+
setProviderEnabled(entry.id, !on)
|
|
3227
|
+
},
|
|
3228
|
+
}),
|
|
3229
|
+
)
|
|
3230
|
+
}),
|
|
3231
|
+
h('div', { className: 'dce-menu-sep', 'aria-hidden': 'true' }),
|
|
3232
|
+
h(
|
|
3233
|
+
'div',
|
|
3234
|
+
{ className: 'dce-menu-row' },
|
|
3235
|
+
h('span', { className: 'dce-menu-label' }, t('persistLabel')),
|
|
3236
|
+
h(
|
|
3237
|
+
'span',
|
|
3238
|
+
{ className: 'dce-menu-value', title: t('persistHint') },
|
|
3239
|
+
persistenceState() === 'on'
|
|
3240
|
+
? t('persistOn')
|
|
3241
|
+
: persistenceState() === 'off'
|
|
3242
|
+
? t('persistOff')
|
|
3243
|
+
: t('persistUnknown'),
|
|
3244
|
+
),
|
|
3245
|
+
),
|
|
3246
|
+
persistenceState() === 'off'
|
|
3247
|
+
? h(
|
|
3248
|
+
'button',
|
|
3249
|
+
{
|
|
3250
|
+
type: 'button',
|
|
3251
|
+
className: 'dce-menu-item',
|
|
3252
|
+
onClick: () => {
|
|
3253
|
+
void copyText(
|
|
3254
|
+
'跨重启保持登录需要可选的桌面壳补丁(在插件包目录或仓库根目录执行):\n node tools/shell-patch/patch-asar.cjs build\n关闭 DSH 后替换 resources\\app.asar,watchdog 会在退出时自动完成。\n详见 README「已知限制 §1」。',
|
|
3255
|
+
)
|
|
3256
|
+
},
|
|
3257
|
+
},
|
|
3258
|
+
h('span', { className: 'dce-menu-label' }, t('persistCopy')),
|
|
3259
|
+
)
|
|
3260
|
+
: null,
|
|
3261
|
+
h(
|
|
3262
|
+
'button',
|
|
3263
|
+
{
|
|
3264
|
+
type: 'button',
|
|
3265
|
+
className: 'dce-menu-item',
|
|
3266
|
+
'aria-label': t('diagTitle'),
|
|
3267
|
+
onClick: () => {
|
|
3268
|
+
setMenuOpen(false)
|
|
3269
|
+
record.diagOpen = !record.diagOpen
|
|
3270
|
+
notify()
|
|
3271
|
+
},
|
|
3272
|
+
},
|
|
3273
|
+
infoIcon(),
|
|
3274
|
+
h('span', { className: 'dce-menu-label' }, t('diagTitle') + '…'),
|
|
3275
|
+
),
|
|
3276
|
+
h(
|
|
3277
|
+
'div',
|
|
3278
|
+
{ className: 'dce-menu-row' },
|
|
3279
|
+
h('span', { className: 'dce-menu-label' }, t('logging')),
|
|
3280
|
+
h(
|
|
3281
|
+
'button',
|
|
3282
|
+
{
|
|
3283
|
+
type: 'button',
|
|
3284
|
+
role: 'switch',
|
|
3285
|
+
className: 'dce-switch',
|
|
3286
|
+
'data-on': logEnabled ? 'true' : 'false',
|
|
3287
|
+
'aria-checked': logEnabled ? 'true' : 'false',
|
|
3288
|
+
'data-log-toggle': 'true',
|
|
3289
|
+
'aria-label': t('logging'),
|
|
3290
|
+
title: t('loggingHint'),
|
|
3291
|
+
onClick: () => {
|
|
3292
|
+
setLogging(!logEnabled)
|
|
3293
|
+
},
|
|
3294
|
+
},
|
|
3295
|
+
),
|
|
3296
|
+
),
|
|
3297
|
+
)
|
|
3298
|
+
: null,
|
|
3299
|
+
showStatus
|
|
3300
|
+
? h(
|
|
3301
|
+
'div',
|
|
3302
|
+
{ className: 'dce-status-bar' },
|
|
3303
|
+
h('span', { className: 'dce-dot', 'data-phase': snapshot.phase, 'aria-hidden': true }),
|
|
3304
|
+
h('span', { className: 'dce-status' }, t(STATUS_KEY[snapshot.phase] || 'statusIdle')),
|
|
3305
|
+
snapshot.detail === '' ? null : h('span', { className: 'dce-diag' }, snapshot.detail),
|
|
3306
|
+
)
|
|
3307
|
+
: null,
|
|
3308
|
+
h(
|
|
3309
|
+
'div',
|
|
3310
|
+
{ className: 'dce-surface' },
|
|
3311
|
+
h('div', { className: 'dce-viewport', ref: hostRef }),
|
|
3312
|
+
snapshot.phase === PHASE.UNSUPPORTED
|
|
3313
|
+
? hintCard(t('statusUnsupported'), t('unsupportedHint'), t, snapshot)
|
|
3314
|
+
: null,
|
|
3315
|
+
snapshot.phase === PHASE.FAILED
|
|
3316
|
+
? hintCard(t('statusFailed'), t('failedHint'), t, snapshot, onReload)
|
|
3317
|
+
: null,
|
|
3318
|
+
record.oauthBlocked
|
|
3319
|
+
? h(
|
|
3320
|
+
'div',
|
|
3321
|
+
{ className: 'dce-card', role: 'alert' },
|
|
3322
|
+
h('div', { className: 'dce-card-title' }, t('oauthBlockedTitle')),
|
|
3323
|
+
h('div', { className: 'dce-card-body' }, t('oauthBlockedBody')),
|
|
3324
|
+
record.lastUrl === '' ? null : h('div', { className: 'dce-card-detail' }, record.lastUrl),
|
|
3325
|
+
h(
|
|
3326
|
+
'div',
|
|
3327
|
+
{ className: 'dce-card-actions' },
|
|
3328
|
+
h(
|
|
3329
|
+
'button',
|
|
3330
|
+
{ type: 'button', className: 'dce-button dce-button-primary', onClick: onReload },
|
|
3331
|
+
t('goHome'),
|
|
3332
|
+
),
|
|
3333
|
+
h(
|
|
3334
|
+
'button',
|
|
3335
|
+
{ type: 'button', className: 'dce-button', onClick: openInBrowser },
|
|
3336
|
+
t('openInBrowser'),
|
|
3337
|
+
),
|
|
3338
|
+
h(
|
|
3339
|
+
'button',
|
|
3340
|
+
{
|
|
3341
|
+
type: 'button',
|
|
3342
|
+
className: 'dce-button',
|
|
3343
|
+
onClick: () => {
|
|
3344
|
+
record.oauthBlocked = false
|
|
3345
|
+
notify()
|
|
3346
|
+
},
|
|
3347
|
+
},
|
|
3348
|
+
t('dismiss'),
|
|
3349
|
+
),
|
|
3350
|
+
),
|
|
3351
|
+
)
|
|
3352
|
+
: null,
|
|
3353
|
+
record.diagOpen
|
|
3354
|
+
? h(
|
|
3355
|
+
'div',
|
|
3356
|
+
{ className: 'dce-card', role: 'dialog' },
|
|
3357
|
+
h('div', { className: 'dce-card-title' }, t('diagTitle')),
|
|
3358
|
+
h(
|
|
3359
|
+
'div',
|
|
3360
|
+
{ className: 'dce-card-detail' },
|
|
3361
|
+
t('diagUrl') + ': ' + (record.lastUrl === '' ? guestUrl(record.element) || '—' : record.lastUrl),
|
|
3362
|
+
),
|
|
3363
|
+
h('div', { className: 'dce-card-detail' }, t('diagRight') + ': ' + describeRightSidebar(t)),
|
|
3364
|
+
h('div', { className: 'dce-card-detail' }, t('diagSeat') + ': ' + describeSeat(t)),
|
|
3365
|
+
record.selfTest === ''
|
|
3366
|
+
? null
|
|
3367
|
+
: h('div', { className: 'dce-card-detail' }, t('selfTest') + ': ' + record.selfTest),
|
|
3368
|
+
h(
|
|
3369
|
+
'div',
|
|
3370
|
+
{ className: 'dce-card-detail' },
|
|
3371
|
+
t('diagPhase') + ': ' + phaseText(t, snapshot.phase) + (snapshot.detail === '' ? '' : ' · ' + snapshot.detail),
|
|
3372
|
+
),
|
|
3373
|
+
h(
|
|
3374
|
+
'div',
|
|
3375
|
+
{ className: 'dce-card-detail' },
|
|
3376
|
+
t('partitionLabel') + ': ' + shortId(record.partition) + ' · #' + record.mounts,
|
|
3377
|
+
),
|
|
3378
|
+
h(
|
|
3379
|
+
'div',
|
|
3380
|
+
{ className: 'dce-card-detail' },
|
|
3381
|
+
t('diagEvent') + ': ' + (record.lastEvent === '' ? '—' : valueText(t, EVENT_KEY, record.lastEvent)),
|
|
3382
|
+
),
|
|
3383
|
+
h(
|
|
3384
|
+
'div',
|
|
3385
|
+
{ className: 'dce-card-detail' },
|
|
3386
|
+
t('logging') + ': ' + (logEnabled ? t('loggingOn') : t('loggingOff')),
|
|
3387
|
+
),
|
|
3388
|
+
logEnabled
|
|
3389
|
+
? h(
|
|
3390
|
+
'button',
|
|
3391
|
+
{
|
|
3392
|
+
type: 'button',
|
|
3393
|
+
className: 'dce-button',
|
|
3394
|
+
onClick: () => {
|
|
3395
|
+
void copyText(JSON.stringify(readLogEntries(), null, 2))
|
|
3396
|
+
},
|
|
3397
|
+
},
|
|
3398
|
+
t('loggingCopy'),
|
|
3399
|
+
)
|
|
3400
|
+
: null,
|
|
3401
|
+
record.handoff === ''
|
|
3402
|
+
? null
|
|
3403
|
+
: h('div', { className: 'dce-card-detail' }, t('diagHandoff') + ': ' + record.handoff),
|
|
3404
|
+
h('div', { className: 'dce-card-body' }, t('diagHint')),
|
|
3405
|
+
h(
|
|
3406
|
+
'div',
|
|
3407
|
+
{ className: 'dce-card-actions' },
|
|
3408
|
+
h(
|
|
3409
|
+
'button',
|
|
3410
|
+
{
|
|
3411
|
+
type: 'button',
|
|
3412
|
+
className: 'dce-button',
|
|
3413
|
+
onClick: () => {
|
|
3414
|
+
record.selfTest = runSeatSelfTest(t)
|
|
3415
|
+
notify()
|
|
3416
|
+
},
|
|
3417
|
+
},
|
|
3418
|
+
t('selfTest'),
|
|
3419
|
+
),
|
|
3420
|
+
h(
|
|
3421
|
+
'button',
|
|
3422
|
+
{ type: 'button', className: 'dce-button dce-button-primary', onClick: copyDiagnostics },
|
|
3423
|
+
copyState === 'copied' ? t('copied') : copyState === 'failed' ? t('copyFailed') : t('copyDiag'),
|
|
3424
|
+
),
|
|
3425
|
+
h('button', { type: 'button', className: 'dce-button', onClick: onReload }, t('reload')),
|
|
3426
|
+
h(
|
|
3427
|
+
'button',
|
|
3428
|
+
{
|
|
3429
|
+
type: 'button',
|
|
3430
|
+
className: 'dce-button',
|
|
3431
|
+
onClick: () => {
|
|
3432
|
+
record.diagOpen = false
|
|
3433
|
+
notify()
|
|
3434
|
+
},
|
|
3435
|
+
},
|
|
3436
|
+
t('dismiss'),
|
|
3437
|
+
),
|
|
3438
|
+
),
|
|
3439
|
+
)
|
|
3440
|
+
: null,
|
|
3441
|
+
),
|
|
3442
|
+
),
|
|
3443
|
+
)
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
/** The left-navigation entry; inherits color so it follows the theme. */
|
|
3447
|
+
function ChatNavIcon(props) {
|
|
3448
|
+
const size = props !== undefined && props !== null && typeof props.size === 'number' && props.size > 0 ? props.size : 20
|
|
3449
|
+
const active = props !== undefined && props !== null && props.active === true
|
|
3450
|
+
return h(
|
|
3451
|
+
'svg',
|
|
3452
|
+
{
|
|
3453
|
+
width: size,
|
|
3454
|
+
height: size,
|
|
3455
|
+
viewBox: '0 0 24 24',
|
|
3456
|
+
fill: 'none',
|
|
3457
|
+
stroke: 'currentColor',
|
|
3458
|
+
strokeWidth: active ? 2 : 1.7,
|
|
3459
|
+
strokeLinecap: 'round',
|
|
3460
|
+
strokeLinejoin: 'round',
|
|
3461
|
+
'aria-hidden': true,
|
|
3462
|
+
focusable: 'false',
|
|
3463
|
+
style: { display: 'block', opacity: active ? 1 : 0.82 },
|
|
3464
|
+
},
|
|
3465
|
+
h('path', { d: 'M4.2 6.6c0-1.2 1-2.2 2.2-2.2h11.2c1.2 0 2.2 1 2.2 2.2v7.9c0 1.2-1 2.2-2.2 2.2H10l-4.6 3.1c-.5.3-1.2 0-1.2-.7V6.6Z' }),
|
|
3466
|
+
h('path', { d: 'M8.2 9.4h7.6M8.2 12.8h4.8' }),
|
|
3467
|
+
)
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
// ------------------------------------------------------------------
|
|
3471
|
+
// Plugin record
|
|
3472
|
+
// ------------------------------------------------------------------
|
|
3473
|
+
|
|
3474
|
+
// Test hook: dead code in the app, because dsh never sets this flag. It
|
|
3475
|
+
// lets the suite drive the lease handshake against a fake shell instead of
|
|
3476
|
+
// mounting the React UI.
|
|
3477
|
+
if (globalThis.__DSH_CHAT_EMBED_TEST__ === true) {
|
|
3478
|
+
globalThis.__DSHCE_TEST__ = {
|
|
3479
|
+
NS,
|
|
3480
|
+
PANEL_ID,
|
|
3481
|
+
PROVIDERS,
|
|
3482
|
+
DEFAULT_PROVIDER,
|
|
3483
|
+
ACTIVE_TAB_KEY,
|
|
3484
|
+
TAB_ORDER_KEY,
|
|
3485
|
+
BUILD,
|
|
3486
|
+
PHASE,
|
|
3487
|
+
DICTS,
|
|
3488
|
+
CSS,
|
|
3489
|
+
TIMINGS,
|
|
3490
|
+
isOAuthUrl,
|
|
3491
|
+
isGuestBlank,
|
|
3492
|
+
normalizedUserAgent,
|
|
3493
|
+
currentUserAgent,
|
|
3494
|
+
desktopBrowser,
|
|
3495
|
+
isEmbedSupported,
|
|
3496
|
+
shortId,
|
|
3497
|
+
isLiveUrl,
|
|
3498
|
+
findProvider,
|
|
3499
|
+
getStatus,
|
|
3500
|
+
watch,
|
|
3501
|
+
activeProvider,
|
|
3502
|
+
setActive,
|
|
3503
|
+
guestRecord,
|
|
3504
|
+
mountGuest,
|
|
3505
|
+
reloadGuest,
|
|
3506
|
+
destroyGuests,
|
|
3507
|
+
navigateGuest,
|
|
3508
|
+
startUrlFor,
|
|
3509
|
+
lastUrls,
|
|
3510
|
+
orderedProviders,
|
|
3511
|
+
getTabOrder,
|
|
3512
|
+
moveProvider,
|
|
3513
|
+
normalizeOrder,
|
|
3514
|
+
isEnabled,
|
|
3515
|
+
getEnabled,
|
|
3516
|
+
setProviderEnabled,
|
|
3517
|
+
providersInOrder,
|
|
3518
|
+
ENABLED_KEY,
|
|
3519
|
+
getSurface,
|
|
3520
|
+
formatDiagReport,
|
|
3521
|
+
copyText,
|
|
3522
|
+
ensureActiveGuest,
|
|
3523
|
+
inlineHosts,
|
|
3524
|
+
resolveInlineHost,
|
|
3525
|
+
hostsInline,
|
|
3526
|
+
inlineSeat,
|
|
3527
|
+
ChatCenterPage,
|
|
3528
|
+
updateSurface,
|
|
3529
|
+
getLogging,
|
|
3530
|
+
persistenceState,
|
|
3531
|
+
setLogging,
|
|
3532
|
+
clearLog,
|
|
3533
|
+
readLogEntries,
|
|
3534
|
+
log,
|
|
3535
|
+
preferSeat,
|
|
3536
|
+
ChatSurface,
|
|
3537
|
+
ChatNavIcon,
|
|
3538
|
+
ChatRightBody,
|
|
3539
|
+
ChatRightTitle,
|
|
3540
|
+
ChatDockButton,
|
|
3541
|
+
RIGHT_TAB_ID,
|
|
3542
|
+
RIGHT_TAB_KIND,
|
|
3543
|
+
RIGHT_TAB_SLOT,
|
|
3544
|
+
RIGHT_TAB_TITLE_SLOT,
|
|
3545
|
+
DOCK_SLOT,
|
|
3546
|
+
DOCK_ID,
|
|
3547
|
+
PANEL_ID,
|
|
3548
|
+
describeSeat,
|
|
3549
|
+
runSeatSelfTest,
|
|
3550
|
+
seatReportData,
|
|
3551
|
+
installInspect,
|
|
3552
|
+
formatRect,
|
|
3553
|
+
translator,
|
|
3554
|
+
rightTabType,
|
|
3555
|
+
openRightTab,
|
|
3556
|
+
rightSidebarStatus,
|
|
3557
|
+
setOpenRightTabImpl: (impl) => {
|
|
3558
|
+
openRightTabImpl = typeof impl === 'function' ? impl : () => {}
|
|
3559
|
+
},
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
return {
|
|
3564
|
+
inject: ['slots', 'locale'],
|
|
3565
|
+
apply(ctx) {
|
|
3566
|
+
// A hot reload can apply this bundle again while the previous instance is
|
|
3567
|
+
// still alive. Its slots, tab type and observers would then block or
|
|
3568
|
+
// shadow ours (duplicate id, single-slot collision), so take over first.
|
|
3569
|
+
const carrier = typeof window !== 'undefined' && window !== null ? window : undefined
|
|
3570
|
+
const previous = carrier === undefined ? undefined : carrier[INSTANCE_KEY]
|
|
3571
|
+
if (previous !== undefined && previous !== null && typeof previous.dispose === 'function') {
|
|
3572
|
+
try {
|
|
3573
|
+
previous.dispose()
|
|
3574
|
+
} catch (error) {
|
|
3575
|
+
void error
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
const disposers = []
|
|
3580
|
+
const disposeAll = () => {
|
|
3581
|
+
while (disposers.length > 0) {
|
|
3582
|
+
const dispose = disposers.pop()
|
|
3583
|
+
try {
|
|
3584
|
+
dispose()
|
|
3585
|
+
} catch (error) {
|
|
3586
|
+
void error
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
if (typeof rightTabTypeDispose === 'function') {
|
|
3590
|
+
try {
|
|
3591
|
+
rightTabTypeDispose()
|
|
3592
|
+
} catch (error) {
|
|
3593
|
+
void error
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
rightTabTypeDispose = undefined
|
|
3597
|
+
rightSeatsInstalled = false
|
|
3598
|
+
rightSeatAttempts = []
|
|
3599
|
+
rightTypeState = { state: 'idle', detail: '' }
|
|
3600
|
+
openRightTabImpl = () => {}
|
|
3601
|
+
try {
|
|
3602
|
+
destroyGuests()
|
|
3603
|
+
} catch (error) {
|
|
3604
|
+
void error
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
if (carrier !== undefined) carrier[INSTANCE_KEY] = { version: BUILD, dispose: disposeAll }
|
|
3608
|
+
|
|
3609
|
+
// Locale dictionaries first: the entry label reads them.
|
|
3610
|
+
disposers.push(ctx.effect(() => ctx.locale.register(NS, DICTS), 'dsh-chat-embed: dictionaries'))
|
|
3611
|
+
const t = ctx.locale.bind(NS)
|
|
3612
|
+
uiT = t
|
|
3613
|
+
if (typeof ctx.on === 'function') {
|
|
3614
|
+
// Switching the DSH language re-renders the panel immediately; a
|
|
3615
|
+
// language change never needs a reload.
|
|
3616
|
+
disposers.push(ctx.on('locale/change', () => notify()))
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
// Slots: the chat page in the center panel, its sidebar icon, the right
|
|
3620
|
+
// sidebar tab (body + chip title) and the session header button.
|
|
3621
|
+
disposers.push(
|
|
3622
|
+
ctx.effect(() => {
|
|
3623
|
+
const entries = [
|
|
3624
|
+
ctx.slots.inject('main', () =>
|
|
3625
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, registrant: 'dsh-chat-embed' }, ChatCenterPage),
|
|
3626
|
+
),
|
|
3627
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
3628
|
+
ctx.slots.register(
|
|
3629
|
+
{
|
|
3630
|
+
name: 'sidebar.panellist',
|
|
3631
|
+
id: PANEL_ID,
|
|
3632
|
+
order: 30,
|
|
3633
|
+
label: () => t('navLabel'),
|
|
3634
|
+
locale: NS,
|
|
3635
|
+
registrant: 'dsh-chat-embed',
|
|
3636
|
+
},
|
|
3637
|
+
ChatNavIcon,
|
|
3638
|
+
),
|
|
3639
|
+
),
|
|
3640
|
+
]
|
|
3641
|
+
return () => {
|
|
3642
|
+
for (const dispose of entries) dispose()
|
|
3643
|
+
}
|
|
3644
|
+
}, 'dsh-chat-embed: entries'),
|
|
3645
|
+
)
|
|
3646
|
+
|
|
3647
|
+
// Styles and the guests share one lifetime: unloading the plugin must
|
|
3648
|
+
// release every lease and leave no detached surface behind.
|
|
3649
|
+
disposers.push(
|
|
3650
|
+
ctx.effect(() => {
|
|
3651
|
+
const style = injectStyles()
|
|
3652
|
+
return () => {
|
|
3653
|
+
destroyGuests()
|
|
3654
|
+
if (style !== null && style !== undefined && style.parentNode !== null && style.parentNode !== undefined) {
|
|
3655
|
+
style.parentNode.removeChild(style)
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
}, 'dsh-chat-embed: surface'),
|
|
3659
|
+
)
|
|
3660
|
+
|
|
3661
|
+
// The right column is an optional neighbour. Wire it up in isolated
|
|
3662
|
+
// steps: a rejected seat or a service that shows up later must never
|
|
3663
|
+
// take the whole plugin (and its left entry) down with it.
|
|
3664
|
+
installRightSidebar(ctx, t)
|
|
3665
|
+
disposers.push(() => {
|
|
3666
|
+
while (rightDisposers.length > 0) {
|
|
3667
|
+
const dispose = rightDisposers.pop()
|
|
3668
|
+
try {
|
|
3669
|
+
dispose()
|
|
3670
|
+
} catch (error) {
|
|
3671
|
+
void error
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
})
|
|
3675
|
+
|
|
3676
|
+
// Read-only inspection channel: the live geometry can be read from the
|
|
3677
|
+
// Host without user interaction.
|
|
3678
|
+
const disposeInspect = installInspect(ctx, t)
|
|
3679
|
+
if (typeof disposeInspect === 'function') disposers.push(disposeInspect)
|
|
3680
|
+
},
|
|
3681
|
+
}
|
|
3682
|
+
},
|
|
3683
|
+
})
|