Sfoglia il codice sorgente

fix: v2.4.0 修复实测发现的三个问题

- 打开日志/配置无反应:openTarget 误用 HideWindow(SW_HIDE) 把 explorer 窗口隐藏;改用 ShellExecuteW(SW_SHOWNORMAL),并补齐菜单日志

- Web 界面启动期无法访问:dsh web 启动到输出 token 需 29~38s,期间打开的是无 token 地址;新增 Status.Ready,未就绪时菜单显示启动中并禁用

- 更新白等 5 分钟:版本相同时 npm 仍整包重装 518 个包;新增 upToDate 比对,已是最新则跳过安装与重启

- 菜单响应性:停止/重启等耗时操作后台执行(防重入),不再阻塞菜单

- 可观测性:日志记录 Web 启动耗时;查看日志/打开配置补日志
caesar 16 ore fa
parent
commit
cd2654936a
5 ha cambiato i file con 142 aggiunte e 35 eliminazioni
  1. 3 1
      README.md
  2. 32 0
      proc_windows.go
  3. 8 2
      supervisor.go
  4. 80 32
      tray.go
  5. 19 0
      updater.go

+ 3 - 1
README.md

@@ -139,7 +139,8 @@ nssm start DeepSeekTray
 | 健壮性 | 端口被占用 | 不启动冲突进程,退避 6s→12s→24s | 日志时间戳 |
 | 健壮性 | 配置损坏 / UTF-8 BOM | 回退默认值 / 正确读取 | 单元测试 + CLI |
 | 安全性 | 日志 token | 输出 `token=***`(真实值仅存内存) | 日志 |
-| 安全性 | 打开 URL/路径 | 不经 shell 解析 | 代码审查 |
+| 安全性 | 打开 URL/路径 | 不经 shell(ShellExecuteW) | 探针测试通过 |
+| 可靠性 | 打开日志/配置 | 窗口正常显示(不再被 SW_HIDE 隐藏) | 探针调用成功(目视确认由用户完成) |
 | 效率 | status 查询 / 二进制 | 0.4 秒 / 约 5.2 MB | 计时与文件大小 |
 
 > 复现方式:`build.ps1` 跑单元测试;上表运行时项可用 `--port 3099` 与假命令分别复现。未标注"实测"的行为代码审查结论。
@@ -152,6 +153,7 @@ nssm start DeepSeekTray
 
 ## 版本
 
+- v2.4.0 — 修复实测发现的三个问题:**打开日志/配置无反应**(误用 SW_HIDE 把 explorer 窗口隐藏了 → 改用 ShellExecuteW)、**Web 界面启动期不可用**(新增就绪状态,启动中禁用点击而非打开无效地址)、**更新白等 5 分钟**(版本相同时跳过整包重装与重启);菜单耗时操作后台执行不再阻塞;日志记录 Web 启动耗时
 - v2.3.0 — 三次迭代:修复**停止/启动竞态**(不再残留进程)、配置写入线程安全、更新前先停服务、配置读写单元测试与守护生命周期测试、`build.ps1` 一键构建
 - v2.2.0 — 二次迭代:**新增单元测试**(并借其修复退避上限问题)、修复定期更新"关闭后再开启"失效、职责拆分(probe/util)、日志按天轮转、进程终止兜底、daemon 优雅退出
 - v2.1.0 — 自检优化:端口预检 + 指数退避 + 定期更新单例 + `Local` 互斥体 + 日志脱敏 + 原子配置 + `autostart` 子命令

+ 32 - 0
proc_windows.go

@@ -4,6 +4,7 @@ package main
 
 import (
 	"context"
+	"fmt"
 	"os"
 	"os/exec"
 	"strconv"
@@ -64,10 +65,41 @@ func processAlive(pid int) bool {
 
 var (
 	kernel32          = windows.NewLazySystemDLL("kernel32.dll")
+	shell32           = windows.NewLazySystemDLL("shell32.dll")
 	procCreateMutex   = kernel32.NewProc("CreateMutexW")
 	procAttachConsole = kernel32.NewProc("AttachConsole")
+	procShellExecuteW = shell32.NewProc("ShellExecuteW")
 )
 
+// openExternal 用系统 shell 打开 URL / 文件 / 目录。
+// 注意:这里绝不能设置 HideWindow / CREATE_NO_WINDOW —— explorer 打开的目录窗口
+// 会被 SW_HIDE 隐藏(表现为"点击菜单无反应")。
+func openExternal(target string) error {
+	if target == "" {
+		return nil
+	}
+	verb, err := windows.UTF16PtrFromString("open")
+	if err != nil {
+		return err
+	}
+	file, err := windows.UTF16PtrFromString(target)
+	if err != nil {
+		return err
+	}
+	const swShowNormal = 1
+	ret, _, callErr := procShellExecuteW.Call(
+		0,
+		uintptr(unsafe.Pointer(verb)),
+		uintptr(unsafe.Pointer(file)),
+		0, 0,
+		swShowNormal,
+	)
+	if ret <= 32 { // ShellExecuteW 约定:<=32 表示失败
+		return fmt.Errorf("ShellExecuteW(%q) 失败: code=%d err=%v", target, ret, callErr)
+	}
+	return nil
+}
+
 // singleInstance 基于命名互斥体的单实例保护。
 type singleInstance struct {
 	handle windows.Handle

+ 8 - 2
supervisor.go

@@ -20,6 +20,7 @@ const healthyUptime = 30 * time.Second
 // Status 服务状态快照。
 type Status struct {
 	Running   bool
+	Ready     bool // 已捕获带 token 的访问地址,Web 界面可打开
 	PID       int
 	Restarts  int
 	Uptime    time.Duration
@@ -105,8 +106,12 @@ func (s *Supervisor) SetAutoStart(enabled bool) error {
 }
 
 // UpdateAndRestart 先停止服务再更新,避免 Windows 上 native 模块文件被占用导致安装失败;
-// 无论更新成功与否都恢复到原有运行意图。
+// 无论更新成功与否都恢复到原有运行意图。已是最新版本时既不安装也不重启。
 func (s *Supervisor) UpdateAndRestart() (string, error) {
+	if fresh, current := s.upToDate(); fresh {
+		s.log.Printf("更新:已是最新(%s),不重启服务", current)
+		return "已是最新版本 " + current, nil
+	}
 	wasDesired := s.isDesired()
 	if wasDesired {
 		s.Stop()
@@ -139,6 +144,7 @@ func (s *Supervisor) Status() Status {
 	}
 	return Status{
 		Running:   s.running,
+		Ready:     s.running && s.webURL != "",
 		PID:       s.pid,
 		Restarts:  s.restarts,
 		Uptime:    uptime,
@@ -354,7 +360,7 @@ func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
 			s.mu.Lock()
 			if url != s.webURL {
 				s.webURL = url
-				s.log.Printf("捕获 Web 访问地址(含 token,仅存内存)")
+				s.log.Printf("捕获 Web 访问地址(含 token,仅存内存);启动耗时 %s", time.Since(s.startedAt).Round(time.Second))
 			}
 			s.mu.Unlock()
 		}

+ 80 - 32
tray.go

@@ -3,8 +3,8 @@ package main
 import (
 	_ "embed"
 	"fmt"
-	"os/exec"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/getlantern/systray"
@@ -27,15 +27,15 @@ func runTray(cfg *Config, log *Logger) {
 
 func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
-	mURL := systray.AddMenuItem("打开 Web 界面", "")
+	mURL := systray.AddMenuItem("Web 界面:等待服务就绪…", "服务就绪后打开(自动带 token)")
 	systray.AddSeparator()
 	mStart := systray.AddMenuItem("启动服务", "启动 dsh web")
 	mStop := systray.AddMenuItem("停止服务", "停止 dsh web")
 	mRestart := systray.AddMenuItem("重启服务", "重启 dsh web")
 	systray.AddSeparator()
 	mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务(无窗口、不打开浏览器)", isAutoStartEnabled())
-	mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh", cfg.AutoUpdate)
-	mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest")
+	mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh(已是最新则跳过)", cfg.AutoUpdate)
+	mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest(先停服务)")
 	systray.AddSeparator()
 	mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
 	mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
@@ -44,15 +44,30 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘")
 
 	mStop.Disable()
+	mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址)
 
-	// 状态刷新:仅在有变化时写 UI(减少 systray 调用)
+	// runOp 在后台执行耗时操作(停止/重启可能等待数秒),避免阻塞菜单响应;同一时刻只允许一个。
+	var opMu sync.Mutex
+	runOp := func(name string, fn func()) {
+		go func() {
+			if !opMu.TryLock() {
+				log.Printf("%s:已有操作进行中,忽略本次", name)
+				return
+			}
+			defer opMu.Unlock()
+			fn()
+		}()
+	}
+
+	// 状态刷新:仅在有变化时写 UI
 	go func() {
 		ticker := time.NewTicker(3 * time.Second)
 		defer ticker.Stop()
 		var lastTitle, lastTip, lastURL string
-		lastRunning := false
+		lastRunning, lastWebEnabled := false, false
 		for range ticker.C {
 			st := sup.Status()
+
 			if title := statusTitle(st); title != lastTitle {
 				mStatus.SetTitle(title)
 				lastTitle = title
@@ -61,10 +76,32 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				mStatus.SetTooltip(tip)
 				lastTip = tip
 			}
-			if u := "打开 Web 界面:" + shortURL(st.URL); u != lastURL {
-				mURL.SetTitle(u)
-				lastURL = u
+
+			// Web 菜单:就绪前禁用,避免"打开后无法访问"
+			webText, webEnabled := "", true
+			switch {
+			case st.Ready:
+				webText = "打开 Web 界面:" + shortURL(st.URL)
+			case st.External:
+				webText = "打开 Web 界面(外部实例):" + shortURL(st.URL)
+			case st.Running:
+				webText, webEnabled = "Web 界面:服务启动中…", false
+			default:
+				webText, webEnabled = "打开 Web 界面(服务未运行)", false
+			}
+			if webText != lastURL {
+				mURL.SetTitle(webText)
+				lastURL = webText
+			}
+			if webEnabled != lastWebEnabled {
+				if webEnabled {
+					mURL.Enable()
+				} else {
+					mURL.Disable()
+				}
+				lastWebEnabled = webEnabled
 			}
+
 			if st.Running != lastRunning {
 				if st.Running {
 					mStart.Disable()
@@ -90,23 +127,29 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 		}
 	}()
 
-	// 菜单事件
+	// 菜单事件:只做记录与分发(耗时操作交给 runOp,保证菜单不卡)
 	go func() {
 		for {
 			select {
 			case <-mStart.ClickedCh:
 				log.Printf("菜单:启动服务")
-				sup.Start()
+				runOp("启动服务", sup.Start)
 			case <-mStop.ClickedCh:
 				log.Printf("菜单:停止服务")
-				sup.Stop()
+				runOp("停止服务", sup.Stop)
 			case <-mRestart.ClickedCh:
 				log.Printf("菜单:重启服务")
-				sup.Restart()
+				runOp("重启服务", sup.Restart)
 			case <-mURL.ClickedCh:
-				target := sup.Status().URL
-				log.Printf("菜单:打开 Web 界面")
-				openTarget(target)
+				st := sup.Status()
+				if !st.Ready && !st.External {
+					log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)")
+					continue
+				}
+				log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL))
+				if err := openExternal(st.URL); err != nil {
+					log.Printf("打开 Web 界面失败: %v", err)
+				}
 			case <-mAutoStart.ClickedCh:
 				next := !mAutoStart.Checked()
 				if err := setAutoStart(next); err != nil {
@@ -136,17 +179,24 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 			case <-mUpdateNow.ClickedCh:
 				go func() {
 					log.Printf("菜单:立即更新(先停服务再更新)")
-					if _, err := sup.UpdateAndRestart(); err != nil {
+					text, err := sup.UpdateAndRestart()
+					if err != nil {
 						log.Printf("更新失败: %v", err)
 					} else {
-						log.Printf("更新完成,服务已恢复运行")
+						log.Printf("更新完成: %s", firstLine(text))
 					}
 					mVersion.SetTitle("版本:" + sup.installedVersion())
 				}()
 			case <-mLogs.ClickedCh:
-				openTarget(logDir())
+				log.Printf("菜单:查看日志 %s", logDir())
+				if err := openExternal(logDir()); err != nil {
+					log.Printf("打开日志失败: %v", err)
+				}
 			case <-mConfig.ClickedCh:
-				openTarget(configPath())
+				log.Printf("菜单:打开配置文件 %s", configPath())
+				if err := openExternal(configPath()); err != nil {
+					log.Printf("打开配置失败: %v", err)
+				}
 			case <-mQuit.ClickedCh:
 				log.Printf("菜单:退出")
 				systray.Quit()
@@ -165,6 +215,8 @@ func statusTitle(st Status) string {
 	switch {
 	case st.Updating:
 		return "状态:正在更新…"
+	case st.Running && !st.Ready:
+		return fmt.Sprintf("状态:启动中 (PID %d)", st.PID)
 	case st.Running:
 		return fmt.Sprintf("状态:运行中 (PID %d)", st.PID)
 	case st.External:
@@ -179,6 +231,9 @@ func statusTooltip(st Status) string {
 	if st.Running {
 		parts = append(parts, "运行 "+humanDuration(st.Uptime))
 	}
+	if st.Running && !st.Ready {
+		parts = append(parts, "Web 界面启动中(首次约需 30 秒)")
+	}
 	if st.Restarts > 0 {
 		parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))
 	}
@@ -191,17 +246,10 @@ func statusTooltip(st Status) string {
 	return strings.Join(parts, ";")
 }
 
-// openTarget 用系统默认程序打开 URL 或路径,不经 shell 解析(避免注入面)。
-func openTarget(target string) {
-	if target == "" {
-		return
-	}
-	var cmd *exec.Cmd
-	if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
-		cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", target)
-	} else {
-		cmd = exec.Command("explorer.exe", target)
+// firstLine 取多行输出的首行,便于日志摘要。
+func firstLine(s string) string {
+	if i := strings.IndexByte(s, '\n'); i >= 0 {
+		return s[:i]
 	}
-	hideWindow(cmd)
-	_ = cmd.Start()
+	return s
 }

+ 19 - 0
updater.go

@@ -11,7 +11,21 @@ import (
 
 const allowScripts = "@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs"
 
+// upToDate 判断已安装版本是否已是最新(查询失败时按"非最新"处理)。
+func (s *Supervisor) upToDate() (bool, string) {
+	current := s.installedVersion()
+	if current == "" {
+		return false, ""
+	}
+	latest, err := s.LatestVersion()
+	if err != nil {
+		return false, current
+	}
+	return latest == current, current
+}
+
 // Update 执行全局更新:npm install -g @deepseek-ai/dsh@latest --allow-scripts=...
+// 已是最新版本时直接跳过——避免版本相同时仍整包重装(实测曾耗时 5 分钟)。
 func (s *Supervisor) Update() (string, error) {
 	s.mu.Lock()
 	if s.updating {
@@ -26,6 +40,11 @@ func (s *Supervisor) Update() (string, error) {
 		s.mu.Unlock()
 	}()
 
+	if fresh, current := s.upToDate(); fresh {
+		s.log.Printf("更新:已是最新(%s),跳过安装", current)
+		return "已是最新版本 " + current, nil
+	}
+
 	exe, args := npmCommandFor("install", "-g", dshPackage+"@latest", "--allow-scripts="+allowScripts)
 	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
 	defer cancel()