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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. 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.0",
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
- const out = fs.openSync(logFile, 'a')
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', out, out],
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
- // 没有自管的 dsh(可能复用了外部服务)→ 无法安全重启,提示用户
462
- dialog.showMessageBox(mainWindow || undefined, {
463
- type: 'info',
464
- title: '无法重启',
465
- message: '当前使用的是外部已有的 dsh 服务,不是本应用启动的,无法从这里重启。',
466
- detail: '请在外部终端手动重启该服务,然后刷新页面(F5)。',
467
- buttons: [''],
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
- mainWindow.loadURL(`http://127.0.0.1:${port}`)
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 && !mainWindow.isDestroyed() && target) mainWindow.loadURL(target).catch(() => {})
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' })