DeepSeek Harness 托盘启动器 —— 构建全流程与学习笔记

本文档完整记录「DSHLauncher」托盘启动器的需求分析 → 方案选型 → 源码设计 → 图标生成 → 编译 → 测试全过程,以及过程中遇到的 6 个关键问题 和解决思路,供日后查阅、复习与扩展参考。

一、项目介绍(启动器是什么)

1.1 需求背景

DeepSeek Harness(DSH)的 Web 图形界面服务平时需要手动打开 cmd 输入指令才能启动:

dsh web        # 等价于 dsh --profile web

用户希望有一个可执行文件,满足:

  1. 双击即启动服务,不需要任何命令行操作;
  2. 服务后台静默运行(不弹黑色控制台窗口),通过系统状态栏(托盘)图标常驻控制;
  3. 可选开机自启(开机自动后台启动,无需管理员权限)。

1.2 功能特性(交付版)

功能说明
双击启动后台静默运行 dsh web --no-open,无控制台窗口
托盘常驻图标常驻系统通知区域;双击托盘图标 → 浏览器打开界面
右键菜单状态显示 / 打开界面 / 重启服务 / 停止服务 / 开机自启开关 / 打开日志目录 / 退出
开机自启在当前用户「启动文件夹」创建快捷方式,无需管理员;开机静默启动、不弹浏览器
防重复服务已在运行时不再重复启动;重复双击 exe 只打开界面
单实例命名 Mutex 保证只有一个启动器实例
日志排障%LOCALAPPDATA%\DSHLauncher\ 下自动记录启动器日志与 dsh 服务输出
零依赖单文件 EXE(约 22 KB),无需安装任何运行时

1.3 目录结构

D:\desktop\workproject\DSHLauncher\
├── DSHLauncher.exe           ← 交付物:双击即用的托盘启动器
├── src\
│   ├── Program.cs            ← C# 源码(唯一源码文件)
│   └── app.ico               ← 应用图标(构建脚本生成)
├── build.ps1                 ← 一键构建脚本(生成图标 + 编译 + 可选桌面快捷方式)
├── README.md                 ← 用户快速使用指南
└── 构建记录与学习笔记.md      ← 本文档

桌面快捷方式(构建时可生成):D:\desktop\DeepSeek Harness 启动器.lnk

1.4 快速使用

# 一键构建(含桌面快捷方式)
powershell -ExecutionPolicy Bypass -File .\build.ps1 -DesktopShortcut

# 之后直接双击 DSHLauncher.exe(或桌面快捷方式)即可
# 托盘图标:双击打开界面;右键菜单控制重启/停止/开机自启

二、构建全流程

2.1 需求分析与方案选型

第一步:确认「要启动的服务」到底是什么

不要凭空猜测,先查环境:

# 1. 查看 dsh 命令行本身
Get-Command dsh                 # → C:\Users\yjxrmk\.bun\bin\dsh.exe

# 2. 阅读 dsh 包的入口,确认命令族
#    C:\Users\yjxrmk\.bun\install\global\node_modules\@deepseek-ai\dsh\lib\bin.js
#    关键结论:web 是 --profile web 的硬编码别名;launcher 参数之后的参数原样转发给应用

# 3. 阅读 web 应用自身的命令行 flag
#    node_modules\@deepseek-ai\dsh-web-app\lib\startup.js
#    关键结论:--host / --port / --no-open / --trusted-host
#              --no-open = 不自动打开浏览器(后台运行正需要它)

# 4. 确认运行时环境变量与配置文件
$env:DSH_HOME                   # → C:\Users\yjxrmk\.dsh
#    profiles\web\cordis.yml / cordis.patch.yml 是配置层

最终确认启动指令:dsh web --no-open,默认端口 3080

第二步:方案对比

方案优点缺点结论
VBS 包装 cmd 启动简单无托盘控制、无菜单不满足需求
PowerShell + 托盘脚本可做托盘双击 .ps1 黑窗/策略限制、无单文件 exe不满足需求
C# WinForms 托盘应用(编译为 EXE)真正的可执行文件、托盘/菜单/开机自启齐全需要编译器✅ 采用
dotnet SDK 发布现代 C#需要联网装 SDK/运行时;自包含体积大本机有但非最优

第三步:技术选型 —— 为什么用 .NET Framework 的 csc.exe

  • Windows 10/11 自带 .NET Framework 4.x,编译器位于:
    C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe
  • 离线编译,无需联网、无需安装 SDK、无需 NuGet;
  • 产出单文件 EXE/target:winexe),目标机器零依赖。
⚠️ 代价:该编译器只支持 C# 5 语法,不能用字符串插值 $"..."async/await、out 变量等新特性(详见「四、关键知识点」)。

2.2 源码设计(Program.cs)

单一源码文件,按职责划分为 5 个模块:

模块职责
Log日志写入 %LOCALAPPDATA%\DSHLauncher\dsl.log;另供 dsh 子进程 stdout/stderr 落盘
Dsh服务封装:exe 路径查找、HTTP 健康检查、等待就绪/停止、netstat 找端口占用进程、taskkill 杀进程树、打开浏览器
Autostart开机自启:读/写/删「启动文件夹」快捷方式(COM)
TrayApp托盘主逻辑:NotifyIcon、右键菜单、状态定时刷新、后台操作线程
Program入口:命令行参数解析、单实例 Mutex、启动 Application 消息循环

关键机制逐条拆解

① 如何做到「后台静默启动,无窗口」

psi.UseShellExecute = false;   // 直接创建进程,不经 Shell
psi.CreateNoWindow = true;     // 不创建控制台窗口
psi.RedirectStandardOutput = true;  // 输出重定向到日志文件
psi.RedirectStandardError  = true;
// 输出/错误用 BeginOutputReadLine 异步读取,避免管道阻塞

② 如何判断服务「是否已在运行」—— HTTP 健康检查

不探测 TCP 端口而是直接发 HTTP 请求(原因见「三、问题 6」):

HttpWebRequest req = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:3080/");
req.Timeout = 1500;
// 任何 HTTP 响应(包括 4xx/5xx)都算“服务在线”

③ 如何「停止服务」—— 进程树清理

  • 若服务是本启动器拉起的:直接 taskkill /PID <pid> /T /F/T 杀子孙进程);
  • 若是外部(cmd 手动)启动的:netstat -ano -p tcp 解析出占用 3080 的 PID 再杀。

④ 如何「重启服务」:先静默停止(StopCore(true))→ 等端口释放 → 再启动(StartCore(false))。

⑤ 如何防止界面卡死:启动/停止/重启都在 ThreadPool 工作线程执行,UI 线程只做状态刷新;用 _busy 标志防止菜单重入。

⑥ 单实例:命名 Mutex(Local\DSHLauncher_1),第二个实例直接打开界面后退出。

⑦ 托盘气泡:工作线程不直接调 ShowBalloonTip(跨线程 UI 调用不稳定),而是写入 _pendingMsg,由 UI 线程每 3 秒的定时器统一投递。

⑧ 开机自启:在 shell:startup(用户启动文件夹)创建指向自身的快捷方式,带 --autostart 参数;开机自启时静默(不弹气泡、不打开浏览器)。

⑨ 运行时托盘图标:用 System.Drawing 现绘(圆角矩形 + 白点),GetHiconIcon.FromHandleCloneDestroyIcon(P/Invoke 释放句柄,防泄漏)。

⑩ 命令行接口(也是自测接口)

DSHLauncher.exe                # 正常启动(托盘)
DSHLauncher.exe --autostart    # 开机自启专用(静默)
DSHLauncher.exe --autostart-on / --autostart-off   # 脚本化开关自启,退出码 0/1

2.3 图标生成(app.ico)

不借助任何图片工具,直接在 PowerShell 里按 ICO 二进制格式手工生成 32×32、32 位色图标。

ICO 文件结构(单图像)

部分字节数说明
ICONDIR 头6reserved(2) + type=1(2) + count=1(2)
ICONDIRENTRY16宽=32、高=32、planes=1、bitcount=32、图像字节数、偏移=22
BITMAPINFOHEADER40biSize=40、宽=32、高=64(XOR+AND 合图)、bitcount=32
XOR 数据32×32×4 = 4096像素 BGRA,自下而上排列
AND 掩码32×32÷8 = 1281bpp,全 0 = 完全不透明
总大小 = 6 + 16 + 40 + 4096 + 128 = 4286 字节。这个数字是排障时的关键校验值(见问题 2)。

像素着色:用带符号距离场(SDF)画圆角矩形 + 白点:

  • 圆角矩形:d = max(|p - 中心| - (half - r), 0) 的长度 - rd ≤ 0 为内部 → 深蓝(31,111,216);
  • 中心白点:距中心 ≤ 7.5 像素 → 白色;
  • 其余像素 Alpha = 0(透明)。

2.4 构建脚本(build.ps1)

职责链:生成 app.ico → 调用 csc.exe 编译 → (可选)创建桌面快捷方式

csc 编译参数说明:

/nologo                    不打印横幅
/target:winexe             图形程序(无控制台)
/optimize+                 开启优化
/win32icon:app.ico         把图标嵌入 EXE 资源
/out:DSHLauncher.exe       输出路径
/r:System.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll  引用 GAC 程序集
src\Program.cs             源码

桌面快捷方式用 COM(WScript.Shell)创建:目标为 exe,IconLocation = "exe路径,0"

2.5 测试流程与结果

对交付物做了三层验证(均在服务已在运行的环境中进行,不干扰现有服务):

#测试项方法结果
1图标有效性[System.Drawing.Icon]::ExtractAssociatedIcon(exe)32×32 有效 ✅
2开机自启开启exe --autostart-on(Start-Process -Wait 捕获退出码)exit=0,启动文件夹出现快捷方式 ✅
3开机自启关闭exe --autostart-offexit=0,快捷方式被删除 ✅
4托盘常驻直接启动 exe,6 秒后检查进程进程存活 ✅
5服务检测查看 dsl.log正确记录「服务已在运行」,未重复拉起服务(dsh-err.log 未生成)✅
6桌面快捷方式build.ps1 -DesktopShortcut + 读取 lnk 属性目标指向 DSHLauncher.exe ✅
测试要点:CLI 模式用 Start-Process -Wait -PassThru 拿退出码;GUI 模式用 Start-Process -PassThru 后轮询进程是否存活。

三、中间遇到的问题与解决方案(重点)

问题 1:PowerShell 5.1 读脚本中文乱码,直接解析失败

现象:运行 build.ps1 报解析错误:

Unexpected token '$exeFile' in expression or statement.
The string is missing the terminator: ".

报错位置周围的字符串变成了乱码(如「妗岄潰蹇嵎鏂瑰紡宸插垱寤」)。

原因编码不匹配。脚本文件是 UTF-8(无 BOM),但 Windows PowerShell 5.1 对无 BOM 的 .ps1 默认按系统 ANSI 代码页(中文系统 = GBK)读取 → 中文逐字节被错误解码 → 某些字符的字节序列恰好破坏字符串引号的配对,导致整段脚本解析失败。

解决:把脚本文件转换为 UTF-8 带 BOM(BOM = EF BB BF 开头),PowerShell 5.1 检测到 BOM 即按 UTF-8 读取:

$c = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText($path, $c, (New-Object System.Text.UTF8Encoding($true)))

教训

  • 凡是要给 Windows PowerShell 5.1 跑的 .ps1,含中文就必须带 BOM(或全英文);
  • PowerShell 7(pwsh)默认 UTF-8 无 BOM 也能正确处理,但目标用户不一定是 7;
  • C# 源码给 csc 也要带 BOM,让编译器按 UTF-8 读,避免字符串中文乱码进 EXE。

问题 2:图标无效 → csc 报 CS1567「Error creating Win32 resources」

现象:生成出的 app.ico 只有 4282 字节(期望 4286),随后编译报:

error CS1567: Error creating Win32 resources: Error reading icon '...app.ico' -- 图标无效

原因:手写 ICO 时 BITMAPINFOHEADER 写成了 36 字节——biSize、biWidth、biHeight、biPlanes、biBitCount、biCompression、biSizeImage 共 36 字节后,结尾只补了 12 个零字节(biXPels/biYPels/biClrUsed),漏了 biClrImportant 的 4 字节。头部长度错误导致整张图像被解析器判定无效。

解决:补齐 4 个零字节(16 个 0),BITMAPINFOHEADER 恢复标准 40 字节,文件正好 4286 字节,编译通过。

教训

  • ICO 的 BITMAPINFOHEADER 一定是 40 字节,别少写;
  • 手写二进制格式时,先算好总字节数(4286),生成后立刻核对文件长度,第一时间暴露结构错误;
  • New-Object System.Drawing.Icon(path) 可快速验证 ICO 合法性。

问题 3:PowerShell 数组字面量 [byte[]] 数值溢出

现象:(问题 2 的组成部分)[byte[]](40, 0, ..., $xorSize, ...)$xorSize = 32×32×4 = 4096

原因[byte[]] 里放 4096 超出 byte 范围(0~255),转换会失败/异常。

解决:拆成 4 个字节按小端(低位在前)写:

($xorSize -band 0xFF), (($xorSize -shr 8) -band 0xFF),
(($xorSize -shr 16) -band 0xFF), (($xorSize -shr 24) -band 0xFF)

教训:所有 16/32 位数值(imageSize、biSizeImage、偏移量)写入字节序列时,都要手动做小端分解。

问题 4:编辑工具重写文件后 BOM 丢失,乱码问题复发

现象:给 build.ps1 打上 BOM 并成功编译出图标后,又用编辑工具修改脚本里的一个字节数组(补 4 个零),再次运行又报同样的中文乱码解析错误

原因:编辑工具以无 BOM 的 UTF-8 重写整个文件,把之前加的 BOM 覆盖掉了。

解决:先检查文件头 3 字节是否为 EF BB BF,缺了再补一次 BOM:

$bytes = [System.IO.File]::ReadAllBytes($f)
$hasBom = ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
if (-not $hasBom) { # 重新写入带 BOM 版本 }

教训「BOM 化」必须是文件修改流程的最后一步——任何工具改完文件后都要复查/重补 BOM;排查“改了之后突然解析失败”时,第一反应查 BOM 是否还在。

问题 5:DSH 沙箱限制执行外部程序(安全边界)

现象:在开发环境里执行 dsh --helpdotnet --list-sdksnetstat 等外部程序均被拦截:

[sandbox: file access denied under workspace-write mode]

原因:这是运行时沙箱的设计——只允许在工作区内读写文件,执行工作区外的程序(读取其文件)属于受限操作,属于刻意留的安全边界,不是 bug。

解决(在合法授权机制内):

  • 按规则用同一命令升级一次权限重试sandbox_permissions: danger-full-access + 一句理由);
  • 升级被拒则停止并说明,不绕道;
  • 设计上避开沙箱:健康检查用 HTTP 请求而不是执行 netstat 探测(见问题 6)。

教训:理解「命令被拒 → 升级重试一次 → 被拒即止」的权限契约;把避开受限操作作为默认设计选项(能纯函数/协议判断就别调外部程序)。

问题 6:Get-NetTCPConnection 查端口「拒绝访问」→ 改用 HTTP 健康检查

现象Get-NetTCPConnection -LocalPort 3080 报「拒绝访问」,一度误判「服务没运行」;而 Invoke-WebRequest http://127.0.0.1:3080/ 返回 HTTP 200,服务明明在跑。

原因Get-NetTCPConnection 依赖对系统网络信息的访问,在当前受限环境被拒;而探测服务是否可用,本质问题是「端口上有没有可用的 Web 服务」而不是「端口有没有监听」

解决:启动器的健康检查一律用 HTTP 请求(响应即在线);停止服务时用 netstat(用户实际运行环境不受沙箱限制)解析端口占用 PID。

教训

  • 判断「服务是否可用」用应用层探测(HTTP)比系统层探测(端口)更可靠、权限需求更低;
  • 环境被限制时先换思路(协议探测),不要死磕同一个 API。

小坑补充

  • GUI 程序与 PowerShell 的等待语义& exe 对 GUI(winexe)程序不会可靠等待,CLI 模式拿退出码要用 Start-Process -Wait -PassThru,常驻模式用 -PassThru + 轮询进程存活。
  • csc 错误信息乱码:csc 输出按 GBK 控制台编码,在 UTF-8 环境中显示为乱码(error CS1567: ����...),可结合错误码(CS1567)搜索定位。

四、关键知识点速查

4.1 dsh 命令行族(本次调研结论)

命令含义
dsh web启动 Web 界面服务(=dsh --profile web
dsh web --no-open后台服务,不自动打开浏览器
dsh web --port 8080 / --host 127.0.0.1指定端口/绑定地址
dsh --profile headless "任务"单次任务模式
dsh plugin --profile web add <pkg>安装 profile 插件
$DSH_HOME(= ~\.dsh配置/会话/存储目录

4.2 C# 5 兼容写法对照(老编译器限制)

现代语法C# 5 替代
$"端口 {port}""端口 " + portstring.Format
obj?.Propif (obj != null) ...(显式判空)
out var x先声明 int x; ... out x
async/awaitThreadPool.QueueUserWorkItem / 事件回调
表达式体成员 =>普通方法体
Lambda (s,e) => {...}也可用(C# 3 支持),匿名方法 delegate {...} 亦可

4.3 托盘编程固定套路(WinForms / .NET Framework)

  1. 进程入口 [STAThread] + Application.Run(new ApplicationContext())
  2. NotifyIcon:设置 IconTextVisible = trueContextMenuStripDoubleClick
  3. 右键菜单用 ContextMenuStrip,状态类条目 Enabled = false
  4. 定时刷新用 System.Windows.Forms.Timer(UI 线程),跨线程更新 UI 一律走它
  5. 退出流程:_icon.Visible = falseDisposeExitThread()

4.4 COM 创建快捷方式(无 dynamic 的反射写法)

Type shellType = Type.GetTypeFromProgID("WScript.Shell");
object shell = Activator.CreateInstance(shellType);
object sc = shellType.InvokeMember("CreateShortcut", BindingFlags.InvokeMethod,
    null, shell, new object[] { lnkPath });
Type t = sc.GetType();
t.InvokeMember("TargetPath", BindingFlags.SetProperty, null, sc, new object[] { exePath });
t.InvokeMember("Arguments",  BindingFlags.SetProperty, null, sc, new object[] { "--autostart" });
t.InvokeMember("IconLocation", BindingFlags.SetProperty, null, sc, new object[] { exePath + ",0" });
t.InvokeMember("Save", BindingFlags.InvokeMethod, null, sc, null);

4.5 子进程隐藏启动的要点

  • UseShellExecute=false + CreateNoWindow=true
  • 重定向 stdout/stderr 后必须异步读取BeginOutputReadLine),否则写满管道会死锁;
  • EnableRaisingEvents=true + Exited 事件监听崩溃退出;
  • 杀进程用 taskkill /PID <id> /T /F 连子进程一起清理。

五、如何进一步修改 / 扩展

想做的事改哪里
改端口(如 8080)Program.csDsh.Port 常量后重新 build.ps1
服务崩溃后自动重启Exited 事件里计数 + 延迟重试(注意加退避防死循环)
开机自启时也打开浏览器StartCore 里去掉 --no-open,并允许 autostart 分支弹提示
做成便携版启动器本身单文件;把「自动发现的 dsh.exe 路径」改为配置项即可
同时管理多个服务/端口把端口/健康 URL 抽象成配置数组,TrayApp 循环管理
开机自启改用注册表把 Autostart 模块的快捷方式改为 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 写注册表(同样无需管理员,但可用性稍差、用户不直观)

六、实测数据存档

条目
编译产物DSHLauncher.exe,22,528 字节(22 KB)
图标文件src\app.ico,4,286 字节,32×32 32 位色
源码src\Program.cs,约 24.6 KB
编译器C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe(.NET Framework 4.x,离线)
dsh 可执行文件C:\Users\yjxrmk\.bun\bin\dsh.exe
服务地址http://127.0.0.1:3080/
日志目录%LOCALAPPDATA%\DSHLauncher\(dsl.log / dsh-out.log / dsh-err.log)
自启快捷方式%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\DeepSeek Harness 启动器.lnk
桌面快捷方式D:\desktop\DeepSeek Harness 启动器.lnk(可选生成)
最后修改:2026 年 08 月 23 日
如果觉得我的文章对你有用,请随意赞赏