dsh-desktop-windows 0.4.0 → 0.4.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/package.json +2 -1
- package/src/main.js +166 -15
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-desktop-windows",
|
|
3
3
|
"productName": "DeepSeek Harness Desktop",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"description": "DeepSeek Harness 桌面版(Windows)· Electron desktop shell for DeepSeek Harness — 双击即用 / double-click to run · 支持选区截图提问、系统托盘、会话管理 / region screenshot, system tray, session manager",
|
|
6
6
|
"main": "src/main.js",
|
|
7
7
|
"license": "MIT",
|
|
@@ -67,3 +67,4 @@
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
|
package/src/main.js
CHANGED
|
@@ -351,24 +351,81 @@ let isShuttingDownDsh = false
|
|
|
351
351
|
// 已自动重启过一次的标志(同一次会话最多自愈一次,避免崩溃循环)
|
|
352
352
|
let didAutoRestartDsh = false
|
|
353
353
|
|
|
354
|
+
/* ------------------------------------------------------------------ *
|
|
355
|
+
* dsh 0.1.5+ 启动令牌(launch token)
|
|
356
|
+
*
|
|
357
|
+
* 从 0.1.5 起 dsh web 的 Web UI 需要启动时打印的带 token URL 才能访问:
|
|
358
|
+
* 首次带 ?token=... 访问 → 服务端种下会话 cookie → 之后访问干净的 / 即可。
|
|
359
|
+
* 本壳捕获那行 URL 并用它加载窗口,cookie 建好后 F5 刷新照常工作。
|
|
360
|
+
* 若服务不是本壳启动的(外部/WSL),拿不到 token,只能提示用户。
|
|
361
|
+
* ------------------------------------------------------------------ */
|
|
362
|
+
|
|
363
|
+
let localLaunchUrl = null
|
|
364
|
+
let launchUrlWaiters = []
|
|
365
|
+
const LAUNCH_URL_RE = /dsh web:\s*(https?:\/\/[^\s()]+)/
|
|
366
|
+
const LAUNCH_URL_TIMEOUT_MS = 15_000
|
|
367
|
+
|
|
368
|
+
/** 从 dsh 输出流里捕获带 token 的 URL(只认第一行)。 */
|
|
369
|
+
function captureLaunchUrl(text) {
|
|
370
|
+
if (localLaunchUrl) return
|
|
371
|
+
const m = LAUNCH_URL_RE.exec(text)
|
|
372
|
+
if (!m) return
|
|
373
|
+
localLaunchUrl = m[1]
|
|
374
|
+
// 只记「捕获到了」,不把 token 写进日志
|
|
375
|
+
log('captured authenticated dsh web url')
|
|
376
|
+
const waiters = launchUrlWaiters
|
|
377
|
+
launchUrlWaiters = []
|
|
378
|
+
for (const resolve of waiters) resolve(localLaunchUrl)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 等带 token 的 URL 出现;超时返回 null(不阻塞启动流程)。 */
|
|
382
|
+
function waitForLaunchUrl(timeoutMs = LAUNCH_URL_TIMEOUT_MS) {
|
|
383
|
+
if (localLaunchUrl) return Promise.resolve(localLaunchUrl)
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
const onUrl = (url) => {
|
|
386
|
+
clearTimeout(timer)
|
|
387
|
+
resolve(url)
|
|
388
|
+
}
|
|
389
|
+
const timer = setTimeout(() => {
|
|
390
|
+
launchUrlWaiters = launchUrlWaiters.filter((w) => w !== onUrl)
|
|
391
|
+
resolve(null)
|
|
392
|
+
}, timeoutMs)
|
|
393
|
+
launchUrlWaiters.push(onUrl)
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 重启 dsh 前重置,避免用上一次的旧 token。 */
|
|
398
|
+
function resetLaunchUrl() {
|
|
399
|
+
localLaunchUrl = null
|
|
400
|
+
}
|
|
401
|
+
|
|
354
402
|
/** 启动 dsh web,日志追加到 userData/dsh.log。返回子进程。 */
|
|
355
403
|
function startDsh(port) {
|
|
356
404
|
const bin = resolveDshBin()
|
|
357
405
|
const logFile = path.join(app.getPath('userData'), 'dsh.log')
|
|
358
|
-
|
|
406
|
+
// 用管道而不是直接写 fd:既要落日志,又要捕获启动时打印的带 token URL
|
|
407
|
+
const out = fs.createWriteStream(logFile, { flags: 'a' })
|
|
359
408
|
// shell:true 时把参数拼进命令字符串(参数均为常量),避免 DEP0190 警告
|
|
360
409
|
// --no-open 关闭 dsh 默认的自动打开浏览器行为(桌面壳自己用 BrowserWindow 加载,不弹浏览器)
|
|
361
410
|
const cmd = `"${bin}" web --port ${port} --no-open`
|
|
362
411
|
const child = spawn(cmd, {
|
|
363
412
|
shell: true,
|
|
364
413
|
windowsHide: true,
|
|
365
|
-
stdio: ['ignore',
|
|
414
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
366
415
|
env: { ...process.env },
|
|
367
416
|
})
|
|
417
|
+
const onData = (chunk) => {
|
|
418
|
+
const text = chunk.toString()
|
|
419
|
+
out.write(text)
|
|
420
|
+
captureLaunchUrl(text)
|
|
421
|
+
}
|
|
422
|
+
child.stdout.on('data', onData)
|
|
423
|
+
child.stderr.on('data', onData)
|
|
368
424
|
log(`spawned dsh: ${cmd} (pid ${child.pid})`)
|
|
369
425
|
log(`dsh log: ${logFile}`)
|
|
370
426
|
child.on('exit', (code, signal) => {
|
|
371
427
|
log(`dsh exited: code=${code} signal=${signal}`)
|
|
428
|
+
out.end()
|
|
372
429
|
if (ownedDsh !== child) return
|
|
373
430
|
ownedDsh = null
|
|
374
431
|
// 崩溃自愈:本壳启动的 dsh 意外退出(非主动关闭/非退出中)时自动重启一次
|
|
@@ -409,12 +466,16 @@ function restartOwnedDsh(port, exitCode) {
|
|
|
409
466
|
log('port already serving after crash; not restarting')
|
|
410
467
|
return
|
|
411
468
|
}
|
|
469
|
+
resetLaunchUrl()
|
|
412
470
|
const child = startDsh(port)
|
|
413
471
|
ownedDsh = child
|
|
414
472
|
// 等它就绪;若起不来则等 exit 回调里的二次提示
|
|
415
|
-
waitForServer(port, START_TIMEOUT_MS).then((ready) => {
|
|
473
|
+
waitForServer(port, START_TIMEOUT_MS).then(async (ready) => {
|
|
416
474
|
if (ready) {
|
|
417
475
|
log('dsh auto-restarted successfully')
|
|
476
|
+
// 新进程有新 token:等捕获后重新加载页面
|
|
477
|
+
await waitForLaunchUrl()
|
|
478
|
+
loadApp()
|
|
418
479
|
dialog.showMessageBox(mainWindow || undefined, {
|
|
419
480
|
type: 'info',
|
|
420
481
|
title: 'dsh 已自动重启',
|
|
@@ -428,6 +489,22 @@ function restartOwnedDsh(port, exitCode) {
|
|
|
428
489
|
}, 3000)
|
|
429
490
|
}
|
|
430
491
|
|
|
492
|
+
/** 查监听指定端口的进程 PID(netstat 解析,找不到返回 null)。 */
|
|
493
|
+
function portOwnerPid(port) {
|
|
494
|
+
return new Promise((resolve) => {
|
|
495
|
+
execFile('netstat', ['-ano', '-p', 'tcp'], { windowsHide: true }, (err, stdout) => {
|
|
496
|
+
if (err || !stdout) return resolve(null)
|
|
497
|
+
const line = String(stdout)
|
|
498
|
+
.split('\n')
|
|
499
|
+
.find((l) => l.includes(`:${port} `) && /LISTENING/i.test(l))
|
|
500
|
+
if (!line) return resolve(null)
|
|
501
|
+
const parts = line.trim().split(/\s+/)
|
|
502
|
+
const pid = Number(parts[parts.length - 1])
|
|
503
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : null)
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
431
508
|
/** 杀掉进程树(Windows 用 taskkill /T 覆盖子孙进程),带超时兜底。 */
|
|
432
509
|
function killProcessTree(pid) {
|
|
433
510
|
return new Promise((resolve) => {
|
|
@@ -458,23 +535,50 @@ async function shutdownOwnedDsh() {
|
|
|
458
535
|
async function restartDshService() {
|
|
459
536
|
if (!ownedDsh) {
|
|
460
537
|
log('restart: no owned dsh to restart')
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
538
|
+
if (!targetIsLocal) {
|
|
539
|
+
// 远程模式:本壳无权重启对方服务器
|
|
540
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
541
|
+
type: 'info',
|
|
542
|
+
title: '无法重启',
|
|
543
|
+
message: '当前连接的是远程服务器上的 dsh。',
|
|
544
|
+
detail: '请在服务器上重启该服务(如 docker compose 重建容器),然后刷新页面(F5)。',
|
|
545
|
+
buttons: ['好'],
|
|
546
|
+
noLink: true,
|
|
547
|
+
})
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
// 本机、但服务不是本壳启动的(残留/手动启动):dsh 0.1.5+ 需要启动令牌,
|
|
551
|
+
// 本壳拿不到,页面会 401。经用户确认后接管:结束占用端口的进程,再由本壳启动。
|
|
552
|
+
const port = resolvePort()
|
|
553
|
+
const { response } = await dialog.showMessageBox(mainWindow || undefined, {
|
|
554
|
+
type: 'question',
|
|
555
|
+
title: '接管本机 dsh 服务',
|
|
556
|
+
message: `端口 ${port} 上有一个不是本应用启动的 dsh 服务。`,
|
|
557
|
+
detail:
|
|
558
|
+
'dsh 0.1.5 起,Web UI 需要启动时打印的令牌才能访问,外部启动的服务本应用无法自动连接。\n\n' +
|
|
559
|
+
'是否结束该进程,并由本应用重新启动 dsh?(当前页面会短暂中断)',
|
|
560
|
+
buttons: ['接管并重启', '取消'],
|
|
561
|
+
defaultId: 0,
|
|
562
|
+
cancelId: 1,
|
|
468
563
|
noLink: true,
|
|
469
564
|
})
|
|
470
|
-
return
|
|
565
|
+
if (response !== 0) return
|
|
566
|
+
const pid = await portOwnerPid(port)
|
|
567
|
+
if (pid) {
|
|
568
|
+
log(`restart: taking over external dsh on port ${port} (pid ${pid})`)
|
|
569
|
+
await killProcessTree(pid)
|
|
570
|
+
await new Promise((r) => setTimeout(r, 800))
|
|
571
|
+
} else {
|
|
572
|
+
log(`restart: no listener found on port ${port}; starting a fresh service`)
|
|
573
|
+
}
|
|
471
574
|
}
|
|
472
575
|
const port = resolvePort()
|
|
473
576
|
log(`restart: restarting dsh service on port ${port}…`)
|
|
474
577
|
// 先停(标记主动关停,避免触发崩溃自愈逻辑)
|
|
475
578
|
await shutdownOwnedDsh()
|
|
476
579
|
isShuttingDownDsh = false // 重置,让后续退出能触发自愈
|
|
477
|
-
//
|
|
580
|
+
// 重新启动(换新 token,先清掉旧的)
|
|
581
|
+
resetLaunchUrl()
|
|
478
582
|
ownedDsh = startDsh(port)
|
|
479
583
|
const ready = await waitForServer(port, START_TIMEOUT_MS)
|
|
480
584
|
if (!ready) {
|
|
@@ -489,10 +593,11 @@ async function restartDshService() {
|
|
|
489
593
|
})
|
|
490
594
|
return
|
|
491
595
|
}
|
|
596
|
+
// 等新的带 token URL(dsh 0.1.5+)
|
|
597
|
+
await waitForLaunchUrl()
|
|
492
598
|
log('restart: dsh back up; reloading window')
|
|
493
|
-
// 等页面也能加载后刷新
|
|
494
599
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
495
|
-
|
|
600
|
+
loadApp()
|
|
496
601
|
}
|
|
497
602
|
dialog.showMessageBox(mainWindow || undefined, {
|
|
498
603
|
type: 'info',
|
|
@@ -588,6 +693,7 @@ async function performDshUpdate(targetVersion) {
|
|
|
588
693
|
const port = resolvePort()
|
|
589
694
|
await shutdownOwnedDsh()
|
|
590
695
|
isShuttingDownDsh = false // 重置,让后续崩溃自愈逻辑仍生效
|
|
696
|
+
resetLaunchUrl()
|
|
591
697
|
ownedDsh = startDsh(port)
|
|
592
698
|
|
|
593
699
|
// 4) 轮询直到服务恢复,然后重载页面
|
|
@@ -600,6 +706,8 @@ async function performDshUpdate(targetVersion) {
|
|
|
600
706
|
}
|
|
601
707
|
setUpdateProgress(-1)
|
|
602
708
|
if (back) {
|
|
709
|
+
// 新版 dsh 同样需要带 token 的 URL(0.1.5+)
|
|
710
|
+
await waitForLaunchUrl()
|
|
603
711
|
loadApp()
|
|
604
712
|
} else {
|
|
605
713
|
log('dsh update: service did not come back within timeout')
|
|
@@ -1225,7 +1333,46 @@ function showMainWindow() {
|
|
|
1225
1333
|
|
|
1226
1334
|
/** 回到应用主页面(不能用 reload()——它会把临时页如更新进度页再刷一遍,把人困住)。 */
|
|
1227
1335
|
function loadApp() {
|
|
1228
|
-
if (mainWindow
|
|
1336
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
1337
|
+
// 本机模式优先用捕获到的带 token URL(dsh 0.1.5+ 必需;cookie 建立后普通刷新即可)
|
|
1338
|
+
const url = targetIsLocal && localLaunchUrl ? localLaunchUrl : target
|
|
1339
|
+
if (!url) return
|
|
1340
|
+
guardAuthRequired()
|
|
1341
|
+
mainWindow.loadURL(url).catch(() => {})
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
let authNoticeShown = false
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* 若页面落到「authentication required」401(常见于复用了外部启动的 dsh,
|
|
1348
|
+
* 本壳拿不到启动令牌),给出可执行的指引而不是让用户看一屏报错。
|
|
1349
|
+
*/
|
|
1350
|
+
function guardAuthRequired() {
|
|
1351
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
1352
|
+
mainWindow.webContents.once('did-finish-load', async () => {
|
|
1353
|
+
if (authNoticeShown) return
|
|
1354
|
+
try {
|
|
1355
|
+
const text = await mainWindow.webContents.executeJavaScript(
|
|
1356
|
+
'document.body ? document.body.innerText.slice(0, 300) : ""'
|
|
1357
|
+
)
|
|
1358
|
+
if (!/authentication required/i.test(String(text))) return
|
|
1359
|
+
authNoticeShown = true
|
|
1360
|
+
dialog.showMessageBox(mainWindow, {
|
|
1361
|
+
type: 'warning',
|
|
1362
|
+
title: 'dsh 服务需要启动令牌',
|
|
1363
|
+
message: '当前运行中的 dsh 服务需要启动令牌才能访问(dsh 0.1.5+ 的新认证机制)。',
|
|
1364
|
+
detail:
|
|
1365
|
+
'该服务不是本桌面壳启动的,因此拿不到令牌。\n\n' +
|
|
1366
|
+
'解决办法:托盘右键(或 Alt → 文件)→「重启 dsh 服务」,选择「接管并重启」,' +
|
|
1367
|
+
'由桌面壳结束该进程并重新启动 dsh 即可。\n\n' +
|
|
1368
|
+
'也可以完全退出 DeepSeek Harness Desktop 并结束已有 dsh web 进程后,重新打开桌面壳。',
|
|
1369
|
+
buttons: ['好'],
|
|
1370
|
+
noLink: true,
|
|
1371
|
+
}).catch(() => {})
|
|
1372
|
+
} catch {
|
|
1373
|
+
/* 忽略检测失败 */
|
|
1374
|
+
}
|
|
1375
|
+
})
|
|
1229
1376
|
}
|
|
1230
1377
|
|
|
1231
1378
|
/** 显示连接页。mode: first=首次使用 / offline=连不上 / switch=从应用主动换服务器 */
|
|
@@ -1297,6 +1444,7 @@ async function startLocalMode({ persist = true, fromStartup = false } = {}) {
|
|
|
1297
1444
|
let external = await probe(port)
|
|
1298
1445
|
if (!external) {
|
|
1299
1446
|
// 2) 没有 → 自己启动,等就绪
|
|
1447
|
+
resetLaunchUrl()
|
|
1300
1448
|
ownedDsh = startDsh(port)
|
|
1301
1449
|
const ready = await waitForServer(port, START_TIMEOUT_MS)
|
|
1302
1450
|
if (!ready) {
|
|
@@ -1318,6 +1466,9 @@ async function startLocalMode({ persist = true, fromStartup = false } = {}) {
|
|
|
1318
1466
|
log('reusing external dsh service on port ' + port)
|
|
1319
1467
|
}
|
|
1320
1468
|
|
|
1469
|
+
// 2.5) 本壳启动的 dsh:等带 token 的 URL 出现(dsh 0.1.5+ 必需)
|
|
1470
|
+
if (!external) await waitForLaunchUrl()
|
|
1471
|
+
|
|
1321
1472
|
// 3) 就绪:首次创建窗口(连接页触发时窗口已存在,直接进入)
|
|
1322
1473
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
1323
1474
|
createWindow({ mode: 'app' })
|