@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.
- package/LICENSE +1 -1
- package/README.en.md +162 -0
- package/README.md +155 -102
- package/launcher/bin/dsh-launcher.exe +0 -0
- package/lib/client.js +16 -5
- package/lib/index.js +66 -33
- package/package.json +2 -2
- package/launcher/build.ps1 +0 -44
- package/launcher/src-tauri/Cargo.toml +0 -20
- package/launcher/src-tauri/build.rs +0 -3
- package/launcher/src-tauri/capabilities/default.json +0 -9
- package/launcher/src-tauri/icons/128x128.png +0 -0
- package/launcher/src-tauri/icons/128x128@2x.png +0 -0
- package/launcher/src-tauri/icons/32x32.png +0 -0
- package/launcher/src-tauri/icons/Square107x107Logo.png +0 -0
- package/launcher/src-tauri/icons/Square142x142Logo.png +0 -0
- package/launcher/src-tauri/icons/Square150x150Logo.png +0 -0
- package/launcher/src-tauri/icons/Square284x284Logo.png +0 -0
- package/launcher/src-tauri/icons/Square30x30Logo.png +0 -0
- package/launcher/src-tauri/icons/Square310x310Logo.png +0 -0
- package/launcher/src-tauri/icons/Square44x44Logo.png +0 -0
- package/launcher/src-tauri/icons/Square71x71Logo.png +0 -0
- package/launcher/src-tauri/icons/Square89x89Logo.png +0 -0
- package/launcher/src-tauri/icons/StoreLogo.png +0 -0
- package/launcher/src-tauri/icons/icon.icns +0 -0
- package/launcher/src-tauri/icons/icon.ico +0 -0
- package/launcher/src-tauri/icons/icon.png +0 -0
- package/launcher/src-tauri/src/dsh.rs +0 -286
- package/launcher/src-tauri/src/lib.rs +0 -727
- package/launcher/src-tauri/src/main.rs +0 -6
- package/launcher/src-tauri/tauri.conf.json +0 -38
- package/launcher/ui/index.html +0 -64
- package/launcher/ui/main.js +0 -122
- package/launcher/ui/settings.css +0 -157
- package/launcher/ui/settings.html +0 -71
- package/launcher/ui/settings.js +0 -83
- 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
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
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()
|
|
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
|
-
|
|
338
|
-
|
|
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:
|
|
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:
|
|
363
|
-
return { ok: true, desktop: false, shortcut: await shortcutExists(), diag:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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.
|
|
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": {
|
package/launcher/build.ps1
DELETED
|
@@ -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"] }
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
-
}
|