@lenorin/dsh-tauri-launcher 1.0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/cordis.patch.yml +7 -0
  4. package/launcher/README.md +56 -0
  5. package/launcher/bin/dsh-launcher.exe +0 -0
  6. package/launcher/build.ps1 +44 -0
  7. package/launcher/src-tauri/Cargo.toml +20 -0
  8. package/launcher/src-tauri/build.rs +3 -0
  9. package/launcher/src-tauri/capabilities/default.json +9 -0
  10. package/launcher/src-tauri/icons/128x128.png +0 -0
  11. package/launcher/src-tauri/icons/128x128@2x.png +0 -0
  12. package/launcher/src-tauri/icons/32x32.png +0 -0
  13. package/launcher/src-tauri/icons/Square107x107Logo.png +0 -0
  14. package/launcher/src-tauri/icons/Square142x142Logo.png +0 -0
  15. package/launcher/src-tauri/icons/Square150x150Logo.png +0 -0
  16. package/launcher/src-tauri/icons/Square284x284Logo.png +0 -0
  17. package/launcher/src-tauri/icons/Square30x30Logo.png +0 -0
  18. package/launcher/src-tauri/icons/Square310x310Logo.png +0 -0
  19. package/launcher/src-tauri/icons/Square44x44Logo.png +0 -0
  20. package/launcher/src-tauri/icons/Square71x71Logo.png +0 -0
  21. package/launcher/src-tauri/icons/Square89x89Logo.png +0 -0
  22. package/launcher/src-tauri/icons/StoreLogo.png +0 -0
  23. package/launcher/src-tauri/icons/icon.icns +0 -0
  24. package/launcher/src-tauri/icons/icon.ico +0 -0
  25. package/launcher/src-tauri/icons/icon.png +0 -0
  26. package/launcher/src-tauri/src/dsh.rs +286 -0
  27. package/launcher/src-tauri/src/lib.rs +727 -0
  28. package/launcher/src-tauri/src/main.rs +6 -0
  29. package/launcher/src-tauri/tauri.conf.json +38 -0
  30. package/launcher/ui/index.html +64 -0
  31. package/launcher/ui/main.js +122 -0
  32. package/launcher/ui/settings.css +157 -0
  33. package/launcher/ui/settings.html +71 -0
  34. package/launcher/ui/settings.js +83 -0
  35. package/launcher/ui/styles.css +212 -0
  36. package/lib/client.js +241 -0
  37. package/lib/index.js +445 -0
  38. package/package.json +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-tauri-launcher contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # dsh-tauri-launcher
2
+
3
+ 一个可安装的 DeepSeek Harness Web 插件:在 **设置 → 桌面启动** 中启动/退出本机
4
+ **DeepSeek Harness Tauri 桌面应用**,并联动桌面快捷方式。配套提供桌面应用
5
+ (Tauri)的完整源码与 GitHub Release 自动构建。
6
+
7
+ ## 功能
8
+
9
+ - **桌面端启动开关**:一键启动/退出桌面应用(点击即反馈,秒级状态同步);
10
+ - **快捷方式联动**:开启时自动创建桌面快捷方式、关闭时自动删除;运行中可手动补建;
11
+ - **关闭确认弹窗**:关闭前弹窗确认(含快捷方式将被删除的提示);
12
+ - **状态可视**:运行中 🟢 / 已停止 ⚪ / 状态未知 🟡 + 出错时显示诊断信息;
13
+ - **深色模式适配**:全部颜色走 DSH 主题令牌,深浅色自动适配;
14
+ - **设置导航显示器图标**:替换默认齿轮图标(矢量绘制,任意缩放锐利)。
15
+
16
+ ## 工作原理(摘要)
17
+
18
+ Web 插件(宿主半)通过 `/api/dsh-tauri-launcher/*` 回环路由与浏览器半通信,
19
+ 并使用标记文件协议控制桌面应用(详见 [docs/architecture.md](docs/architecture.md)):
20
+
21
+ - `.dsh-heartbeat`:桌面应用每秒写入时间戳,插件以此探测运行状态;
22
+ - `.dsh-quit`:写入 `1` 后桌面应用仅退出自身(保留 Harness 进程),消费时自删。
23
+
24
+ ## 目录结构
25
+
26
+ ```
27
+ dsh-tauri-launcher/
28
+ ├── package.json # dsh.bundle + dsh.client 双 manifest
29
+ ├── cordis.patch.yml # 组合补丁(插入插件行)
30
+ ├── lib/
31
+ │ ├── index.js # 宿主半:回环路由 + 控制逻辑(可配置)
32
+ │ └── client.js # 浏览器半:设置分区 UI
33
+ ├── launcher/ # Tauri 桌面应用工程(源码 + 一键构建)
34
+ ├── .github/workflows/ # CI:打 tag 自动构建 exe 并发布 Release
35
+ └── docs/ # 架构与发布文档
36
+ ```
37
+
38
+ ## 安装(Web 插件)
39
+
40
+ > **关于 `<profile>`**:它指 DSH 的**配置档(profile)**——一套可启动的插件组合,
41
+ > 位于 `$DSH_HOME/profiles/<名字>/`。`<profile>` 是占位符,**需要替换成你实际
42
+ > 要装进的 profile 名**。本机常见配置档名为 `web`(启动 DSH Web GUI 的档);
43
+ > 查看已有档:`dir %USERPROFILE%\.dsh\profiles`(或 `ls ~/.dsh/profiles`)。
44
+ > 下面的命令示例中,把 `<profile>` 换成 `web` 即可,例如 `dsh plugin --profile web add ...`。
45
+
46
+ 三种方式任选(任选其一):
47
+
48
+ ```bash
49
+ # 1) npm(发布后)
50
+ dsh plugin --profile web add @lenorin/dsh-tauri-launcher
51
+
52
+ # 2) GitHub(git 安装;纯 JS 零构建,无需 allowBuilds 授权)
53
+ dsh plugin --profile web add github:you/dsh-tauri-launcher
54
+
55
+ # 3) 本地 checkout / tarball
56
+ dsh plugin --profile web add ./dsh-tauri-launcher
57
+ dsh plugin --profile web add ./dsh-tauri-launcher-1.0.0.tgz
58
+ ```
59
+
60
+ > 如果你有多个 profile,把命令里的 `web` 换成目标 profile 名即可。
61
+
62
+ 验证层已生效(应能看到 `dsh-tauri-launcher` 层):
63
+
64
+ ```bash
65
+ dsh --profile web --dump-config
66
+ ```
67
+
68
+ 重启 DSH Web 进程后,设置面板即出现「桌面启动」分区。卸载(`web` 换成你的 profile 名):
69
+
70
+ ```bash
71
+ dsh plugin --profile web remove @lenorin/dsh-tauri-launcher
72
+ ```
73
+
74
+ ## 配置
75
+
76
+ 插件行支持以下配置(无硬编码可调参数;默认值适配常见部署):
77
+
78
+ ```yaml
79
+ - id: desktop-launcher
80
+ name: '@lenorin/dsh-tauri-launcher'
81
+ config:
82
+ launcherExe: '' # 桌面应用 exe 绝对路径;空 = 自动探测
83
+ launcherDirs: [] # 候选 exe 目录列表;空 = 内置默认候选
84
+ freshSecs: 4 # 心跳“新鲜窗口”(秒),须大于心跳写入周期
85
+ shortcutName: 'DeepSeek Harness.lnk' # 桌面快捷方式文件名
86
+ ```
87
+
88
+ ## 桌面应用构建与发布
89
+
90
+ - npm 包自带预编译 exe(`launcher/bin/dsh-launcher.exe`),插件安装后自动探测、装上即用;
91
+ - 本地自行构建:`pwsh -File launcher/build.ps1`(可选 `-Offline -CargoHome <dir>`);
92
+ - GitHub Release:推送 `v*` tag,CI 自动构建 Windows exe 并发布(见
93
+ [docs/release.md](docs/release.md))。
94
+
95
+ ## 文档
96
+
97
+ - [docs/architecture.md](docs/architecture.md) — 架构与标记文件协议
98
+ - [docs/release.md](docs/release.md) — 安装、构建、发布、卸载与配置参考
99
+
100
+ ## License
101
+
102
+ [MIT](LICENSE)
@@ -0,0 +1,7 @@
1
+ # dsh-tauri-launcher 组合包补丁:向宿主组合插入双面插件行。
2
+ # 宿主半(exports ".")在 Node 进程内注册 /api/dsh-tauri-launcher/* 回环路由;
3
+ # 浏览器半(exports "./client",经 package.json 的 dsh.client 声明发现)
4
+ # 在 Web 设置面板注册「桌面启动」分区。
5
+ - insert:
6
+ - id: desktop-launcher
7
+ name: '@lenorin/dsh-tauri-launcher'
@@ -0,0 +1,56 @@
1
+ # DeepSeek Harness 桌面启动器(Tauri)
2
+
3
+ 一个用于连接本地 DeepSeek Harness 的桌面应用:
4
+
5
+ - **启动时自动检查**本地是否已安装 DeepSeek Harness(npm 全局包 `@deepseek-ai/dsh`)
6
+ - **未安装则自动安装**:加载页面显示安装动画与 npm 实时日志
7
+ - **安装完成提示“安装成功”**,随后自动启动并打开 DeepSeek Harness Web 界面
8
+ - **安装/启动失败反馈报错信息**,可一键重试
9
+ - **系统托盘**提供“打开 DeepSeek Harness”“设置”与“退出”按钮;退出时终止由本应用启动的 DeepSeek Harness 进程(若启动时发现已有实例在运行则接管显示,退出时不终止外部实例)
10
+ - 关闭窗口最小化到托盘,应用常驻后台
11
+ - **设置窗口**(托盘 → 设置)提供三项开关:
12
+ - **开机启动**:写入/删除 `HKCU\...\Run` 的 `DeepSeekHarness` 注册表值(指向本应用 exe)
13
+ - **创建全局快捷方式**:勾选注册全局快捷键 `Ctrl+Shift+H`(按下即唤起 DeepSeek Harness 主窗口),取消勾选注销快捷键;勾选状态持久化在 exe 同目录的 `.dsh-config.json`,应用启动时自动恢复注册
14
+ - **桌面快捷方式**:勾选在桌面创建 `DeepSeek Harness.lnk`(指向本应用 exe,带图标),取消勾选删除;状态以 .lnk 是否存在为准,无需持久化
15
+
16
+ ## 开发
17
+
18
+ ```bash
19
+ pnpm install # 仅安装 tauri CLI
20
+ pnpm tauri dev # 直接以静态 ui/ 目录运行
21
+ ```
22
+
23
+ ## 构建
24
+
25
+ ```bash
26
+ pnpm tauri build
27
+ ```
28
+
29
+ 前端为 `ui/` 下的纯静态 HTML/CSS/JS(无构建步骤,经 `window.__TAURI__` 全局 API 与后端通信)。
30
+
31
+ ## 环境变量(可选,便于测试)
32
+
33
+ - `DSH_LAUNCHER_NODE`:覆盖 node.exe 路径
34
+ - `DSH_LAUNCHER_NPM_ROOT`:覆盖 npm 全局安装根目录(安装检测与 `dsh` 入口脚本均以此为准)
35
+
36
+ ## 与 Web 设置插件的标记文件协作
37
+
38
+ DeepSeek Harness Web 设置中的“桌面启动”插件(deepseek-harness-tauri)只负责启动/退出本应用;本应用 exe 同目录下的标记文件用于协作(每秒检查一次):
39
+
40
+ - `.dsh-heartbeat`:每秒写入当前 Unix 时间戳,供插件在沙箱内判断本应用是否仍在运行(插件侧的新鲜窗口为 4 秒,须与 1 秒写入周期匹配);
41
+ - `.dsh-quit`:内容为 `1` 且修改时间在 60 秒内 → 本应用**仅退出自身**(消费时自删标记文件),保留由其启动的 DeepSeek Harness 进程继续运行(与托盘“退出”的整树清理语义不同);插件重写内容为 `0` 即取消退出请求。
42
+
43
+ ## 开机启动、全局快捷方式与桌面快捷方式
44
+
45
+ 三项均由本应用自己的设置窗口(托盘 → 设置)控制,与 Web 设置插件无联动:
46
+
47
+ - **开机启动**:直接写入/删除 `HKCU\...\Run` 的 `DeepSeekHarness` 注册表值(指向本应用 exe);
48
+ - **全局快捷方式**:通过 `tauri-plugin-global-shortcut` 注册/注销 `Ctrl+Shift+H`,按下即显示并聚焦主窗口;勾选状态保存在 exe 同目录的 `.dsh-config.json`,启动时自动恢复;
49
+ - **桌面快捷方式**:通过 PowerShell WScript.Shell COM 创建/删除桌面 `DeepSeek Harness.lnk`(桌面路径用 `[Environment]::GetFolderPath('Desktop')` 解析,自动适配 OneDrive 重定向);状态即 .lnk 存在性。
50
+
51
+ ## 工作原理
52
+
53
+ 1. `check_dsh`:通过 `npm root -g` 定位全局安装目录,检查 `node_modules/@deepseek-ai/dsh/package.json`
54
+ 2. `install_dsh`:执行 `npm install -g @deepseek-ai/dsh`,逐行转发输出到前端
55
+ 3. `launch_dsh`:先探测 `127.0.0.1:3080` 是否已有 DSH Web 实例(以页面 `__DSH_BOOT__` 标记为指纹),没有则拉起 `dsh web` 并轮询等待就绪,然后把主窗口导航到本地 Web 界面
56
+ 4. 托盘“退出”或进程退出时,对由本应用启动的 dsh 进程树执行 `taskkill /T /F` 清理
Binary file
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,20 @@
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"] }
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
@@ -0,0 +1,9 @@
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
+ }
@@ -0,0 +1,286 @@
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
+ }