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/index.js ADDED
@@ -0,0 +1,521 @@
1
+ /**
2
+ * dsh-mini — host half.
3
+ *
4
+ * Pops the current conversation out into its own always-on-top window, the way
5
+ * Tencent Yuanbao's mini window works. The window is a browser `--app` window
6
+ * (chromeless, no address bar) loading the very same authenticated GUI URL the
7
+ * main window is on, plus a `dshHud=1` flag that the browser half reads to
8
+ * switch into HUD mode (title, compact chrome, screenshot key).
9
+ *
10
+ * Why a browser --app window instead of an Electron BrowserWindow: this plugin
11
+ * runs in the Cordis host process (a child of the Electron main process), so it
12
+ * has no access to Electron's window APIs. Spawning the installed browser with
13
+ * --app gives a real native second window with zero Electron coupling.
14
+ *
15
+ * The window is then pinned with SetWindowPos (scripts/win-hud.ps1), because an
16
+ * --app window has no always-on-top switch of its own.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process'
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { tmpdir } from 'node:os'
24
+
25
+ export const name = 'dsh-mini'
26
+ export const inject = ['webServer']
27
+
28
+ const HERE = dirname(fileURLToPath(import.meta.url))
29
+ const SCRIPTS = join(HERE, '..', 'scripts')
30
+ const WORK_DIR = join(process.env.LOCALAPPDATA ?? tmpdir(), 'dsh-hud')
31
+ const SHOT_DIR = join(WORK_DIR, 'shots')
32
+ const PROFILE_DIR = join(WORK_DIR, 'profile')
33
+
34
+ /**
35
+ * The native shell built from `app/HudWindow.cs` (WebView2 + WinForms). It is
36
+ * the preferred backend: a real chromeless window we own, so the title bar,
37
+ * screenshot key and always-on-top state are ours instead of the browser's.
38
+ */
39
+ const NATIVE_EXE = join(HERE, '..', 'app', 'bin', 'DSH小窗.exe')
40
+
41
+ /** Exact-route prefix; the browser half uses the same constant. */
42
+ const PREFIX = '/dsh-mini'
43
+
44
+ /** Query flag that switches the page into HUD mode. */
45
+ const HUD_FLAG = 'dshHud'
46
+
47
+ /**
48
+ * Re-target an already running native shell. The exe polls `pending.txt` every
49
+ * 700 ms and navigates to whatever URL it finds, so writing it is how `open`
50
+ * moves an existing small window onto another conversation.
51
+ */
52
+ function writePendingUrl(url) {
53
+ try {
54
+ mkdirSync(WORK_DIR, { recursive: true })
55
+ writeFileSync(join(WORK_DIR, 'pending.txt'), url, 'utf8')
56
+ } catch {
57
+ // A failed hand-off is not fatal: the window is already open.
58
+ }
59
+ }
60
+
61
+ function sendJson(res, status, value) {
62
+ const body = JSON.stringify(value)
63
+ res.writeHead(status, {
64
+ 'content-type': 'application/json; charset=utf-8',
65
+ 'cache-control': 'no-store',
66
+ 'content-length': Buffer.byteLength(body),
67
+ })
68
+ res.end(body)
69
+ }
70
+
71
+ function readJson(req) {
72
+ return new Promise((resolve, reject) => {
73
+ let raw = ''
74
+ req.setEncoding('utf8')
75
+ req.on('data', (chunk) => {
76
+ raw += chunk
77
+ if (raw.length > 1_000_000) reject(new Error('body too large'))
78
+ })
79
+ req.on('end', () => {
80
+ if (raw.trim() === '') { resolve({}); return }
81
+ try { resolve(JSON.parse(raw)) } catch (error) { reject(new Error(`invalid JSON body: ${error.message}`)) }
82
+ })
83
+ req.on('error', reject)
84
+ })
85
+ }
86
+
87
+ /** First PowerShell that actually exists, preferring PowerShell 7. */
88
+ function powershell() {
89
+ const windir = process.env.SystemRoot ?? 'C:\\Windows'
90
+ const candidates = [
91
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
92
+ join(windir, 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'),
93
+ ]
94
+ for (const candidate of candidates) {
95
+ if (existsSync(candidate)) return candidate
96
+ }
97
+ throw new Error('no PowerShell found (looked for pwsh 7 and Windows PowerShell 5.1)')
98
+ }
99
+
100
+ /**
101
+ * Run one helper script and parse its single-line JSON reply.
102
+ * @param {string} script file name inside scripts/
103
+ * @param {string[]} args
104
+ */
105
+ function runScript(script, args = []) {
106
+ return new Promise((resolve, reject) => {
107
+ const file = join(SCRIPTS, script)
108
+ if (!existsSync(file)) { reject(new Error(`hud: helper not found: ${file}`)); return }
109
+ let shell
110
+ try { shell = powershell() } catch (error) { reject(error); return }
111
+ const child = spawn(
112
+ shell,
113
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file, ...args],
114
+ { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
115
+ )
116
+ let out = ''
117
+ let err = ''
118
+ child.stdout.setEncoding('utf8')
119
+ child.stderr.setEncoding('utf8')
120
+ child.stdout.on('data', (chunk) => { out += chunk })
121
+ child.stderr.on('data', (chunk) => { err += chunk })
122
+ child.on('error', (error) => { reject(error) })
123
+ child.on('close', (code) => {
124
+ const line = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop()
125
+ if (line === undefined) {
126
+ reject(new Error(`hud: ${script} produced no JSON (exit ${String(code)})${err ? `: ${err.trim().slice(0, 300)}` : ''}`))
127
+ return
128
+ }
129
+ let parsed
130
+ try { parsed = JSON.parse(line) } catch (error) { reject(new Error(`hud: ${script} returned unparsable JSON: ${error.message}`)); return }
131
+ if (parsed.status !== 'ok') { reject(new Error(`hud: ${script} failed: ${String(parsed.error ?? 'unknown')}`)); return }
132
+ resolve(parsed.data ?? {})
133
+ })
134
+ })
135
+ }
136
+
137
+ /** Locate an installed Chromium browser that supports `--app`. */
138
+ function findBrowser(configured) {
139
+ if (configured && existsSync(configured)) return configured
140
+ const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
141
+ const pf86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
142
+ const candidates = [
143
+ join(pf86, 'Microsoft\\Edge\\Application\\msedge.exe'),
144
+ join(pf, 'Microsoft\\Edge\\Application\\msedge.exe'),
145
+ join(pf, 'Google\\Chrome\\Application\\chrome.exe'),
146
+ join(pf86, 'Google\\Chrome\\Application\\chrome.exe'),
147
+ join(process.env.LOCALAPPDATA ?? pf, 'Google\\Chrome\\Application\\chrome.exe'),
148
+ ]
149
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate
150
+ throw new Error('no Edge or Chrome found; set `browserPath` in the plugin config')
151
+ }
152
+
153
+ /**
154
+ * Add the HUD flag to a URL without disturbing an existing query string or hash.
155
+ * The GUI may route with `#/...`, so the flag must land before the hash.
156
+ */
157
+ function withQuery(href, key, value) {
158
+ const hashIndex = href.indexOf('#')
159
+ const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href
160
+ const hash = hashIndex >= 0 ? href.slice(hashIndex) : ''
161
+ const sep = beforeHash.includes('?') ? '&' : '?'
162
+ return `${beforeHash}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`
163
+ }
164
+
165
+ function withHudFlag(href) {
166
+ return withQuery(href, HUD_FLAG, '1')
167
+ }
168
+
169
+ function assertLocalHref(href) {
170
+ let url
171
+ try { url = new URL(href) } catch { throw new Error('href is not a URL') }
172
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('href must be http(s)')
173
+ const host = url.hostname
174
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
175
+ throw new Error(`refusing to pop out a non-local URL (host: ${host})`)
176
+ }
177
+ return url
178
+ }
179
+
180
+ /**
181
+ * The address the Web carrier is reachable at. The desktop window cannot supply it
182
+ * (its own `location.href` is a custom scheme), so the host resolves it here.
183
+ */
184
+ function advertise(ctx) {
185
+ try {
186
+ const webApp = typeof ctx.get === 'function' ? ctx.get('webApp') : undefined
187
+ if (webApp !== undefined && webApp !== null && typeof webApp.getAdvertisedUrl === 'function') {
188
+ const url = String(webApp.getAdvertisedUrl() ?? '')
189
+ if (url !== '') return url
190
+ }
191
+ } catch {
192
+ // Fall through to the carrier origin.
193
+ }
194
+ try {
195
+ const origin = ctx.webServer.getOrigin()
196
+ if (typeof origin === 'string' && origin !== '') return origin
197
+ } catch {
198
+ // Nothing advertised at all.
199
+ }
200
+ return ''
201
+ }
202
+
203
+ const CONFIG_PATH = join(WORK_DIR, 'config.ini')
204
+
205
+ const CONFIG_DEFAULTS = { copyShot: true, ask: true, hotkeyFull: 'Ctrl+Shift+1', hotkeyRegion: 'Ctrl+Shift+2' }
206
+
207
+ /**
208
+ * Settings live in a flat `key=value` file because the window shell (C# 5) and this
209
+ * plugin both read them and neither should need a JSON parser to do it.
210
+ */
211
+ function readConfig() {
212
+ const config = { ...CONFIG_DEFAULTS }
213
+ try {
214
+ const text = readFileSync(CONFIG_PATH, 'utf8')
215
+ for (const raw of text.split(/\r?\n/)) {
216
+ const line = raw.trim()
217
+ if (line === '' || line.startsWith('#')) continue
218
+ const eq = line.indexOf('=')
219
+ if (eq <= 0) continue
220
+ const key = line.slice(0, eq).trim()
221
+ const value = line.slice(eq + 1).trim()
222
+ if (key === 'copyShot' || key === 'ask') config[key] = value !== '0'
223
+ else if (key === 'hotkeyFull' || key === 'hotkeyRegion') config[key] = value
224
+ }
225
+ } catch {
226
+ // Missing file means defaults.
227
+ }
228
+ return config
229
+ }
230
+
231
+ function writeConfig(config) {
232
+ mkdirSync(WORK_DIR, { recursive: true })
233
+ const lines = [
234
+ '# DSH 小窗设置 / settings. The C# shell reads this file too.',
235
+ `copyShot=${config.copyShot ? '1' : '0'}`,
236
+ `ask=${config.ask ? '1' : '0'}`,
237
+ `hotkeyFull=${config.hotkeyFull}`,
238
+ `hotkeyRegion=${config.hotkeyRegion}`,
239
+ ]
240
+ writeFileSync(CONFIG_PATH, lines.join('\n') + '\n', 'utf8')
241
+ }
242
+
243
+ /** Tokenized address the shell and the background service can both open. */
244
+ function tokenizedAddress(ctx) {
245
+ const base = advertise(ctx)
246
+ if (base === '') return ''
247
+ try {
248
+ const target = new URL(base)
249
+ const clean = `${target.origin}${target.pathname}`
250
+ const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
251
+ if (connection !== undefined && connection !== null && typeof connection.authenticatedUrl === 'function') {
252
+ return connection.authenticatedUrl(clean)
253
+ }
254
+ return clean
255
+ } catch {
256
+ return base
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Start the always-on helper. It owns the "问大肥鱼" pill and the global screenshot
262
+ * hotkeys, so both keep working while the small window is closed. The exe enforces
263
+ * single instance itself, which makes a blind spawn safe.
264
+ */
265
+ function ensureService(ctx) {
266
+ if (!existsSync(NATIVE_EXE)) return null
267
+ const url = tokenizedAddress(ctx)
268
+ try {
269
+ const child = spawn(NATIVE_EXE, ['--service', '--url', url], {
270
+ detached: true,
271
+ stdio: 'ignore',
272
+ windowsHide: true,
273
+ })
274
+ child.unref()
275
+ return child.pid ?? null
276
+ } catch {
277
+ return null
278
+ }
279
+ }
280
+
281
+ export function apply(ctx, config = {}) {
282
+ const titlePrefix = String(config.titlePrefix ?? 'DSH 小窗')
283
+ const titleB64 = Buffer.from(titlePrefix, 'utf8').toString('base64')
284
+ const width = Number(config.width ?? 460)
285
+ const height = Number(config.height ?? 920)
286
+ const margin = Number(config.margin ?? 8)
287
+ const disposers = []
288
+ let spawned = null
289
+
290
+ // The pill and the global hotkeys must not depend on the small window being open,
291
+ // so the helper starts with the plugin. The carrier may not be up yet, hence the delay.
292
+ setTimeout(() => { try { ensureService(ctx) } catch { } }, 2500)
293
+
294
+ /** The browser pid we launched, so later requests find the window without the title. */
295
+ const ownerPid = () => (spawned !== null && Number.isFinite(spawned.pid) ? String(spawned.pid) : '0')
296
+
297
+ const route = (path, handler) => {
298
+ disposers.push(ctx.webServer.register({ kind: 'exact', path: `${PREFIX}${path}`, handler }))
299
+ }
300
+
301
+ /**
302
+ * Diagnostic breadcrumb channel. The client cannot log anywhere we can read, so
303
+ * `/trace?step=...` (hit through a one-pixel image request) appends to a file.
304
+ */
305
+ route('/trace', (req, res) => {
306
+ try {
307
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
308
+ const line = `${new Date().toISOString()} ${url.searchParams.get('step') ?? '?'} ${url.searchParams.get('detail') ?? ''}\n`
309
+ mkdirSync(WORK_DIR, { recursive: true })
310
+ appendFileSync(join(WORK_DIR, 'trace.log'), line, 'utf8')
311
+ } catch {
312
+ // Diagnostics must never break the request.
313
+ }
314
+ res.writeHead(204, { 'cache-control': 'no-store' })
315
+ res.end()
316
+ })
317
+
318
+ let shotSeq = 0
319
+
320
+ route('/config', async (req, res) => {
321
+ if (req.method === 'GET') {
322
+ sendJson(res, 200, { ok: true, config: readConfig() })
323
+ return
324
+ }
325
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'GET or POST' }); return }
326
+ let body
327
+ try { body = await readJson(req) } catch (error) { sendJson(res, 400, { error: error.message }); return }
328
+ const next = readConfig()
329
+ for (const key of ['copyShot', 'ask', 'hotkeyFull', 'hotkeyRegion']) {
330
+ if (Object.prototype.hasOwnProperty.call(body, key)) next[key] = body[key]
331
+ }
332
+ // Normalize before writing: the shell matches these strings exactly when it
333
+ // registers the global hotkeys.
334
+ next.copyShot = Boolean(next.copyShot)
335
+ next.ask = Boolean(next.ask)
336
+ next.hotkeyFull = String(next.hotkeyFull ?? '').trim() || CONFIG_DEFAULTS.hotkeyFull
337
+ next.hotkeyRegion = String(next.hotkeyRegion ?? '').trim() || CONFIG_DEFAULTS.hotkeyRegion
338
+ writeConfig(next)
339
+ sendJson(res, 200, { ok: true, config: next })
340
+ })
341
+
342
+ route('/open', async (req, res) => {
343
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
344
+ let body
345
+ try { body = await readJson(req) } catch (error) { sendJson(res, 400, { error: error.message }); return }
346
+ try {
347
+ // The desktop renderer is not served over http(s): there `window.location.href`
348
+ // is a file:// or app:// address, so a client-supplied href is unusable. Only
349
+ // accept it when it really is an http(s) URL, and otherwise ask the Web
350
+ // carrier for the address it advertises.
351
+ const offered = String(body.href ?? '')
352
+ const advertised = /^https?:/i.test(offered) ? offered : advertise(ctx)
353
+ if (advertised === '') {
354
+ sendJson(res, 400, { error: 'no web address available; pass an http(s) href' })
355
+ return
356
+ }
357
+ const href = advertised
358
+ assertLocalHref(href)
359
+
360
+ // Already open? Raise the existing window instead of stacking duplicates.
361
+ const status = await runScript('win-hud.ps1', ['-Action', 'status', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '0']).catch(() => ({ found: false }))
362
+ if (status.found === true || status['found'] === true) {
363
+ const focused = await runScript('win-hud.ps1', ['-Action', 'focus', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '0']).catch(() => null)
364
+ const pinned = await runScript('win-hud.ps1', ['-Action', 'pin', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '2000', '-Width', String(width), '-Height', String(height), '-Margin', String(margin)]).catch(() => null)
365
+ sendJson(res, 200, { ok: true, reused: true, focused: focused !== null, window: pinned ?? status })
366
+ return
367
+ }
368
+
369
+ // The HUD gets a *fresh* browser profile, so it holds none of the main
370
+ // window's cookies. Without the process token the server answers "dsh web
371
+ // authentication required", so ask Connection for a tokenized URL.
372
+ const target = new URL(href)
373
+ const base = `${target.origin}${target.pathname}`
374
+ let authenticated = base
375
+ let authSource = 'href'
376
+ try {
377
+ const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
378
+ if (connection !== undefined && connection !== null && typeof connection.authenticatedUrl === 'function') {
379
+ authenticated = connection.authenticatedUrl(base)
380
+ authSource = 'token'
381
+ }
382
+ } catch {
383
+ // No token available: fall back to the caller's URL, which may already carry one.
384
+ }
385
+ const url0 = authSource === 'token' ? `${withHudFlag(authenticated)}${target.hash}` : withHudFlag(href)
386
+ // The small window is a separate document with no shared client state, so it
387
+ // learns which conversation to show from the URL.
388
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
389
+ const url = sessionId === '' ? url0 : withQuery(url0, 'session', sessionId)
390
+
391
+ // Preferred backend: the WebView2 shell we build ourselves. It is a real
392
+ // native window (no browser chrome), it pins itself, and it carries its own
393
+ // title bar with the screenshot key. The browser --app path stays as a
394
+ // fallback for machines where the exe has not been built yet.
395
+ ensureService(ctx)
396
+
397
+ const nativeAvailable = existsSync(NATIVE_EXE)
398
+ let backend = 'browser'
399
+ let browser = null
400
+ if (nativeAvailable) {
401
+ backend = 'native'
402
+ // A running shell owns the window: hand it the new URL instead of starting
403
+ // a second process (the exe's own single-instance path does the same, but
404
+ // this way the response reports the real pid and `reused`).
405
+ const existing = await runScript('native-status.ps1', []).catch(() => ({ found: false }))
406
+ if (existing.found === true) {
407
+ // Only re-navigate when the caller asked for a specific conversation; a
408
+ // plain "pop out" on the window that is already open should just raise it.
409
+ if (sessionId === '') {
410
+ await runScript('native-status.ps1', ['-Action', 'focus']).catch(() => null)
411
+ } else {
412
+ writePendingUrl(url)
413
+ }
414
+ sendJson(res, 200, { ok: true, reused: true, backend, pid: existing.pid, sessionId, url })
415
+ return
416
+ }
417
+ spawned = spawn(NATIVE_EXE, ['--url', url, '--title', titlePrefix, '--width', String(width), '--height', String(height)], {
418
+ detached: true,
419
+ stdio: 'ignore',
420
+ windowsHide: false,
421
+ })
422
+ spawned.unref()
423
+ } else {
424
+ browser = findBrowser(config.browserPath)
425
+ mkdirSync(PROFILE_DIR, { recursive: true })
426
+ const args = [
427
+ `--app=${url}`,
428
+ `--window-size=${width},${height}`,
429
+ `--window-position=${Math.max(0, margin)},${margin}`,
430
+ `--user-data-dir=${PROFILE_DIR}`,
431
+ '--no-first-run',
432
+ '--no-default-browser-check',
433
+ '--disable-features=Translate,MediaRouter',
434
+ ]
435
+ spawned = spawn(browser, args, { detached: true, stdio: 'ignore', windowsHide: true })
436
+ spawned.unref()
437
+ }
438
+
439
+ // An --app launch may re-exec itself, so the pid we spawned can be gone by
440
+ // now; the helper also matches the window by its title, which the HUD page
441
+ // sets as soon as it boots. A failed pin is reported, not thrown: the window
442
+ // is already open at that point and the user can still use it.
443
+ let window = null
444
+ let warning = null
445
+ if (backend === 'browser') {
446
+ try {
447
+ window = await runScript('win-hud.ps1', [
448
+ '-Action', 'pin', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '20000',
449
+ '-Width', String(width), '-Height', String(height), '-Margin', String(margin),
450
+ ])
451
+ } catch (error) {
452
+ warning = String(error?.message ?? error)
453
+ }
454
+ }
455
+ sendJson(res, 200, { ok: true, reused: false, backend, browser, url, authSource, pid: spawned.pid ?? null, window, warning })
456
+ } catch (error) {
457
+ sendJson(res, 500, { error: String(error?.message ?? error) })
458
+ }
459
+ })
460
+
461
+ route('/close', async (req, res) => {
462
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
463
+ try {
464
+ const data = await runScript('win-hud.ps1', ['-Action', 'close', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '0'])
465
+ sendJson(res, 200, { ok: true, ...data })
466
+ } catch (error) {
467
+ sendJson(res, 500, { error: String(error?.message ?? error) })
468
+ }
469
+ })
470
+
471
+ route('/focus', async (req, res) => {
472
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
473
+ try {
474
+ const data = await runScript('win-hud.ps1', ['-Action', 'focus', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '0'])
475
+ sendJson(res, 200, { ok: true, ...data })
476
+ } catch (error) {
477
+ sendJson(res, 500, { error: String(error?.message ?? error) })
478
+ }
479
+ })
480
+
481
+ route('/state', async (req, res) => {
482
+ if (req.method !== 'GET') { sendJson(res, 405, { error: 'GET only' }); return }
483
+ try {
484
+ const data = await runScript('win-hud.ps1', ['-Action', 'status', '-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-WaitMs', '0'])
485
+ sendJson(res, 200, { ok: true, ...data, titlePrefix, width, height })
486
+ } catch (error) {
487
+ sendJson(res, 500, { error: String(error?.message ?? error) })
488
+ }
489
+ })
490
+
491
+ route('/shot', async (req, res) => {
492
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
493
+ let body
494
+ try { body = await readJson(req) } catch (error) { sendJson(res, 400, { error: error.message }); return }
495
+ try {
496
+ mkdirSync(SHOT_DIR, { recursive: true })
497
+ shotSeq += 1
498
+ const out = join(SHOT_DIR, `hud-shot-${Date.now()}-${String(shotSeq)}.png`)
499
+ const args = ['-TitleB64', titleB64, '-OwnerPid', ownerPid(), '-OutFile', out]
500
+ if (body.keepHud === true) args.push('-KeepHud')
501
+ const data = await runScript('hud-shot.ps1', args)
502
+ const bytes = readFileSync(out)
503
+ sendJson(res, 200, {
504
+ ok: true,
505
+ file: data.file ?? out,
506
+ png: `data:image/png;base64,${bytes.toString('base64')}`,
507
+ width: data.width, height: data.height,
508
+ bytes: bytes.length,
509
+ hidHud: (data.hidden ?? 0) > 0,
510
+ })
511
+ } catch (error) {
512
+ sendJson(res, 500, { error: String(error?.message ?? error) })
513
+ }
514
+ })
515
+
516
+ ctx.effect(() => () => {
517
+ for (const dispose of disposers) {
518
+ try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
519
+ }
520
+ })
521
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "dsh-mini",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DSH 常驻小窗插件:把当前会话一键弹出为独立置顶小窗(WebView2 原生窗口),含整屏/框选截图、划词「问大肥鱼」、全局快捷键与截图自动复制",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "dsh-plugin",
12
+ "always-on-top",
13
+ "screenshot",
14
+ "webview2"
15
+ ],
16
+ "homepage": "https://www.npmjs.com/package/dsh-mini",
17
+ "os": [
18
+ "win32"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "default": "./lib/index.js"
26
+ },
27
+ "./client": {
28
+ "default": "./lib/client.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "lib",
34
+ "scripts",
35
+ "app/bin",
36
+ "app/*.cs",
37
+ "app/hud-page.js",
38
+ "app/app.manifest",
39
+ "app/build-app.ps1",
40
+ "app/dev.ps1",
41
+ "app/probe",
42
+ "app/vendor/*.dll",
43
+ "cordis.patch.yml",
44
+ "README.md",
45
+ "开发方案-WebView2原生小窗.md"
46
+ ],
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web",
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-ui-slots",
55
+ "@deepseek-ai/dsh-client-ui-conversation"
56
+ ]
57
+ },
58
+ "compatibility": {
59
+ "node": ">=20",
60
+ "dsh": ">=0.2.0-rc.1"
61
+ }
62
+ },
63
+ "dependencies": {},
64
+ "scripts": {
65
+ "build": "powershell -NoProfile -ExecutionPolicy Bypass -File app/build-app.ps1",
66
+ "pack": "npm pack"
67
+ },
68
+ "peerDependencies": {
69
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.2.0-rc.1"
70
+ }
71
+ }