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/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,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mini",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH 常驻小窗插件:把当前会话一键弹出为独立置顶小窗(WebView2 原生窗口),含整屏/框选截图、划词「问大肥鱼」、全局快捷键与截图自动复制",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["dsh", "deepseek-harness", "dsh-plugin", "always-on-top", "screenshot", "webview2"],
|
|
9
|
+
"homepage": "https://www.npmjs.com/package/dsh-mini",
|
|
10
|
+
"os": ["win32"],
|
|
11
|
+
"engines": { "node": ">=20" },
|
|
12
|
+
"exports": {
|
|
13
|
+
".": { "default": "./lib/index.js" },
|
|
14
|
+
"./client": { "default": "./lib/client.js" },
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"scripts",
|
|
20
|
+
"app/bin",
|
|
21
|
+
"app/*.cs",
|
|
22
|
+
"app/hud-page.js",
|
|
23
|
+
"app/app.manifest",
|
|
24
|
+
"app/build-app.ps1",
|
|
25
|
+
"app/dev.ps1",
|
|
26
|
+
"app/probe",
|
|
27
|
+
"app/vendor/*.dll",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"开发方案-WebView2原生小窗.md"
|
|
31
|
+
],
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"compatibility": { "node": ">=20", "dsh": ">=0.2.0-rc.1" }
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "powershell -NoProfile -ExecutionPolicy Bypass -File app/build-app.ps1",
|
|
46
|
+
"pack": "npm pack"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.0-rc.1"
|
|
50
|
+
}
|
|
51
|
+
}
|