소스 검색

refactor: v2.2.0 二次迭代(单元测试 + 修复 + 职责拆分)

- 新增 8 个单元测试;测试立即暴露退避上限未生效(已修复:循环内提前 break,避免溢出)

- 修复定期更新开关关掉再打开后失效(循环改常驻,关闭仅暂停计时)

- 结构拆分:probe.go(环境探测)/ util.go(纯工具),supervisor 专注守护

- 健壮:日志按天轮转;killTree 失败退化为直接终止;daemon 支持优雅退出信号

- 整洁:attachConsole 复用包级 proc,移除 tray.go 重复函数
caesar 1 일 전
부모
커밋
0c3ca2be35
10개의 변경된 파일과 416개의 추가작업 그리고 175개의 파일을 삭제
  1. 48 16
      README.md
  2. 24 4
      logger.go
  3. 10 2
      main.go
  4. 85 0
      probe.go
  5. 13 5
      proc_windows.go
  6. 18 115
      supervisor.go
  7. 0 24
      tray.go
  8. 5 9
      updater.go
  9. 63 0
      util.go
  10. 150 0
      util_test.go

+ 48 - 16
README.md

@@ -9,20 +9,23 @@ DeepSeek Harness **Web 版桌面包装**(Windows 系统托盘 + 守护进程
 ## 特性
 
 - **隐藏终端**:以 `node <dsh>/lib/bin.js web --no-open` 直接启动(无 shell 包装),子进程使用 `CREATE_NO_WINDOW` + `HideWindow`
-- **守护重启**:异常退出自动重启,采用**指数退避**(基准 5s,上限 2 分钟;稳定运行 30s 后复位),杜绝重启风暴
-- **端口预检**:目标端口已被占用(例如已有实例在运行)时不启动,改为温和等待(上限 30s 轮询),避免端口冲突
+- **守护重启**:异常退出自动重启,**指数退避**(基准可配,封顶 2 分钟;稳定运行 30s 后复位),杜绝重启风暴
+- **端口预检**:端口已被占用(例如已有实例)时不启动,改为温和等待(封顶 30s 轮询),避免端口冲突
 - **启停管理**:托盘一键启动 / 停止 / 重启;停止为**同步等待**(最多 8s),重启前等待端口释放
 - **开机自启**:托盘勾选或 `autostart on|off`(写 HKCU Run 键,无需管理员权限)
-- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`;可开启定期后台更新(**单例循环**,有新版才更新并重启)
+- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`;可开启定期后台更新(**常驻单例循环**:关闭仅暂停计时,再次开启立即恢复)
 - **带 token 打开**:捕获 `dsh web` 输出的访问地址;菜单标题只显示 origin,token 只存内存
 - **安全**:日志自动脱敏 `token=***`;打开 URL/路径不经 shell 解析(`rundll32` / `explorer`)
-- **健壮**:配置原子写入(临时文件 + rename)、支持 UTF-8 BOM、损坏配置回退默认值
-- **单实例**:`Local\` 命名互斥体(普通用户可创建,避免 `Global\` 权限失败导致失效)
+- **健壮**:配置原子写入(临时文件 + rename)、支持 UTF-8 BOM、损坏配置回退默认值;日志按天轮转;进程树终止失败时退化为直接终止
+- **单实例**:`Local` 命名互斥体(普通用户可创建,避免 `Global` 权限失败导致失效)
+- **可验证**:8 个单元测试覆盖脱敏、URL 提取、配置归一、端口探测、退避、日志轮转
 - **低依赖**:第三方仅 2 个(`getlantern/systray`、`golang.org/x/sys`),单文件分发约 5 MB
 
-## 构建
+## 构建与测试
 
 ```powershell
+go vet ./...        # 静态检查
+go test ./...       # 单元测试
 go build -ldflags "-H=windowsgui -s -w" -o deepseek-tray.exe .
 ```
 
@@ -32,7 +35,7 @@ go build -ldflags "-H=windowsgui -s -w" -o deepseek-tray.exe .
 |---|---|
 | `deepseek-tray.exe` | 托盘模式(默认) |
 | `deepseek-tray.exe --silent` | 静默托盘(开机自启使用,无窗口、不打开浏览器) |
-| `deepseek-tray.exe daemon` | 无 UI 守护模式(适合注册为 Windows 服务) |
+| `deepseek-tray.exe daemon` | 无 UI 守护模式(支持优雅退出信号,适合注册为 Windows 服务) |
 | `deepseek-tray.exe status` | 状态查询(同时写日志) |
 | `deepseek-tray.exe autostart [on\|off]` | 查看/设置开机自启 |
 | `deepseek-tray.exe version` | 版本号 |
@@ -48,7 +51,7 @@ nssm start DeepSeekTray
 
 ## 托盘菜单
 
-- 状态:运行中 (PID) / 已停止 / 外部实例运行中 / 正在更新(含运行时长与重启次数提示)
+- 状态:运行中 (PID) / 已停止 / 外部实例运行中 / 正在更新(提示含运行时长与重启次数)
 - 打开 Web 界面(带 token,直接进入)
 - 启动 / 停止 / 重启服务
 - 开机自启(勾选)
@@ -80,25 +83,53 @@ nssm start DeepSeekTray
 | `nodePath` / `dshBinJs` | 留空自动探测(`node` + `npm root -g` 下的 dsh 入口) |
 | `autoStart` | 开机自启(与托盘勾选同步) |
 | `autoUpdate` / `updateIntervalHours` | 定期后台更新开关与间隔 |
-| `restartDelaySeconds` | 重启退避的基准延迟(实际按失败次数指数放大) |
+| `restartDelaySeconds` | 重启退避的基准延迟(按失败次数指数放大,封顶 2 分钟) |
+
+> 修改配置文件后需重启程序生效。
+
+## 源码结构
+
+| 文件 | 职责 |
+|---|---|
+| `main.go` | 入口与 CLI 子命令(托盘 / daemon / status / autostart) |
+| `supervisor.go` | 守护核心:端口预检、隐藏启动、退避重启、状态快照 |
+| `probe.go` | 环境探测:node 路径、dsh 入口、已装版本(带缓存与串行化) |
+| `updater.go` | 更新与定期更新循环 |
+| `tray.go` | 托盘菜单与交互 |
+| `config.go` / `logger.go` / `npm.go` / `util.go` | 配置、日志、npm 调用、纯工具 |
+| `*_windows.go` | Win32 细节(隐藏窗口、进程树终止、单实例、控制台附加) |
+| `util_test.go` | 单元测试 |
 
 ## 日志
 
-`%APPDATA%\deepseek-tray\logs\tray-YYYYMMDD.log`(含 dsh web 的 stdout/stderr;**token 已脱敏**)
+`%APPDATA%\deepseek-tray\logs\tray-YYYYMMDD.log`(**按天轮转**;含 dsh web 的 stdout/stderr;**token 已脱敏**)
+
+## 验证结论
+
+### 单元测试(`go test`)
+
+| 测试 | 覆盖 |
+|---|---|
+| TestRedact | token 脱敏(多 token / 终止符 / 无 token / 空串) |
+| TestExtractHTTPURL | URL 提取与误报拒绝(httpx) |
+| TestShortURL / TestHumanDuration | 展示格式化 |
+| TestConfigNormalized | 配置越界回退默认值 |
+| TestIsPortOpen | 真实监听端口的探测 |
+| TestBackoff | 退避序列与封顶(**此测试发现并修复了上限未生效问题**) |
+| TestLoggerRotate | 日志按天轮转 |
 
-## 验证结论(v2.1.0)
+### 运行时验证(v2.2.0 回归)
 
 | 维度 | 验证项 | 结果 |
 |---|---|---|
 | 可靠性 | 隐藏终端(daemon / dsh web 窗口句柄) | 均为 0 |
 | 可靠性 | 自动重启(假命令立即退出) | 启动 6 次 / 退出 4 次 / 重启 4 次 |
-| 可靠性 | 单实例(第二个实例启动) | 立即退出并记录 |
-| 健壮性 | 端口被占用 | 不启动冲突进程,退避递增 6s→12s→24s |
+| 可靠性 | 单实例(启动第二个实例) | 立即退出并记录 |
+| 健壮性 | 端口被占用 | 不启动冲突进程,退避 6s→12s→24s |
 | 健壮性 | 配置损坏 / UTF-8 BOM | 回退默认值 / 正确读取 |
-| 安全性 | 日志 token | 输出为 `token=***` |
+| 安全性 | 日志 token | 输出 `token=***` |
 | 安全性 | 打开 URL/路径 | 不经 shell 解析 |
-| 效率 | status 查询 / 二进制 | 0.4 秒 / 约 5 MB |
-| 效率 | 状态轮询 | 仅在值变化时刷新 UI |
+| 效率 | status 查询 / 二进制 | 0.4 秒 / 约 5.2 MB |
 
 ## 环境要求
 
@@ -108,6 +139,7 @@ nssm start DeepSeekTray
 
 ## 版本
 
+- v2.2.0 — 二次迭代:**新增单元测试**(并借其修复退避上限问题)、修复定期更新"关闭后再开启"失效、职责拆分(probe/util)、日志按天轮转、进程终止兜底、daemon 优雅退出
 - v2.1.0 — 自检优化:端口预检 + 指数退避 + 定期更新单例 + `Local` 互斥体 + 日志脱敏 + 原子配置 + `autostart` 子命令
 - v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能归档于 `legacy-v1` 标签)
 

+ 24 - 4
logger.go

@@ -9,28 +9,46 @@ import (
 	"time"
 )
 
-// Logger 简单文件日志(windowsgui 子系统无控制台,统一落盘)。
-// 所有写出内容都经过 Redact:访问 token 不会明文落盘。
+// logger.go —— 简单文件日志(windowsgui 子系统无控制台,统一落盘)。
+// 特性:按天轮转(跨天自动切换文件);所有写出内容经 Redact 脱敏,token 不明文落盘。
+
 type Logger struct {
 	mu   sync.Mutex
 	file *os.File
 	path string
+	day  string
 }
 
 func NewLogger() *Logger {
+	l := &Logger{}
+	l.rotate(time.Now().Format("20060102"))
+	return l
+}
+
+// rotate 切换到指定日期的日志文件(关闭旧句柄)。
+func (l *Logger) rotate(day string) {
+	if l.file != nil {
+		_ = l.file.Close()
+		l.file = nil
+	}
 	dir := logDir()
 	_ = os.MkdirAll(dir, 0o755)
-	p := filepath.Join(dir, "tray-"+time.Now().Format("20060102")+".log")
+	p := filepath.Join(dir, "tray-"+day+".log")
 	f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
 	if err != nil {
 		f = nil
 	}
-	return &Logger{file: f, path: p}
+	l.file = f
+	l.path = p
+	l.day = day
 }
 
 func (l *Logger) Printf(format string, args ...any) {
 	l.mu.Lock()
 	defer l.mu.Unlock()
+	if day := time.Now().Format("20060102"); day != l.day {
+		l.rotate(day) // 跨天轮转
+	}
 	body := Redact(fmt.Sprintf(format, args...))
 	line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), body)
 	if l.file != nil {
@@ -39,6 +57,8 @@ func (l *Logger) Printf(format string, args ...any) {
 }
 
 func (l *Logger) Path() string {
+	l.mu.Lock()
+	defer l.mu.Unlock()
 	return l.path
 }
 

+ 10 - 2
main.go

@@ -3,10 +3,12 @@ package main
 import (
 	"fmt"
 	"os"
+	"os/signal"
 	"strings"
+	"syscall"
 )
 
-const appVersion = "2.1.0"
+const appVersion = "2.2.0"
 
 // 会话级命名互斥体(Local 前缀无需特权;Global 前缀普通用户常创建失败,会导致单实例失效)
 const mutexName = "Local\\DeepSeekTraySingleton"
@@ -67,7 +69,13 @@ func runDaemon(cfg *Config, log *Logger) {
 	if cfg.AutoUpdate {
 		sup.startAutoUpdate()
 	}
-	select {}
+
+	// 优雅退出:Ctrl+C / 服务停止信号(NSSM 停止服务时生效)
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+	<-sig
+	log.Printf("收到退出信号,停止服务")
+	sup.Stop()
 }
 
 // printStatus 命令行状态查询(同时写日志,便于无控制台场景回溯)。

+ 85 - 0
probe.go

@@ -0,0 +1,85 @@
+package main
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+)
+
+// probe.go —— 运行环境探测(node 路径、dsh 入口、已装版本)。
+// 结果缓存于 Supervisor,probeMu 保证并发只探测一次;更新后调用 invalidateProbe 失效。
+
+func (s *Supervisor) nodePath() string {
+	s.mu.Lock()
+	if s.nodeCache != "" {
+		v := s.nodeCache
+		s.mu.Unlock()
+		return v
+	}
+	s.mu.Unlock()
+
+	p, err := exec.LookPath("node")
+	if err != nil {
+		return ""
+	}
+	s.mu.Lock()
+	s.nodeCache = p
+	s.mu.Unlock()
+	return p
+}
+
+// dshBinJS 探测 dsh 的 bin.js(APPDATA\npm 与 npm root -g)。
+func (s *Supervisor) dshBinJS() string {
+	s.probeMu.Lock()
+	defer s.probeMu.Unlock()
+
+	s.mu.Lock()
+	if s.binCache != "" {
+		v := s.binCache
+		s.mu.Unlock()
+		return v
+	}
+	s.mu.Unlock()
+
+	var candidates []string
+	if appdata := os.Getenv("APPDATA"); appdata != "" {
+		candidates = append(candidates, filepath.Join(appdata, "npm", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"))
+	}
+	if root := npmGlobalRoot(); root != "" {
+		candidates = append(candidates, filepath.Join(root, "@deepseek-ai", "dsh", "lib", "bin.js"))
+	}
+	for _, c := range candidates {
+		if fileExists(c) {
+			s.mu.Lock()
+			s.binCache = c
+			s.mu.Unlock()
+			s.log.Printf("dsh 入口: %s", c)
+			return c
+		}
+	}
+	return ""
+}
+
+// invalidateProbe 使入口缓存失效(更新 dsh 后调用)。
+func (s *Supervisor) invalidateProbe() {
+	s.mu.Lock()
+	s.binCache = ""
+	s.mu.Unlock()
+}
+
+// installedVersion 读取已安装 dsh 版本(空串表示未找到)。
+func (s *Supervisor) installedVersion() string {
+	binJS := s.cfg.DshBinJS
+	if binJS == "" {
+		binJS = s.dshBinJS()
+	}
+	if binJS == "" {
+		return ""
+	}
+	return readPackageVersion(filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json"))
+}
+
+func fileExists(p string) bool {
+	st, err := os.Stat(p)
+	return err == nil && !st.IsDir()
+}

+ 13 - 5
proc_windows.go

@@ -26,6 +26,7 @@ func hideWindow(cmd *exec.Cmd) {
 }
 
 // killTree 强制结束进程树(含 node 派生的子进程),带超时防止卡住。
+// taskkill 不可用或超时时,退化为直接终止目标进程。
 func killTree(pid int) error {
 	if pid <= 0 {
 		return nil
@@ -34,7 +35,14 @@ func killTree(pid int) error {
 	defer cancel()
 	cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
 	hideWindow(cmd)
-	return cmd.Run()
+	if err := cmd.Run(); err == nil {
+		return nil
+	}
+	proc, err := os.FindProcess(pid)
+	if err != nil {
+		return err
+	}
+	return proc.Kill()
 }
 
 // processAlive 判断进程是否仍在运行。
@@ -55,8 +63,9 @@ func processAlive(pid int) bool {
 }
 
 var (
-	kernel32        = windows.NewLazySystemDLL("kernel32.dll")
-	procCreateMutex = kernel32.NewProc("CreateMutexW")
+	kernel32          = windows.NewLazySystemDLL("kernel32.dll")
+	procCreateMutex   = kernel32.NewProc("CreateMutexW")
+	procAttachConsole = kernel32.NewProc("AttachConsole")
 )
 
 // singleInstance 基于命名互斥体的单实例保护。
@@ -90,8 +99,7 @@ func (s *singleInstance) release() {
 // attachConsole 让 windowsgui 子系统程序能输出到调用它的终端(仅 CLI 子命令使用)。
 func attachConsole() {
 	const attachParentProcess = 0xFFFFFFFF
-	procAttach := kernel32.NewProc("AttachConsole")
-	ret, _, _ := procAttach.Call(uintptr(attachParentProcess))
+	ret, _, _ := procAttachConsole.Call(uintptr(attachParentProcess))
 	if ret == 0 {
 		return
 	}

+ 18 - 115
supervisor.go

@@ -5,17 +5,16 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"net"
-	"os"
 	"os/exec"
-	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
 )
 
-// 稳定运行的判定阈值:超过它则清零失败计数(退避复位)。
+// supervisor.go —— dsh web 守护核心:端口预检、隐藏启动、指数退避重启、状态快照。
+
+// healthyUptime 稳定运行判定阈值:超过它则清零失败计数(退避复位)。
 const healthyUptime = 30 * time.Second
 
 // Status 服务状态快照。
@@ -30,10 +29,10 @@ type Status struct {
 	External  bool // 端口被外部实例占用(非本程序启动)
 }
 
-// Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启(指数退避)。
+// Supervisor 守护 dsh web。
 type Supervisor struct {
 	mu        sync.Mutex
-	probeMu   sync.Mutex // 串行化入口探测,避免并发重复探测
+	probeMu   sync.Mutex // 串行化入口探测(见 probe.go)
 	cfg       *Config
 	log       *Logger
 	pid       int
@@ -50,7 +49,7 @@ type Supervisor struct {
 	webURL    string // dsh web 输出的完整 URL(含访问 token,仅存内存)
 
 	autoMu   sync.Mutex
-	autoLoop bool // 定期更新循环是否在运行(保证单例)
+	autoLoop bool // 定期更新循环是否已启动(进程生命周期内单例)
 }
 
 func NewSupervisor(cfg *Config, log *Logger) *Supervisor {
@@ -130,7 +129,11 @@ func (s *Supervisor) setError(err error) {
 	s.log.Printf("守护错误: %v", err)
 }
 
-// backoff 指数退避:base * 2^failures(上限 2 分钟),避免故障时重启风暴。
+// maxBackoff 重启退避上限。
+const maxBackoff = 2 * time.Minute
+
+// backoff 指数退避:base * 2^failures,封顶 maxBackoff。
+// 循环内提前 break,既保证封顶语义,也避免超大 failures 造成数值溢出。
 func (s *Supervisor) backoff() time.Duration {
 	s.mu.Lock()
 	failures := s.failures
@@ -140,11 +143,14 @@ func (s *Supervisor) backoff() time.Duration {
 		base = 5
 	}
 	d := time.Duration(base) * time.Second
-	for i := 0; i < failures && i < 5; i++ {
+	for i := 0; i < failures; i++ {
+		if d >= maxBackoff {
+			break
+		}
 		d *= 2
 	}
-	if d > 2*time.Minute {
-		d = 2 * time.Minute
+	if d > maxBackoff {
+		d = maxBackoff
 	}
 	return d
 }
@@ -254,7 +260,7 @@ func (s *Supervisor) isDesired() bool {
 	return s.desired
 }
 
-// bumpFailure 增加失败计数(探测/启动阶段失败)。
+// bumpFailure 增加失败计数(触发退避放大)。
 func (s *Supervisor) bumpFailure() {
 	s.mu.Lock()
 	s.failures++
@@ -313,22 +319,6 @@ func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
 	}
 }
 
-// extractHTTPURL 从一行输出里取出 http(s) URL(去除尾随分隔符)。
-func extractHTTPURL(line string) string {
-	idx := strings.Index(line, "http")
-	if idx < 0 {
-		return ""
-	}
-	url := strings.TrimSpace(line[idx:])
-	if i := strings.IndexAny(url, " \t"); i > 0 {
-		url = url[:i]
-	}
-	if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
-		return url
-	}
-	return ""
-}
-
 // resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,避免弹窗与注入面)。
 func (s *Supervisor) resolveCommand() (string, []string, error) {
 	node := s.cfg.NodePath
@@ -351,90 +341,3 @@ func (s *Supervisor) resolveCommand() (string, []string, error) {
 	}
 	return "", nil, errors.New("未找到 dsh 入口:请确认已全局安装 @deepseek-ai/dsh,或在配置中指定 nodePath / dshBinJs")
 }
-
-func (s *Supervisor) nodePath() string {
-	s.mu.Lock()
-	if s.nodeCache != "" {
-		v := s.nodeCache
-		s.mu.Unlock()
-		return v
-	}
-	s.mu.Unlock()
-	p, err := exec.LookPath("node")
-	if err != nil {
-		return ""
-	}
-	s.mu.Lock()
-	s.nodeCache = p
-	s.mu.Unlock()
-	return p
-}
-
-// dshBinJS 探测 dsh 的 bin.js(APPDATA\npm 与 npm root -g);probeMu 保证并发只探测一次。
-func (s *Supervisor) dshBinJS() string {
-	s.probeMu.Lock()
-	defer s.probeMu.Unlock()
-
-	s.mu.Lock()
-	if s.binCache != "" {
-		v := s.binCache
-		s.mu.Unlock()
-		return v
-	}
-	s.mu.Unlock()
-
-	var candidates []string
-	if appdata := os.Getenv("APPDATA"); appdata != "" {
-		candidates = append(candidates, filepath.Join(appdata, "npm", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"))
-	}
-	if root := npmGlobalRoot(); root != "" {
-		candidates = append(candidates, filepath.Join(root, "@deepseek-ai", "dsh", "lib", "bin.js"))
-	}
-	for _, c := range candidates {
-		if fileExists(c) {
-			s.mu.Lock()
-			s.binCache = c
-			s.mu.Unlock()
-			s.log.Printf("dsh 入口: %s", c)
-			return c
-		}
-	}
-	return ""
-}
-
-// invalidateProbe 使入口缓存失效(更新后调用)。
-func (s *Supervisor) invalidateProbe() {
-	s.mu.Lock()
-	s.binCache = ""
-	s.mu.Unlock()
-}
-
-// installedVersion 读取已安装 dsh 版本。
-func (s *Supervisor) installedVersion() string {
-	binJS := s.cfg.DshBinJS
-	if binJS == "" {
-		binJS = s.dshBinJS()
-	}
-	if binJS == "" {
-		return ""
-	}
-	return readPackageVersion(filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json"))
-}
-
-func fileExists(p string) bool {
-	st, err := os.Stat(p)
-	return err == nil && !st.IsDir()
-}
-
-// isPortOpen 检测端口是否有监听者。
-func isPortOpen(host string, port int) bool {
-	if host == "" {
-		host = "127.0.0.1"
-	}
-	conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 1200*time.Millisecond)
-	if err != nil {
-		return false
-	}
-	_ = conn.Close()
-	return true
-}

+ 0 - 24
tray.go

@@ -197,30 +197,6 @@ func statusTooltip(st Status) string {
 	return strings.Join(parts, ";")
 }
 
-// humanDuration 简短的时长展示(<1 分钟、x 分钟、x 小时 y 分)。
-func humanDuration(d time.Duration) string {
-	if d <= 0 {
-		return "0 秒"
-	}
-	sec := int(d.Seconds())
-	switch {
-	case sec < 60:
-		return fmt.Sprintf("%d 秒", sec)
-	case sec < 3600:
-		return fmt.Sprintf("%d 分钟", sec/60)
-	default:
-		return fmt.Sprintf("%d 小时 %d 分", sec/3600, (sec%3600)/60)
-	}
-}
-
-// shortURL 菜单标题只显示 origin,避免 token 撑爆标题。
-func shortURL(u string) string {
-	if i := strings.Index(u, "?"); i > 0 {
-		return u[:i]
-	}
-	return u
-}
-
 // openTarget 用系统默认程序打开 URL 或路径,不经 shell 解析(避免注入面)。
 func openTarget(target string) {
 	if target == "" {

+ 5 - 9
updater.go

@@ -58,8 +58,9 @@ func (s *Supervisor) LatestVersion() (string, error) {
 	return strings.TrimSpace(string(out)), nil
 }
 
-// startAutoUpdate 定期后台更新(单例):每分钟检查开关,到达间隔才查询/更新。
-// 重复调用安全:已有循环时直接返回,避免多个循环并存。
+// startAutoUpdate 启动定期更新循环(进程内单例、常驻)。
+// 循环不因开关关闭而退出——关闭时仅暂停计时,再次开启立即恢复,
+// 避免"关掉再打开"后因旧循环退出而导致定期更新永久失效。
 func (s *Supervisor) startAutoUpdate() {
 	s.autoMu.Lock()
 	if s.autoLoop {
@@ -70,11 +71,6 @@ func (s *Supervisor) startAutoUpdate() {
 	s.autoMu.Unlock()
 
 	go func() {
-		defer func() {
-			s.autoMu.Lock()
-			s.autoLoop = false
-			s.autoMu.Unlock()
-		}()
 		var elapsed time.Duration
 		ticker := time.NewTicker(time.Minute)
 		defer ticker.Stop()
@@ -84,8 +80,8 @@ func (s *Supervisor) startAutoUpdate() {
 			interval := time.Duration(s.cfg.UpdateIntervalH) * time.Hour
 			s.mu.Unlock()
 			if !enabled {
-				s.log.Printf("定期更新:已关闭")
-				return
+				elapsed = 0 // 关闭期间不计时
+				continue
 			}
 			if interval <= 0 {
 				interval = 24 * time.Hour

+ 63 - 0
util.go

@@ -0,0 +1,63 @@
+package main
+
+import (
+	"fmt"
+	"net"
+	"strings"
+	"time"
+)
+
+// util.go —— 无状态的纯工具函数(便于单元测试与复用)。
+
+// isPortOpen 检测端口是否有监听者。
+func isPortOpen(host string, port int) bool {
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 1200*time.Millisecond)
+	if err != nil {
+		return false
+	}
+	_ = conn.Close()
+	return true
+}
+
+// extractHTTPURL 从一行输出里取出 http(s) URL(去除尾随分隔符)。
+func extractHTTPURL(line string) string {
+	idx := strings.Index(line, "http")
+	if idx < 0 {
+		return ""
+	}
+	url := strings.TrimSpace(line[idx:])
+	if i := strings.IndexAny(url, " 	"); i > 0 {
+		url = url[:i]
+	}
+	if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
+		return url
+	}
+	return ""
+}
+
+// humanDuration 简短的时长展示。
+func humanDuration(d time.Duration) string {
+	if d <= 0 {
+		return "0 秒"
+	}
+	sec := int(d.Seconds())
+	switch {
+	case sec < 60:
+		return fmt.Sprintf("%d 秒", sec)
+	case sec < 3600:
+		return fmt.Sprintf("%d 分钟", sec/60)
+	default:
+		return fmt.Sprintf("%d 小时 %d 分", sec/3600, (sec%3600)/60)
+	}
+}
+
+// shortURL 菜单标题只显示 origin,避免 token 撑爆标题。
+func shortURL(u string) string {
+	if i := strings.Index(u, "?"); i > 0 {
+		return u[:i]
+	}
+	return u
+}

+ 150 - 0
util_test.go

@@ -0,0 +1,150 @@
+package main
+
+import (
+	"net"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestRedact 覆盖 token 脱敏的各种边界(含多 token、终止符、无 token)。
+func TestRedact(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"http://127.0.0.1:3099/?token=abc123", "http://127.0.0.1:3099/?token=***"},
+		{"no token here", "no token here"},
+		{"a token=x&b=1", "a token=***&b=1"},
+		{"token=", "token=***"},
+		{"token=a token=b", "token=*** token=***"},
+		{"token=abc\"x", "token=***\"x"},
+		{"", ""},
+	}
+	for _, c := range cases {
+		if got := Redact(c.in); got != c.want {
+			t.Errorf("Redact(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+// TestExtractHTTPURL 覆盖 URL 提取与误报拒绝。
+func TestExtractHTTPURL(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"dsh web: http://127.0.0.1:3099/?token=abc", "http://127.0.0.1:3099/?token=abc"},
+		{"[dsh out] https://example.com/x", "https://example.com/x"},
+		{"no url at all", ""},
+		{"httpx not a url", ""},
+		{"prefix http://a.b c", "http://a.b"},
+	}
+	for _, c := range cases {
+		if got := extractHTTPURL(c.in); got != c.want {
+			t.Errorf("extractHTTPURL(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+func TestShortURL(t *testing.T) {
+	if got := shortURL("http://127.0.0.1:3080/?token=x"); got != "http://127.0.0.1:3080/" {
+		t.Errorf("shortURL 去除 query 失败: %q", got)
+	}
+	if got := shortURL("http://127.0.0.1:3080/"); got != "http://127.0.0.1:3080/" {
+		t.Errorf("shortURL 无 query 不应改变: %q", got)
+	}
+}
+
+func TestHumanDuration(t *testing.T) {
+	cases := []struct {
+		d    time.Duration
+		want string
+	}{
+		{0, "0 秒"},
+		{-time.Second, "0 秒"},
+		{45 * time.Second, "45 秒"},
+		{90 * time.Second, "1 分钟"},
+		{2*time.Hour + 5*time.Minute, "2 小时 5 分"},
+	}
+	for _, c := range cases {
+		if got := humanDuration(c.d); got != c.want {
+			t.Errorf("humanDuration(%v) = %q, want %q", c.d, got, c.want)
+		}
+	}
+}
+
+// TestConfigNormalized 覆盖越界字段回退默认值。
+func TestConfigNormalized(t *testing.T) {
+	c := &Config{WebPort: -1, WebHost: "", UpdateIntervalH: 0, RestartDelaySec: 0}
+	n := c.normalized()
+	if n.WebPort != 3080 {
+		t.Errorf("WebPort 应回退 3080,得到 %d", n.WebPort)
+	}
+	if n.WebHost != "127.0.0.1" {
+		t.Errorf("WebHost 应回退 127.0.0.1,得到 %q", n.WebHost)
+	}
+	if n.UpdateIntervalH != 24 {
+		t.Errorf("UpdateIntervalH 应回退 24,得到 %d", n.UpdateIntervalH)
+	}
+	if n.RestartDelaySec != 5 {
+		t.Errorf("RestartDelaySec 应回退 5,得到 %d", n.RestartDelaySec)
+	}
+
+	over := &Config{WebPort: 70000, WebHost: "127.0.0.1", UpdateIntervalH: 1, RestartDelaySec: 1}
+	if got := over.normalized().WebPort; got != 3080 {
+		t.Errorf("超范围 WebPort 应回退 3080,得到 %d", got)
+	}
+}
+
+// TestIsPortOpen 用真实监听端口验证探测。
+func TestIsPortOpen(t *testing.T) {
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Skipf("无法创建监听: %v", err)
+	}
+	port := ln.Addr().(*net.TCPAddr).Port
+	if !isPortOpen("127.0.0.1", port) {
+		t.Errorf("已监听端口 %d 应判定为 open", port)
+	}
+	_ = ln.Close()
+	time.Sleep(100 * time.Millisecond)
+	if isPortOpen("127.0.0.1", port) {
+		t.Errorf("已关闭端口 %d 应判定为 closed", port)
+	}
+}
+
+// TestLoggerRotate 验证日志按天轮转(切换文件),并清理测试产物。
+func TestLoggerRotate(t *testing.T) {
+	l := NewLogger()
+	defer l.Close()
+	base := l.Path()
+	l.rotate("19990101")
+	rotated := l.Path()
+	if rotated == base {
+		t.Errorf("rotate 未切换日志路径: %q", rotated)
+	}
+	if !strings.HasSuffix(rotated, "tray-19990101.log") {
+		t.Errorf("轮转文件名不符合预期: %q", rotated)
+	}
+	t.Cleanup(func() { _ = os.Remove(rotated) })
+}
+
+// TestBackoff 验证指数退避序列与上限。
+func TestBackoff(t *testing.T) {
+	s := NewSupervisor(&Config{RestartDelaySec: 3, WebHost: "127.0.0.1", WebPort: 1}, nil)
+	// 无日志时 Logger 为 nil 会 panic,这里只测纯计算:手动构造 failures
+	_ = s
+	want := []time.Duration{3 * time.Second, 6 * time.Second, 12 * time.Second, 24 * time.Second, 48 * time.Second}
+	for i, w := range want {
+		s.failures = i
+		if got := s.backoff(); got != w {
+			t.Errorf("failures=%d backoff=%v, want %v", i, got, w)
+		}
+	}
+	// 上限 2 分钟
+	s.failures = 10
+	if got := s.backoff(); got != 2*time.Minute {
+		t.Errorf("退避上限应为 2 分钟,得到 %v", got)
+	}
+	// 端口占用温和退避上限 30 秒
+	s.failures = 10
+	if got := s.portBusyBackoff(); got != 30*time.Second {
+		t.Errorf("端口占用退避上限应为 30 秒,得到 %v", got)
+	}
+}