@lenorin/dsh-tauri-launcher 1.0.0 → 1.0.2

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 (37) hide show
  1. package/LICENSE +1 -1
  2. package/README.en.md +162 -0
  3. package/README.md +155 -102
  4. package/launcher/bin/dsh-launcher.exe +0 -0
  5. package/lib/client.js +16 -5
  6. package/lib/index.js +66 -33
  7. package/package.json +2 -2
  8. package/launcher/build.ps1 +0 -44
  9. package/launcher/src-tauri/Cargo.toml +0 -20
  10. package/launcher/src-tauri/build.rs +0 -3
  11. package/launcher/src-tauri/capabilities/default.json +0 -9
  12. package/launcher/src-tauri/icons/128x128.png +0 -0
  13. package/launcher/src-tauri/icons/128x128@2x.png +0 -0
  14. package/launcher/src-tauri/icons/32x32.png +0 -0
  15. package/launcher/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/launcher/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/launcher/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/launcher/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/launcher/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/launcher/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/launcher/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/launcher/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/launcher/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/launcher/src-tauri/icons/StoreLogo.png +0 -0
  25. package/launcher/src-tauri/icons/icon.icns +0 -0
  26. package/launcher/src-tauri/icons/icon.ico +0 -0
  27. package/launcher/src-tauri/icons/icon.png +0 -0
  28. package/launcher/src-tauri/src/dsh.rs +0 -286
  29. package/launcher/src-tauri/src/lib.rs +0 -727
  30. package/launcher/src-tauri/src/main.rs +0 -6
  31. package/launcher/src-tauri/tauri.conf.json +0 -38
  32. package/launcher/ui/index.html +0 -64
  33. package/launcher/ui/main.js +0 -122
  34. package/launcher/ui/settings.css +0 -157
  35. package/launcher/ui/settings.html +0 -71
  36. package/launcher/ui/settings.js +0 -83
  37. package/launcher/ui/styles.css +0 -212
package/lib/index.js CHANGED
@@ -25,12 +25,8 @@ const STDIO = { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }
25
25
  /** 本包随附的预编译桌面应用 exe 目录(无论安装到哪个 profile 都能定位)。 */
26
26
  const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
27
27
 
28
- /** 未配置 launcherDirs 时的默认候选目录(包内 exe 优先,本机常见布局兜底,可被行配置覆盖)。 */
29
- const DEFAULT_DIRS = [
30
- PACKAGE_BIN,
31
- 'F:\\DeepSeek Harness\\DeepSeek Harness Tauri\\dsh-launcher\\src-tauri\\target\\release',
32
- 'F:\\DeepSeek Harness\\DeepSeek Harness Tauri\\dsh-launcher\\src-tauri\\target\\debug',
33
- ]
28
+ /** 未配置 launcherDirs 时的默认候选目录(仅包内随附的预编译 exe;本地自行构建的 exe 通过行配置 launcherDirs/launcherExe 指定)。 */
29
+ const DEFAULT_DIRS = [PACKAGE_BIN]
34
30
 
35
31
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
36
32
  const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
@@ -41,9 +37,17 @@ function writeJson(res, status, body) {
41
37
  res.end(payload)
42
38
  }
43
39
 
40
+ /** 请求体大小上限(回环接口的防御性约束)。 */
41
+ const MAX_BODY_BYTES = 1024 * 1024
42
+
44
43
  async function readJsonBody(req) {
45
44
  const chunks = []
46
- for await (const chunk of req) chunks.push(chunk)
45
+ let total = 0
46
+ for await (const chunk of req) {
47
+ total += chunk.length
48
+ if (total > MAX_BODY_BYTES) return {}
49
+ chunks.push(chunk)
50
+ }
47
51
  try {
48
52
  const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
49
53
  return typeof parsed === 'object' && parsed !== null ? parsed : {}
@@ -58,6 +62,31 @@ function isLoopback(req) {
58
62
  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
59
63
  }
60
64
 
65
+ /**
66
+ * CSRF 防线(#4):回环之外直接拒绝;回环内还须满足任一——
67
+ * 1. 携带本插件约定的自定义头(同源 fetch 可带,跨站 form POST 无法伪造);
68
+ * 2. Origin 为 DSH 自身页面地址(http://127.0.0.1|localhost|[::1]:port);
69
+ * 3. Referer 指向 DSH 自身页面地址。
70
+ * 三者全缺(如 <img>/<script> 等无头请求)拒绝。
71
+ */
72
+ function isTrustedRequest(req, port) {
73
+ if (!isLoopback(req)) return false
74
+ const headers = req.headers || {}
75
+ if (headers['x-requested-with'] === 'dsh-tauri-launcher') return true
76
+ const allowed = new Set([
77
+ 'http://127.0.0.1:' + port,
78
+ 'http://localhost:' + port,
79
+ 'http://[::1]:' + port,
80
+ ])
81
+ if (headers.origin && allowed.has(headers.origin)) return true
82
+ const referer = headers.referer
83
+ if (referer) {
84
+ const base = referer.split('/').slice(0, 3).join('/')
85
+ if (allowed.has(base)) return true
86
+ }
87
+ return false
88
+ }
89
+
61
90
  export function apply(ctx, config) {
62
91
  // 注入的服务由加载器保证在 apply 前就绪(不能在 apply 时用 ctx.get 提前
63
92
  // 捕获未就绪的服务,否则会永久拿到 undefined)。
@@ -88,12 +117,6 @@ export function apply(ctx, config) {
88
117
  if (slash > 0) list.push(resolved.launcherExe.slice(0, slash))
89
118
  }
90
119
  for (const dir of DEFAULT_DIRS) list.push(dir)
91
- const root = sandboxPolicy && sandboxPolicy.workspaceRoot
92
- if (root) {
93
- const r = String(root).replace(/[\\/]+$/, '')
94
- list.push(r + '\\dsh-launcher\\src-tauri\\target\\release')
95
- list.push(r + '\\dsh-launcher\\src-tauri\\target\\debug')
96
- }
97
120
  return [...new Set(list)]
98
121
  }
99
122
 
@@ -110,11 +133,8 @@ export function apply(ctx, config) {
110
133
  }
111
134
 
112
135
  async function exeDirs() {
113
- const dirs = []
114
- for (const dir of baseDirs()) {
115
- if (await exeExists(dir)) dirs.push(dir)
116
- }
117
- return dirs
136
+ const checked = await Promise.all(baseDirs().map(async (dir) => ((await exeExists(dir)) ? dir : null)))
137
+ return checked.filter((dir) => dir !== null)
118
138
  }
119
139
 
120
140
  async function fileText(dir, name) {
@@ -189,7 +209,7 @@ export function apply(ctx, config) {
189
209
  if (!subprocess) throw new Error('subprocess 服务不可用')
190
210
  const handle = subprocess.spawn({
191
211
  argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
192
- cwd: cwd || (await pickExe() ? await pickExe() : ''),
212
+ cwd: cwd || (await pickExe()) || '',
193
213
  stdio: STDIO,
194
214
  graceMs: 3000,
195
215
  })
@@ -334,8 +354,11 @@ export function apply(ctx, config) {
334
354
  const running = await isRunning()
335
355
  const exe = await pickExe()
336
356
  const shortcut = await shortcutExists()
337
- const diag = await buildDiag()
338
- return { ok: true, desktop: running, shortcut, exe: exe || null, diag }
357
+ return { ok: true, desktop: running, shortcut, exe: exe || null }
358
+ }
359
+
360
+ async function getDiag() {
361
+ return { ok: true, diag: await buildDiag() }
339
362
  }
340
363
 
341
364
  async function setDesktop(enabled) {
@@ -357,10 +380,10 @@ export function apply(ctx, config) {
357
380
  const state = await waitFresh(20)
358
381
  if (state === true) {
359
382
  if (!(await ensureShortcut())) lastMarkerError = 'shortcut sync failed (create)'
360
- return { ok: true, desktop: true, shortcut: await shortcutExists(), diag: await buildDiag() }
383
+ return { ok: true, desktop: true, shortcut: await shortcutExists(), diag: '' }
361
384
  }
362
- if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: await buildDiag() }
363
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
385
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
386
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
364
387
  }
365
388
 
366
389
  const owned = spawnHandle
@@ -379,33 +402,35 @@ export function apply(ctx, config) {
379
402
  if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
380
403
  writeQuitMarkerNoWait('0')
381
404
  if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
382
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
405
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
383
406
  }
384
407
  writeQuitMarkerNoWait('0')
385
408
  const state = await waitGone(20)
386
409
  if (state === true) {
387
410
  if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
388
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
411
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
389
412
  }
390
- if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: await buildDiag() }
413
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: '' }
391
414
  try {
392
415
  const exe = await pickExe()
393
416
  const dir = exe ? exe.slice(0, exe.lastIndexOf('\\')) : process.cwd()
394
- await runPowerShell('Stop-Process -Name dsh-launcher -Force -ErrorAction SilentlyContinue', dir)
417
+ // 仅终止与探测到的 exe 路径匹配的进程,避免误杀其他同名程序。
418
+ const script = "$p = Get-Process -Name dsh-launcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq " + psQuote(exe || '') + " }; if ($p) { $p | Stop-Process -Force -ErrorAction SilentlyContinue }"
419
+ await runPowerShell(script, dir)
395
420
  } catch (error) {
396
421
  lastMarkerError = String((error && error.message) || error)
397
422
  }
398
423
  const state2 = await waitGone(8)
399
424
  if (state2 === true) {
400
425
  if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
401
- return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
426
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: '' }
402
427
  }
403
428
  return { ok: false, error: '桌面应用仍在运行(退出请求与强制结束均未生效)。', shortcut: await shortcutExists(), diag: await buildDiag() }
404
429
  }
405
430
 
406
431
  async function setShortcut() {
407
432
  const ok = await ensureShortcut()
408
- if (ok) return { ok: true, shortcut: true, diag: await buildDiag() }
433
+ if (ok) return { ok: true, shortcut: true, diag: '' }
409
434
  return { ok: false, error: '创建桌面快捷方式失败。', shortcut: await shortcutExists(), diag: await buildDiag() }
410
435
  }
411
436
 
@@ -415,7 +440,7 @@ export function apply(ctx, config) {
415
440
  kind: 'exact',
416
441
  path: '/api/dsh-tauri-launcher/state',
417
442
  handler: async (req, res) => {
418
- if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
443
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
419
444
  writeJson(res, 200, await getState())
420
445
  },
421
446
  },
@@ -423,7 +448,7 @@ export function apply(ctx, config) {
423
448
  kind: 'exact',
424
449
  path: '/api/dsh-tauri-launcher/set-desktop',
425
450
  handler: async (req, res) => {
426
- if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
451
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
427
452
  const body = await readJsonBody(req)
428
453
  writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
429
454
  },
@@ -432,10 +457,18 @@ export function apply(ctx, config) {
432
457
  kind: 'exact',
433
458
  path: '/api/dsh-tauri-launcher/set-shortcut',
434
459
  handler: async (req, res) => {
435
- if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
460
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
436
461
  writeJson(res, 200, await setShortcut())
437
462
  },
438
463
  },
464
+ {
465
+ kind: 'exact',
466
+ path: '/api/dsh-tauri-launcher/diagnose',
467
+ handler: async (req, res) => {
468
+ if (!isTrustedRequest(req, ctx.webServer.port)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
469
+ writeJson(res, 200, await getDiag())
470
+ },
471
+ },
439
472
  ]
440
473
  const disposers = routes.map((route) => ctx.webServer.register(route))
441
474
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenorin/dsh-tauri-launcher",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "DSH Web 插件:基于 Tauri 2 的 DeepSeek Harness 桌面启动器,自动联动桌面快捷方式,exe 路径与探测窗口可配置。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,7 +12,7 @@
12
12
  "files": [
13
13
  "lib",
14
14
  "cordis.patch.yml",
15
- "launcher",
15
+ "launcher/bin",
16
16
  "README.md"
17
17
  ],
18
18
  "dsh": {
@@ -1,44 +0,0 @@
1
- # dsh-tauri-launcher 桌面应用一键构建脚本(Windows)。
2
- # 用法:在仓库根目录运行 pwsh -File launcher/build.ps1
3
- # 产物:launcher/src-tauri/target/release/dsh-launcher.exe
4
- #
5
- # 说明:
6
- # - 依赖 Rust 工具链(rustup + MSVC 或 GNU)与 Node.js 22+;
7
- # - 若本机无法联网拉取 crates.io 依赖,可先用 --offline 配合一个预置的
8
- # CARGO_HOME(例如把已缓存的 .cargo 注册表目录传给 -CargoHome 参数)。
9
-
10
- param(
11
- [switch]$Offline,
12
- [string]$CargoHome = ''
13
- )
14
-
15
- $ErrorActionPreference = 'Stop'
16
- $root = Split-Path -Parent $PSScriptRoot
17
- $srcTauri = Join-Path $PSScriptRoot 'src-tauri'
18
-
19
- if ($CargoHome -ne '') {
20
- $env:CARGO_HOME = $CargoHome
21
- Write-Host "CARGO_HOME = $CargoHome"
22
- }
23
-
24
- Push-Location $srcTauri
25
- try {
26
- if ($Offline) {
27
- Write-Host 'cargo build --release --offline'
28
- cargo build --release --offline
29
- } else {
30
- Write-Host 'cargo build --release'
31
- cargo build --release
32
- }
33
- if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)" }
34
- } finally {
35
- Pop-Location
36
- }
37
-
38
- $exe = Join-Path $srcTauri 'target\release\dsh-launcher.exe'
39
- if (-not (Test-Path -LiteralPath $exe)) {
40
- throw "构建产物缺失:$exe"
41
- }
42
- Write-Host ''
43
- Write-Host "构建完成:$exe"
44
- Get-Item -LiteralPath $exe | Select-Object Length, LastWriteTime
@@ -1,20 +0,0 @@
1
- [package]
2
- name = "dsh-launcher"
3
- version = "0.1.0"
4
- description = "DeepSeek Harness 桌面启动器:自动检测/安装并启动本地 DeepSeek Harness,系统托盘管理退出"
5
- authors = ["you"]
6
- edition = "2021"
7
-
8
- [lib]
9
- name = "dsh_launcher_lib"
10
- crate-type = ["staticlib", "cdylib", "rlib"]
11
-
12
- [build-dependencies]
13
- tauri-build = { version = "2", features = [] }
14
-
15
- [dependencies]
16
- tauri = { version = "2", features = ["tray-icon", "image-png"] }
17
- tauri-plugin-global-shortcut = "2"
18
- serde = { version = "1", features = ["derive"] }
19
- serde_json = "1"
20
- tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "time", "net", "sync"] }
@@ -1,3 +0,0 @@
1
- fn main() {
2
- tauri_build::build()
3
- }
@@ -1,9 +0,0 @@
1
- {
2
- "$schema": "../gen/schemas/desktop-schema.json",
3
- "identifier": "default",
4
- "description": "Capability for the launcher windows (main + settings)",
5
- "windows": ["main", "settings"],
6
- "permissions": [
7
- "core:default"
8
- ]
9
- }
Binary file
Binary file
Binary file
Binary file
@@ -1,286 +0,0 @@
1
- //! DeepSeek Harness 管理逻辑:检测安装、npm 自动安装、端口探测。
2
- //! 与 tauri 解耦,便于独立测试。
3
-
4
- use std::collections::VecDeque;
5
- use std::env;
6
- use std::path::Path;
7
- use std::process::Command as StdCommand;
8
- use std::time::Duration;
9
-
10
- use serde::Serialize;
11
- use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
12
- use tokio::process::Command as TokioCommand;
13
-
14
- /// npm 包名:DeepSeek Harness 本体。
15
- pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
16
- /// Web GUI 默认端口。
17
- pub const DSH_PORT: u16 = 3080;
18
- /// Web GUI 默认地址。
19
- pub const DSH_URL: &str = "http://127.0.0.1:3080/";
20
- /// dsh web 页面中的指纹标记,用于确认端口上跑的是 DeepSeek Harness。
21
- pub const DSH_MARKER: &str = "__DSH_BOOT__";
22
-
23
- /// Windows: 隐藏子进程控制台窗口。
24
- #[cfg(windows)]
25
- fn hide_console_std(cmd: &mut StdCommand) {
26
- use std::os::windows::process::CommandExt;
27
- cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
28
- }
29
- #[cfg(not(windows))]
30
- fn hide_console_std(_cmd: &mut StdCommand) {}
31
-
32
- #[cfg(windows)]
33
- fn hide_console_tokio(cmd: &mut TokioCommand) {
34
- cmd.creation_flags(0x0800_0000);
35
- }
36
- #[cfg(not(windows))]
37
- fn hide_console_tokio(_cmd: &mut TokioCommand) {}
38
-
39
- /// 同步执行命令并捕获标准输出。
40
- pub fn run_capture(cmd: &str, args: &[&str]) -> Result<String, String> {
41
- let mut c = StdCommand::new(cmd);
42
- c.args(args);
43
- hide_console_std(&mut c);
44
- let out = c.output().map_err(|e| e.to_string())?;
45
- if out.status.success() {
46
- Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
47
- } else {
48
- let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
49
- Err(if detail.is_empty() {
50
- format!("命令执行失败:{cmd} {}", args.join(" "))
51
- } else {
52
- detail
53
- })
54
- }
55
- }
56
-
57
- /// 解析 node.exe 完整路径(支持 DSH_LAUNCHER_NODE 覆盖,便于测试)。
58
- pub fn node_exe() -> Option<String> {
59
- if let Ok(v) = env::var("DSH_LAUNCHER_NODE") {
60
- if !v.trim().is_empty() {
61
- return Some(v.trim().to_string());
62
- }
63
- }
64
- run_capture("cmd", &["/C", "where", "node"])
65
- .ok()
66
- .and_then(|s| s.lines().next().map(str::trim).map(String::from))
67
- .filter(|s| !s.is_empty())
68
- }
69
-
70
- /// npm 全局安装根目录(支持 DSH_LAUNCHER_NPM_ROOT 覆盖,便于测试)。
71
- pub fn npm_root() -> Option<String> {
72
- if let Ok(v) = env::var("DSH_LAUNCHER_NPM_ROOT") {
73
- if !v.trim().is_empty() {
74
- return Some(v.trim().to_string());
75
- }
76
- }
77
- run_capture("cmd", &["/C", "npm", "root", "-g"])
78
- .ok()
79
- .map(|s| s.trim().to_string())
80
- .filter(|s| !s.is_empty())
81
- }
82
-
83
- #[derive(Serialize, Clone)]
84
- pub struct CheckResult {
85
- pub installed: bool,
86
- pub version: Option<String>,
87
- pub root: Option<String>,
88
- pub bin_path: Option<String>,
89
- pub node_ok: bool,
90
- pub error: Option<String>,
91
- }
92
-
93
- fn read_pkg_version(pkg_json: &Path) -> Option<String> {
94
- let text = std::fs::read_to_string(pkg_json).ok()?;
95
- let v: serde_json::Value = serde_json::from_str(&text).ok()?;
96
- v.get("version").and_then(|v| v.as_str()).map(String::from)
97
- }
98
-
99
- /// 检查本地是否已安装 DeepSeek Harness(npm 全局包)。
100
- pub fn check() -> CheckResult {
101
- if node_exe().is_none() {
102
- return CheckResult {
103
- installed: false,
104
- version: None,
105
- root: None,
106
- bin_path: None,
107
- node_ok: false,
108
- error: Some(
109
- "未检测到 Node.js。DeepSeek Harness 依赖 Node.js 运行,请先安装 Node.js(https://nodejs.org/)后重试。"
110
- .to_string(),
111
- ),
112
- };
113
- }
114
- let Some(root) = npm_root() else {
115
- return CheckResult {
116
- installed: false,
117
- version: None,
118
- root: None,
119
- bin_path: None,
120
- node_ok: true,
121
- error: Some("无法确定 npm 全局安装目录(npm root -g 执行失败),请确认 npm 已正确安装。".to_string()),
122
- };
123
- };
124
- // npm root -g 返回的路径本身以 node_modules 结尾;同时也兼容以
125
- // npm prefix -g 形态给出的根目录(不含 node_modules),两种都探测。
126
- let base = Path::new(&root);
127
- let candidates = [base.join("node_modules").join(DSH_PACKAGE), base.join(DSH_PACKAGE)];
128
- let Some(pkg_dir) = candidates.iter().find(|d| d.join("package.json").exists()) else {
129
- return CheckResult {
130
- installed: false,
131
- version: None,
132
- root: Some(root),
133
- bin_path: None,
134
- node_ok: true,
135
- error: None,
136
- };
137
- };
138
- CheckResult {
139
- installed: true,
140
- version: read_pkg_version(&pkg_dir.join("package.json")),
141
- root: Some(root),
142
- bin_path: Some(pkg_dir.join("lib").join("bin.js").to_string_lossy().into_owned()),
143
- node_ok: true,
144
- error: None,
145
- }
146
- }
147
-
148
- /// 执行 `npm install -g @deepseek-ai/dsh`,逐行回调输出,返回安装后的版本号。
149
- /// 设置 DSH_LAUNCHER_NPM_ROOT 时改为安装到该目录(与检测根目录保持一致,便于测试)。
150
- pub async fn install<F>(mut emit: F) -> Result<String, String>
151
- where
152
- F: FnMut(&str, &str),
153
- {
154
- let override_root = env::var("DSH_LAUNCHER_NPM_ROOT")
155
- .ok()
156
- .filter(|s| !s.is_empty());
157
- if let Some(root) = override_root.as_deref() {
158
- std::fs::create_dir_all(root).map_err(|e| format!("无法创建安装目录 {root}:{e}"))?;
159
- let pkg_json = Path::new(root).join("package.json");
160
- if !pkg_json.exists() {
161
- std::fs::write(&pkg_json, b"{\n \"private\": true\n}\n")
162
- .map_err(|e| format!("无法写入 {pkg_json:?}:{e}"))?;
163
- }
164
- }
165
- let mut cmd = TokioCommand::new("cmd");
166
- if let Some(root) = override_root {
167
- cmd.args([
168
- "/C",
169
- "npm",
170
- "install",
171
- "--prefix",
172
- &root,
173
- DSH_PACKAGE,
174
- "--no-fund",
175
- "--no-audit",
176
- "--loglevel=notice",
177
- ]);
178
- } else {
179
- cmd.args([
180
- "/C",
181
- "npm",
182
- "install",
183
- "-g",
184
- DSH_PACKAGE,
185
- "--no-fund",
186
- "--no-audit",
187
- "--loglevel=notice",
188
- ]);
189
- }
190
- cmd.env("npm_config_update_notifier", "false")
191
- .env("npm_config_fund", "false")
192
- .env("npm_config_audit", "false")
193
- .stdout(std::process::Stdio::piped())
194
- .stderr(std::process::Stdio::piped())
195
- .kill_on_drop(true);
196
- hide_console_tokio(&mut cmd);
197
- let mut child = cmd.spawn().map_err(|e| format!("无法启动 npm:{e}"))?;
198
- let stdout = child.stdout.take().ok_or("无法读取 npm 输出")?;
199
- let stderr = child.stderr.take().ok_or("无法读取 npm 输出")?;
200
-
201
- let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
202
- let tx2 = tx.clone();
203
- tokio::spawn(async move {
204
- let mut lines = BufReader::new(stdout).lines();
205
- while let Ok(Some(line)) = lines.next_line().await {
206
- let _ = tx.send(("stdout".to_string(), line));
207
- }
208
- });
209
- tokio::spawn(async move {
210
- let mut lines = BufReader::new(stderr).lines();
211
- while let Ok(Some(line)) = lines.next_line().await {
212
- let _ = tx2.send(("stderr".to_string(), line));
213
- }
214
- });
215
-
216
- let mut tail: VecDeque<String> = VecDeque::with_capacity(256);
217
- let status = loop {
218
- tokio::select! {
219
- status = child.wait() => {
220
- break status.map_err(|e| format!("等待 npm 结束失败:{e}"))?;
221
- }
222
- Some((stream, line)) = rx.recv() => {
223
- if tail.len() >= 256 {
224
- tail.pop_front();
225
- }
226
- tail.push_back(format!("[{stream}] {line}"));
227
- emit(&stream, &line);
228
- }
229
- }
230
- };
231
-
232
- if status.success() {
233
- let after = check();
234
- after
235
- .version
236
- .ok_or_else(|| "npm 报告安装成功,但未能检测到已安装的 DeepSeek Harness。".to_string())
237
- } else {
238
- let code = status
239
- .code()
240
- .map(|c| c.to_string())
241
- .unwrap_or_else(|| "被信号终止".to_string());
242
- let log = tail.into_iter().collect::<Vec<_>>().join("\n");
243
- Err(format!("npm install 失败(退出码 {code}):\n{log}"))
244
- }
245
- }
246
-
247
- /// 探测指定端口上是否正在运行 DeepSeek Harness Web GUI。
248
- pub async fn is_dsh_serving(port: u16) -> bool {
249
- let addr = format!("127.0.0.1:{port}");
250
- let Ok(conn) = tokio::time::timeout(Duration::from_secs(2), tokio::net::TcpStream::connect(&addr)).await else {
251
- return false;
252
- };
253
- let Ok(mut stream) = conn else {
254
- return false;
255
- };
256
- let req = format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
257
- if tokio::time::timeout(Duration::from_secs(2), stream.write_all(req.as_bytes()))
258
- .await
259
- .is_err()
260
- {
261
- return false;
262
- }
263
- let mut buf = Vec::new();
264
- let _ = tokio::time::timeout(Duration::from_secs(3), stream.read_to_end(&mut buf)).await;
265
- String::from_utf8_lossy(&buf).contains(DSH_MARKER)
266
- }
267
-
268
- #[cfg(test)]
269
- mod tests {
270
- use super::*;
271
-
272
- /// 本机已全局安装 @deepseek-ai/dsh:check() 必须能通过
273
- /// `npm root -g` 找到它(回归测试:此前曾把 node_modules 拼接了两次)。
274
- #[test]
275
- fn check_detects_global_install() {
276
- let result = check();
277
- assert!(
278
- result.installed,
279
- "check() 未检测到已安装的 DeepSeek Harness:root={:?} node_ok={} error={:?}",
280
- result.root, result.node_ok, result.error
281
- );
282
- assert!(result.version.is_some(), "检测到安装但未解析出版本号");
283
- let bin = result.bin_path.expect("bin_path 应存在");
284
- assert!(std::path::Path::new(&bin).exists(), "入口脚本不存在:{bin}");
285
- }
286
- }