dsh-mini 0.0.0-stage → 0.1.1

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/lib/client.js ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * dsh-mini — browser half.
3
+ *
4
+ * Two buttons and one small window mode:
5
+ *
6
+ * - main window: an "pop out" button in the session header that asks the host to
7
+ * spawn an Edge/Chrome --app window on the same URL plus `dshHud=1`.
8
+ * - HUD window: the page carries `dshHud=1`, so it titles itself
9
+ * "DSH 小窗 · <session>" (the host finds the native window by that prefix),
10
+ * and grows a screenshot key plus pin/close controls.
11
+ *
12
+ * The screenshot key captures the *screen* with the HUD hidden again, then pastes
13
+ * the result into the composer — so "look at what is on my desktop and explain it"
14
+ * is one click inside the small window.
15
+ */
16
+
17
+ /**
18
+ * The id this bundle is being loaded under.
19
+ *
20
+ * The host serves an entry's client bundle only under the entry id it composed
21
+ * at scan time. After this plugin was renamed from `dsh-hud-window` to
22
+ * `dsh-mini`, a host that was already running kept its old row, so the boot
23
+ * payload still asked for `dsh-hud-window/client.js` and the loader rejected the
24
+ * file because it registered a different id ("loaded without registering
25
+ * ..."), which fails the whole client boot. Reading the id back from the
26
+ * executing script keeps that stale row harmless: it registers under its own
27
+ * expected id and does nothing (see `apply`), until the next host restart drops
28
+ * the old row for good.
29
+ */
30
+ const SCRIPT_ID = (() => {
31
+ try {
32
+ const src = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
33
+ const parts = src.split('/client.js')
34
+ // A multi-entry combo names its members after `??`; only a single-member
35
+ // combo unambiguously identifies this bundle.
36
+ const combo = src.match(/\/plugins\/\?\?([^,&]+)\/client\.js/)
37
+ if (combo && src.split('/client.js').length === 2) return decodeURIComponent(combo[1])
38
+ const single = src.match(/\/plugins\/([^/]+)\/client\.js/)
39
+ return single ? decodeURIComponent(single[1]) : ''
40
+ } catch {
41
+ return ''
42
+ }
43
+ })()
44
+
45
+ /** Legacy ids this bundle may still be requested under by an older host row. */
46
+ const LEGACY_IDS = ['dsh-hud-window']
47
+ const MODULE_ID = SCRIPT_ID === 'dsh-mini' || LEGACY_IDS.includes(SCRIPT_ID) ? SCRIPT_ID : 'dsh-mini'
48
+
49
+ window.__ModuleLoader__.load({
50
+ id: MODULE_ID,
51
+ factory: (require) => {
52
+ var module = { exports: {} }
53
+ var exports = module.exports
54
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
55
+
56
+ let react = require('react')
57
+
58
+ /** Must match PREFIX in lib/index.js and the repo route table. */
59
+ const PREFIX = '/dsh-mini'
60
+ const HUD_FLAG = 'dshHud'
61
+ /** Must start with `titlePrefix` in cordis.patch.yml. */
62
+ const TITLE_PREFIX = 'DSH 小窗'
63
+ const STYLE_ID = 'dsh-mini-style'
64
+
65
+ /** @param {string} key @returns {string} */
66
+ function queryFlag(key) {
67
+ try { return new URLSearchParams(window.location.search).get(key) ?? '' } catch { return '' }
68
+ }
69
+
70
+ /**
71
+ * @param {string} name
72
+ * @returns {string}
73
+ *
74
+ * The GUI logs a fresh profile in by redirecting to a cleaned URL, which drops
75
+ * our query flags; the native shell mirrors them into cookies that survive it.
76
+ */
77
+ function cookieValue(name) {
78
+ try {
79
+ const parts = String(document.cookie ?? '').split(';')
80
+ for (let index = 0; index < parts.length; index += 1) {
81
+ const raw = parts[index].trim()
82
+ const equals = raw.indexOf('=')
83
+ if (equals <= 0) continue
84
+ if (raw.slice(0, equals) === name) {
85
+ const value = raw.slice(equals + 1)
86
+ try { return decodeURIComponent(value) } catch { return value }
87
+ }
88
+ }
89
+ } catch {
90
+ // No cookie access: fall through.
91
+ }
92
+ return ''
93
+ }
94
+
95
+ const isHud = queryFlag(HUD_FLAG) === '1' || cookieValue('dsh_hud') === '1'
96
+
97
+ /**
98
+ * True inside the native WebView2 shell (app/HudWindow.cs). That shell draws
99
+ * its own title bar with the pin / back / shot / close keys, so the page must
100
+ * not add a second set of buttons there — it only renders the compact layout.
101
+ */
102
+ const inNativeShell = (() => {
103
+ try { return Boolean(window.chrome && window.chrome.webview && window.chrome.webview.postMessage) } catch { return false }
104
+ })()
105
+
106
+ // ---------------------------------------------------------------- styles
107
+
108
+ const CSS = [
109
+ '.dsh-hud-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;',
110
+ 'border-radius:8px;border:none;background:transparent;color:inherit;cursor:pointer;padding:0;',
111
+ 'opacity:.68;transition:background .15s ease,opacity .15s ease;flex:0 0 auto}',
112
+ '.dsh-hud-btn:hover{background:rgba(127,127,127,.16);opacity:1}',
113
+ '.dsh-hud-btn:active{transform:scale(.94)}',
114
+ '.dsh-hud-btn[disabled]{opacity:.35;cursor:default}',
115
+ '.dsh-hud-btn svg{width:16px;height:16px;display:block}',
116
+ '.dsh-hud-toast{position:fixed;left:50%;bottom:104px;transform:translateX(-50%);z-index:99999;',
117
+ 'padding:8px 14px;border-radius:10px;font-size:12.5px;line-height:1.45;color:#fff;',
118
+ 'background:rgba(28,28,30,.92);box-shadow:0 10px 28px rgba(0,0,0,.28);max-width:78vw;',
119
+ 'pointer-events:none;animation:dsh-hud-in .16s ease-out}',
120
+ '.dsh-hud-toast[data-kind="error"]{background:rgba(190,48,48,.95)}',
121
+ '@keyframes dsh-hud-in{from{opacity:0;transform:translate(-50%,6px)}to{opacity:1;transform:translate(-50%,0)}}',
122
+ ].join('')
123
+
124
+ function ensureStyles() {
125
+ if (document.getElementById(STYLE_ID) !== null) return
126
+ const style = document.createElement('style')
127
+ style.id = STYLE_ID
128
+ style.textContent = CSS
129
+ document.head.appendChild(style)
130
+ }
131
+
132
+ // ---------------------------------------------------------------- toast
133
+
134
+ let toastTimer = null
135
+
136
+ /** @param {string} message @param {'info'|'error'} [kind] */
137
+ function toast(message, kind = 'info') {
138
+ const node = document.createElement('div')
139
+ node.className = 'dsh-hud-toast'
140
+ node.dataset.kind = kind
141
+ node.textContent = message
142
+ document.body.appendChild(node)
143
+ if (toastTimer !== null) clearTimeout(toastTimer)
144
+ toastTimer = setTimeout(() => { node.remove(); toastTimer = null }, 2600)
145
+ }
146
+
147
+ /**
148
+ * Breadcrumb for diagnosing button wiring. The page has no console channel we
149
+ * can read from outside, so a one-pixel image request lands in the host's
150
+ * `trace.log` instead.
151
+ *
152
+ * @param {string} step
153
+ * @param {unknown} [detail]
154
+ */
155
+ function trace(step, detail) {
156
+ try {
157
+ const image = new window.Image()
158
+ image.src = `${PREFIX}/trace?step=${encodeURIComponent(step)}&detail=${encodeURIComponent(String(detail ?? ''))}&t=${String(Date.now())}`
159
+ } catch {
160
+ // Diagnostics must never break the click path.
161
+ }
162
+ }
163
+
164
+ /** `location.href` only when the page is really served over http(s). */
165
+ function webHref() {
166
+ try {
167
+ const raw = String(window.location.href ?? '')
168
+ return /^https?:/i.test(raw) ? raw : ''
169
+ } catch {
170
+ return ''
171
+ }
172
+ }
173
+
174
+ function safeProtocol() {
175
+ try {
176
+ return String(window.location.protocol ?? '?')
177
+ } catch {
178
+ return '?'
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------- host api
183
+
184
+ async function postJson(path, body) {
185
+ const response = await fetch(`${PREFIX}${path}`, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json' },
188
+ body: JSON.stringify(body ?? {}),
189
+ })
190
+ const text = await response.text()
191
+ let data = {}
192
+ try { data = text === '' ? {} : JSON.parse(text) } catch { data = { error: text } }
193
+ if (!response.ok || (data && data.error)) {
194
+ throw new Error(String(data?.error ?? `HTTP ${String(response.status)}`))
195
+ }
196
+ return data
197
+ }
198
+
199
+ // ---------------------------------------------------------------- composer
200
+
201
+ /**
202
+ * Paste an image into the conversation composer.
203
+ *
204
+ * The composer is a Lexical editor that accepts image files through its paste
205
+ * command; a synthetic ClipboardEvent with a DataTransfer carrying the File is
206
+ * enough, even though `isTrusted` is false.
207
+ *
208
+ * @param {string} dataUrl a `data:image/png;base64,...` string
209
+ * @returns {boolean} whether a composer was found
210
+ */
211
+ function insertIntoComposer(dataUrl) {
212
+ const editors = Array.from(document.querySelectorAll('[data-lexical-editor="true"]'))
213
+ const editor =
214
+ editors.find((node) => {
215
+ const rect = node.getBoundingClientRect()
216
+ return rect.width > 0 && rect.height > 0
217
+ }) ?? editors[0]
218
+ if (editor === undefined) return false
219
+
220
+ const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
221
+ const binary = atob(base64)
222
+ const bytes = new Uint8Array(binary.length)
223
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
224
+ const file = new window.File([bytes], `screenshot-${String(Date.now())}.png`, { type: 'image/png' })
225
+
226
+ const transfer = new window.DataTransfer()
227
+ transfer.items.add(file)
228
+
229
+ editor.focus()
230
+ let event
231
+ try {
232
+ event = new window.ClipboardEvent('paste', {
233
+ dataTransfer: transfer,
234
+ clipboardData: transfer,
235
+ bubbles: true,
236
+ cancelable: true,
237
+ composed: true,
238
+ })
239
+ } catch {
240
+ event = new window.ClipboardEvent('paste', { bubbles: true, cancelable: true, composed: true })
241
+ }
242
+ if (event.clipboardData === null || event.clipboardData === undefined) {
243
+ Object.defineProperty(event, 'clipboardData', { value: transfer })
244
+ }
245
+ editor.dispatchEvent(event)
246
+ return true
247
+ }
248
+
249
+ // ---------------------------------------------------------------- icons
250
+
251
+ function icon(children) {
252
+ return react.createElement(
253
+ 'svg',
254
+ {
255
+ viewBox: '0 0 16 16',
256
+ fill: 'none',
257
+ stroke: 'currentColor',
258
+ strokeWidth: 1.4,
259
+ strokeLinecap: 'round',
260
+ strokeLinejoin: 'round',
261
+ 'aria-hidden': 'true',
262
+ },
263
+ children,
264
+ )
265
+ }
266
+
267
+ const POPOUT_ICON = icon([
268
+ react.createElement('path', { key: 'a', d: 'M6.6 3.6H4.9c-.7 0-1.3.6-1.3 1.3v6.2c0 .7.6 1.3 1.3 1.3h6.2c.7 0 1.3-.6 1.3-1.3V9.4' }),
269
+ react.createElement('path', { key: 'b', d: 'M9.4 2.6h4v4' }),
270
+ react.createElement('path', { key: 'c', d: 'M13.1 2.9 7.7 8.3' }),
271
+ ])
272
+
273
+ const CAMERA_ICON = icon([
274
+ react.createElement('path', { key: 'a', d: 'M2.7 5.9h1.9l1-1.7h2.8l1 1.7h1.9c.5 0 .9.4.9.9v4.4c0 .5-.4.9-.9.9H2.7a.9.9 0 0 1-.9-.9V6.8c0-.5.4-.9.9-.9Z' }),
275
+ react.createElement('circle', { key: 'b', cx: 8, cy: 9.3, r: 1.9 }),
276
+ ])
277
+
278
+ const PIN_ICON = icon([
279
+ react.createElement('path', { key: 'a', d: 'M6.1 2.6h3.8l-.6 3.1 2 2H4.7l2-2-.6-3.1Z' }),
280
+ react.createElement('path', { key: 'b', d: 'M8 7.9v5.5' }),
281
+ ])
282
+
283
+ const CLOSE_ICON = icon([
284
+ react.createElement('path', { key: 'a', d: 'M4.2 4.2 11.8 11.8' }),
285
+ react.createElement('path', { key: 'b', d: 'M11.8 4.2 4.2 11.8' }),
286
+ ])
287
+
288
+ // ---------------------------------------------------------------- buttons
289
+
290
+ /**
291
+ * Header button in the main window: pop the current conversation out.
292
+ *
293
+ * The slot is session scoped, so `props.sessionId` names the conversation the
294
+ * header belongs to; the HUD window is a fresh document and can only learn it
295
+ * from this hand-off.
296
+ */
297
+ function PopoutButton(props) {
298
+ const [busy, setBusy] = react.useState(false)
299
+
300
+ async function onClick() {
301
+ trace('click', webHref() === '' ? `no-http:${safeProtocol()}` : 'http')
302
+ if (busy) return
303
+ setBusy(true)
304
+ try {
305
+ const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
306
+ // The desktop renderer runs on a custom scheme, so `location.href` is not
307
+ // something the host can open. Send it only when it really is http(s);
308
+ // otherwise the host supplies the advertised address itself.
309
+ const data = await postJson('/open', { href: webHref(), sessionId })
310
+ trace('opened', `${String(data && data.pid)}|${sessionId}`)
311
+ toast(data.reused === true ? '小窗已在运行,已切到这个会话' : '已弹出小窗(置顶)')
312
+ } catch (error) {
313
+ trace('open-failed', String(error?.message ?? error))
314
+ toast(`弹出失败:${String(error?.message ?? error)}`, 'error')
315
+ } finally {
316
+ setBusy(false)
317
+ }
318
+ }
319
+
320
+ if (isHud) return null
321
+ return react.createElement(
322
+ 'button',
323
+ {
324
+ type: 'button',
325
+ className: 'dsh-hud-btn',
326
+ title: '弹出此窗口',
327
+ 'aria-label': '弹出此窗口',
328
+ disabled: busy,
329
+ onClick,
330
+ },
331
+ POPOUT_ICON,
332
+ )
333
+ }
334
+
335
+ /** HUD header button: keep the popout on top / close it. */
336
+ function PinButton() {
337
+ const [busy, setBusy] = react.useState(false)
338
+
339
+ async function onClick() {
340
+ if (busy) return
341
+ setBusy(true)
342
+ try {
343
+ await postJson('/focus', {})
344
+ toast('已重新置顶')
345
+ } catch (error) {
346
+ toast(`置顶失败:${String(error?.message ?? error)}`, 'error')
347
+ } finally {
348
+ setBusy(false)
349
+ }
350
+ }
351
+
352
+ if (!isHud) return null
353
+ return react.createElement(
354
+ 'button',
355
+ { type: 'button', className: 'dsh-hud-btn', title: '重新置顶', 'aria-label': '重新置顶', disabled: busy, onClick },
356
+ PIN_ICON,
357
+ )
358
+ }
359
+
360
+ function CloseButton() {
361
+ const [busy, setBusy] = react.useState(false)
362
+
363
+ async function onClick() {
364
+ if (busy) return
365
+ setBusy(true)
366
+ try {
367
+ await postJson('/close', {})
368
+ } catch {
369
+ // The window may already be gone; closing locally is still correct.
370
+ }
371
+ window.close()
372
+ }
373
+
374
+ if (!isHud) return null
375
+ return react.createElement(
376
+ 'button',
377
+ { type: 'button', className: 'dsh-hud-btn', title: '关闭小窗', 'aria-label': '关闭小窗', disabled: busy, onClick },
378
+ CLOSE_ICON,
379
+ )
380
+ }
381
+
382
+ /** HUD composer key: screenshot the desktop (HUD excluded) and paste it in. */
383
+ function ShotButton() {
384
+ const [busy, setBusy] = react.useState(false)
385
+
386
+ async function onClick() {
387
+ if (busy) return
388
+ setBusy(true)
389
+ try {
390
+ const data = await postJson('/shot', {})
391
+ if (typeof data.png !== 'string' || data.png === '') throw new Error('没有拿到图片')
392
+ if (!insertIntoComposer(data.png)) throw new Error('没找到输入框')
393
+ toast('截图已插入输入框')
394
+ } catch (error) {
395
+ toast(`截图失败:${String(error?.message ?? error)}`, 'error')
396
+ } finally {
397
+ setBusy(false)
398
+ }
399
+ }
400
+
401
+ if (!isHud) return null
402
+ return react.createElement(
403
+ 'button',
404
+ { type: 'button', className: 'dsh-hud-btn', title: '截图(不含本窗口)', 'aria-label': '截图', disabled: busy, onClick },
405
+ CAMERA_ICON,
406
+ )
407
+ }
408
+
409
+ // ---------------------------------------------------------------- hud title
410
+
411
+ /**
412
+ * Keep the window title starting with TITLE_PREFIX while still showing the
413
+ * session name, which is what the host's window lookup keys on.
414
+ */
415
+ function startTitleKeeper() {
416
+ if (!isHud) return
417
+ let sessionTitle = ''
418
+ const compose = () => (sessionTitle === '' ? TITLE_PREFIX : `${TITLE_PREFIX} · ${sessionTitle}`)
419
+ const apply = () => {
420
+ const current = document.title ?? ''
421
+ if (current.startsWith(TITLE_PREFIX)) {
422
+ // Already ours: harvest the session part if it was updated.
423
+ const tail = current.slice(TITLE_PREFIX.length).replace(/^\s*·\s*/, '')
424
+ if (tail !== '' && tail !== sessionTitle) sessionTitle = tail
425
+ return
426
+ }
427
+ if (current !== '') sessionTitle = current
428
+ document.title = compose()
429
+ }
430
+ apply()
431
+ const timer = setInterval(apply, 900)
432
+ window.addEventListener('pagehide', () => { clearInterval(timer) }, { once: true })
433
+ }
434
+
435
+ // ---------------------------------------------------------------- hand-off
436
+
437
+ /**
438
+ * In the small window, open the conversation the main window handed over.
439
+ *
440
+ * The HUD is a separate document with no shared client state, so the popout
441
+ * URL carries `session=<id>` and this selects that conversation as soon as the
442
+ * workspace service is reachable.
443
+ */
444
+ function followHandedSession(ctx) {
445
+ if (!isHud) return
446
+ const sessionId = queryFlag('session') || cookieValue('dsh_hud_session')
447
+ if (sessionId === '') return
448
+ let tries = 0
449
+ const step = () => {
450
+ tries += 1
451
+ try {
452
+ const workspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined
453
+ if (workspace !== undefined && workspace !== null && typeof workspace.openSession === 'function') {
454
+ workspace.openSession(sessionId)
455
+ return
456
+ }
457
+ } catch {
458
+ // Service not created yet: keep waiting.
459
+ }
460
+ if (tries < 40) setTimeout(step, 250)
461
+ }
462
+ setTimeout(step, 60)
463
+ }
464
+
465
+ // ---------------------------------------------------------------- settings page
466
+
467
+ const rowStyle = { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0' }
468
+ const labelStyle = { minWidth: '132px', fontSize: '13px', opacity: 0.9 }
469
+ const inputStyle = {
470
+ width: '132px',
471
+ padding: '5px 8px',
472
+ fontSize: '13px',
473
+ color: 'inherit',
474
+ background: 'transparent',
475
+ border: '1px solid currentColor',
476
+ borderRadius: '6px',
477
+ opacity: 0.9,
478
+ }
479
+ const hintStyle = { fontSize: '12px', opacity: 0.6, margin: '4px 0 0' }
480
+
481
+ /** Settings page: screenshot behaviour + the two global hotkeys. */
482
+ function SettingsSection() {
483
+ const [config, setConfig] = react.useState(null)
484
+ const [status, setStatus] = react.useState('')
485
+
486
+ react.useEffect(() => {
487
+ let cancelled = false
488
+ const load = async () => {
489
+ try {
490
+ const response = await fetch(`${PREFIX}/config`, { headers: { accept: 'application/json' } })
491
+ const data = await response.json()
492
+ if (!cancelled) setConfig(data.config ?? {})
493
+ } catch (error) {
494
+ if (!cancelled) setStatus(`读取失败:${String(error?.message ?? error)}`)
495
+ }
496
+ }
497
+ load()
498
+ return () => { cancelled = true }
499
+ }, [])
500
+
501
+ const save = async (patch) => {
502
+ setConfig({ ...(config ?? {}), ...patch })
503
+ try {
504
+ const response = await fetch(`${PREFIX}/config`, {
505
+ method: 'POST',
506
+ headers: { 'content-type': 'application/json' },
507
+ body: JSON.stringify(patch),
508
+ })
509
+ const data = await response.json()
510
+ if (data.error) throw new Error(String(data.error))
511
+ setStatus('已保存')
512
+ setTimeout(() => setStatus(''), 1600)
513
+ } catch (error) {
514
+ setStatus(`保存失败:${String(error?.message ?? error)}`)
515
+ }
516
+ }
517
+
518
+ if (config === null) {
519
+ return react.createElement('div', { style: { padding: '8px 0', fontSize: '13px', opacity: 0.7 } },
520
+ status === '' ? '读取设置…' : status)
521
+ }
522
+
523
+ const checkbox = (key, text, hint) =>
524
+ react.createElement('div', { key, style: rowStyle },
525
+ react.createElement('label', { style: { ...labelStyle, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' } },
526
+ react.createElement('input', {
527
+ type: 'checkbox',
528
+ checked: Boolean(config[key]),
529
+ onChange: (event) => { save({ [key]: event.target.checked }) },
530
+ }),
531
+ text,
532
+ ),
533
+ hint === undefined
534
+ ? null
535
+ : react.createElement('span', { style: { fontSize: '12px', opacity: 0.6 } }, hint),
536
+ )
537
+
538
+ const hotkey = (key, text) =>
539
+ react.createElement('div', { key, style: rowStyle },
540
+ react.createElement('span', { style: labelStyle }, text),
541
+ react.createElement('input', {
542
+ style: inputStyle,
543
+ value: String(config[key] ?? ''),
544
+ onChange: (event) => setConfig({ ...config, [key]: event.target.value }),
545
+ onBlur: (event) => { save({ [key]: event.target.value }) },
546
+ onKeyDown: (event) => { if (event.key === 'Enter') event.currentTarget.blur() },
547
+ }),
548
+ )
549
+
550
+ return react.createElement('div', null,
551
+ react.createElement('h3', { style: { fontSize: '14px', margin: '0 0 4px' } }, '小窗与截图'),
552
+ react.createElement('p', { style: hintStyle }, '设置对原生小窗和后台划词服务同时生效,保存后约 3 秒内应用。'),
553
+ checkbox('copyShot', '截图自动复制到剪贴板', '全屏、框选两种截图都会复制'),
554
+ hotkey('hotkeyFull', '全屏截图快捷键'),
555
+ hotkey('hotkeyRegion', '框选截图快捷键'),
556
+ react.createElement('p', { style: hintStyle }, '写法:Ctrl+Shift+1、Ctrl+Shift+S、Alt+PrintScreen、F8 等。组合里必须带 Ctrl/Alt/Shift/Win 之一。'),
557
+ status === ''
558
+ ? null
559
+ : react.createElement('p', { style: { fontSize: '12px', margin: '6px 0 0', opacity: 0.8 } }, status),
560
+ )
561
+ }
562
+
563
+ // ---------------------------------------------------------------- apply
564
+
565
+ exports.name = MODULE_ID
566
+ exports.inject = ['slots']
567
+
568
+ exports.apply = (ctx) => {
569
+ trace('apply', `${MODULE_ID}|${isHud ? 'hud' : 'main'}|${inNativeShell ? 'native' : 'web'}`)
570
+ // A stale pre-rename row: register (so the loader accepts the bundle) but
571
+ // claim no UI — the current `dsh-mini` entry renders it.
572
+ if (MODULE_ID !== 'dsh-mini') {
573
+ trace('apply:legacy-skip', MODULE_ID)
574
+ return
575
+ }
576
+ ensureStyles()
577
+ startTitleKeeper()
578
+ followHandedSession(ctx)
579
+
580
+ // The settings page is global chrome, so it belongs to the main window.
581
+ if (!isHud) {
582
+ try {
583
+ trace('settings:start')
584
+ ctx.slots.inject('settings.section', () => {
585
+ trace('settings:inject-fired')
586
+ return ctx.slots.register(
587
+ {
588
+ name: 'settings.section',
589
+ id: 'hud-window',
590
+ order: 30,
591
+ label: () => 'DSH 小窗',
592
+ },
593
+ SettingsSection,
594
+ )
595
+ })
596
+ trace('settings:injected')
597
+ } catch (error) {
598
+ trace('settings:failed', error && error.message)
599
+ }
600
+ }
601
+
602
+ // The pop-out key lives in the *main* window only: the HUD is already popped out.
603
+ if (!isHud) {
604
+ ctx.slots.inject('conversation.session.header.utilities', () =>
605
+ ctx.slots.register(
606
+ {
607
+ name: 'conversation.session.header.utilities',
608
+ id: 'hud-popout',
609
+ order: 60,
610
+ label: () => '弹出此窗口',
611
+ },
612
+ PopoutButton,
613
+ ),
614
+ )
615
+ return
616
+ }
617
+
618
+ // Browser fallback HUD (no native title bar) still needs its own keys.
619
+ if (inNativeShell) return
620
+
621
+ ctx.slots.inject('conversation.session.header.utilities', () =>
622
+ ctx.slots.register(
623
+ {
624
+ name: 'conversation.session.header.utilities',
625
+ id: 'hud-pin',
626
+ order: 61,
627
+ label: () => '重新置顶',
628
+ },
629
+ PinButton,
630
+ ),
631
+ )
632
+
633
+ ctx.slots.inject('conversation.session.header.utilities', () =>
634
+ ctx.slots.register(
635
+ {
636
+ name: 'conversation.session.header.utilities',
637
+ id: 'hud-close',
638
+ order: 62,
639
+ label: () => '关闭小窗',
640
+ },
641
+ CloseButton,
642
+ ),
643
+ )
644
+
645
+ ctx.slots.inject('conversation.input.right', () =>
646
+ ctx.slots.register(
647
+ {
648
+ name: 'conversation.input.right',
649
+ id: 'hud-shot',
650
+ order: 20,
651
+ label: () => '截图',
652
+ },
653
+ ShotButton,
654
+ ),
655
+ )
656
+ }
657
+
658
+ return exports
659
+ },
660
+ })