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