tray.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. package main
  2. import (
  3. _ "embed"
  4. "fmt"
  5. "strings"
  6. "sync"
  7. "time"
  8. "github.com/getlantern/systray"
  9. )
  10. //go:embed icon.ico
  11. var iconData []byte
  12. func runTray(cfg *Config, log *Logger) {
  13. sup := NewSupervisor(cfg, log)
  14. systray.Run(func() {
  15. systray.SetIcon(iconData)
  16. systray.SetTooltip("DeepSeek Tray - dsh web 守护")
  17. trayReady(sup, cfg, log)
  18. }, func() {
  19. log.Printf("托盘退出,停止服务")
  20. sup.Stop()
  21. })
  22. }
  23. func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
  24. mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
  25. mURL := systray.AddMenuItem("Web 界面:等待服务就绪…", "服务就绪后打开(自动带 token)")
  26. systray.AddSeparator()
  27. mStart := systray.AddMenuItem("启动服务", "启动 dsh web")
  28. mStop := systray.AddMenuItem("停止服务", "停止 dsh web")
  29. mRestart := systray.AddMenuItem("重启服务", "重启 dsh web")
  30. systray.AddSeparator()
  31. mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务(无窗口、不打开浏览器)", isAutoStartEnabled())
  32. mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh(已是最新则跳过)", cfg.AutoUpdate)
  33. mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest(先停服务)")
  34. systray.AddSeparator()
  35. mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
  36. mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
  37. mConfig := systray.AddMenuItem("打开配置文件", "打开 config.json")
  38. systray.AddSeparator()
  39. mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘")
  40. mStop.Disable()
  41. mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址)
  42. // runOp 在后台执行耗时操作(停止/重启可能等待数秒),避免阻塞菜单响应;同一时刻只允许一个。
  43. var opMu sync.Mutex
  44. runOp := func(name string, fn func()) {
  45. go func() {
  46. if !opMu.TryLock() {
  47. log.Printf("%s:已有操作进行中,忽略本次", name)
  48. return
  49. }
  50. defer opMu.Unlock()
  51. fn()
  52. }()
  53. }
  54. // 状态刷新:仅在有变化时写 UI
  55. go func() {
  56. ticker := time.NewTicker(3 * time.Second)
  57. defer ticker.Stop()
  58. var lastTitle, lastTip, lastURL string
  59. lastRunning, lastWebEnabled := false, false
  60. for range ticker.C {
  61. st := sup.Status()
  62. if title := statusTitle(st); title != lastTitle {
  63. mStatus.SetTitle(title)
  64. lastTitle = title
  65. }
  66. if tip := statusTooltip(st); tip != lastTip {
  67. mStatus.SetTooltip(tip)
  68. lastTip = tip
  69. }
  70. // Web 菜单:就绪前禁用,避免"打开后无法访问"
  71. webText, webEnabled := "", true
  72. switch {
  73. case st.Ready:
  74. webText = "打开 Web 界面:" + shortURL(st.URL)
  75. case st.External:
  76. webText = "打开 Web 界面(外部实例):" + shortURL(st.URL)
  77. case st.Running:
  78. webText, webEnabled = "Web 界面:服务启动中…", false
  79. default:
  80. webText, webEnabled = "打开 Web 界面(服务未运行)", false
  81. }
  82. if webText != lastURL {
  83. mURL.SetTitle(webText)
  84. lastURL = webText
  85. }
  86. if webEnabled != lastWebEnabled {
  87. if webEnabled {
  88. mURL.Enable()
  89. } else {
  90. mURL.Disable()
  91. }
  92. lastWebEnabled = webEnabled
  93. }
  94. if st.Running != lastRunning {
  95. if st.Running {
  96. mStart.Disable()
  97. mStop.Enable()
  98. } else {
  99. mStart.Enable()
  100. mStop.Disable()
  101. }
  102. lastRunning = st.Running
  103. }
  104. }
  105. }()
  106. // 版本刷新(启动一次 + 每 10 分钟)
  107. go func() {
  108. for {
  109. v := sup.installedVersion()
  110. if v == "" {
  111. v = "未知"
  112. }
  113. mVersion.SetTitle("版本:" + v)
  114. time.Sleep(10 * time.Minute)
  115. }
  116. }()
  117. // 菜单事件:只做记录与分发(耗时操作交给 runOp,保证菜单不卡)
  118. go func() {
  119. for {
  120. select {
  121. case <-mStart.ClickedCh:
  122. log.Printf("菜单:启动服务")
  123. runOp("启动服务", sup.Start)
  124. case <-mStop.ClickedCh:
  125. log.Printf("菜单:停止服务")
  126. runOp("停止服务", sup.Stop)
  127. case <-mRestart.ClickedCh:
  128. log.Printf("菜单:重启服务")
  129. runOp("重启服务", sup.Restart)
  130. case <-mURL.ClickedCh:
  131. st := sup.Status()
  132. if !st.Ready && !st.External {
  133. log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)")
  134. continue
  135. }
  136. log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL))
  137. if err := openExternal(st.URL); err != nil {
  138. log.Printf("打开 Web 界面失败: %v", err)
  139. }
  140. case <-mAutoStart.ClickedCh:
  141. next := !mAutoStart.Checked()
  142. if err := setAutoStart(next); err != nil {
  143. log.Printf("设置开机自启失败: %v", err)
  144. continue
  145. }
  146. if next {
  147. mAutoStart.Check()
  148. } else {
  149. mAutoStart.Uncheck()
  150. }
  151. if err := sup.SetAutoStart(next); err != nil {
  152. log.Printf("保存配置失败: %v", err)
  153. }
  154. log.Printf("开机自启 = %v", next)
  155. case <-mAutoUpdate.ClickedCh:
  156. next := !mAutoUpdate.Checked()
  157. if next {
  158. mAutoUpdate.Check()
  159. } else {
  160. mAutoUpdate.Uncheck()
  161. }
  162. if err := sup.SetAutoUpdate(next); err != nil {
  163. log.Printf("保存配置失败: %v", err)
  164. }
  165. log.Printf("定期自动更新 = %v", next)
  166. case <-mUpdateNow.ClickedCh:
  167. go func() {
  168. log.Printf("菜单:立即更新(先停服务再更新)")
  169. text, err := sup.UpdateAndRestart()
  170. if err != nil {
  171. log.Printf("更新失败: %v", err)
  172. } else {
  173. log.Printf("更新完成: %s", firstLine(text))
  174. }
  175. mVersion.SetTitle("版本:" + sup.installedVersion())
  176. }()
  177. case <-mLogs.ClickedCh:
  178. log.Printf("菜单:查看日志 %s", logDir())
  179. if err := openExternal(logDir()); err != nil {
  180. log.Printf("打开日志失败: %v", err)
  181. }
  182. case <-mConfig.ClickedCh:
  183. log.Printf("菜单:打开配置文件 %s", configPath())
  184. if err := openExternal(configPath()); err != nil {
  185. log.Printf("打开配置失败: %v", err)
  186. }
  187. case <-mQuit.ClickedCh:
  188. log.Printf("菜单:退出")
  189. systray.Quit()
  190. return
  191. }
  192. }
  193. }()
  194. sup.Start()
  195. if cfg.AutoUpdate {
  196. sup.startAutoUpdate()
  197. }
  198. }
  199. func statusTitle(st Status) string {
  200. switch {
  201. case st.Updating:
  202. return "状态:正在更新…"
  203. case st.Running && !st.Ready:
  204. return fmt.Sprintf("状态:启动中 (PID %d)", st.PID)
  205. case st.Running:
  206. return fmt.Sprintf("状态:运行中 (PID %d)", st.PID)
  207. case st.External:
  208. return "状态:外部实例运行中"
  209. default:
  210. return "状态:已停止"
  211. }
  212. }
  213. func statusTooltip(st Status) string {
  214. parts := make([]string, 0, 3)
  215. if st.Running {
  216. parts = append(parts, "运行 "+humanDuration(st.Uptime))
  217. }
  218. if st.Running && !st.Ready {
  219. parts = append(parts, "Web 界面启动中(首次约需 30 秒)")
  220. }
  221. if st.Restarts > 0 {
  222. parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))
  223. }
  224. if st.LastError != "" {
  225. parts = append(parts, "最近错误:"+st.LastError)
  226. }
  227. if len(parts) == 0 {
  228. return "服务未运行"
  229. }
  230. return strings.Join(parts, ";")
  231. }
  232. // firstLine 取多行输出的首行,便于日志摘要。
  233. func firstLine(s string) string {
  234. if i := strings.IndexByte(s, '\n'); i >= 0 {
  235. return s[:i]
  236. }
  237. return s
  238. }