tray.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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. // 菜单标题保持短文本(长 URL 会撑宽整个菜单、显得不协调),详情放 tooltip。
  25. mStatus := systray.AddMenuItem("初始化…", "当前服务状态")
  26. mURL := systray.AddMenuItem("打开 Web 界面", "服务就绪后可用(自动带 token)")
  27. systray.AddSeparator()
  28. mStart := systray.AddMenuItem("启动服务", "启动 dsh web")
  29. mStop := systray.AddMenuItem("停止服务", "停止 dsh web")
  30. mRestart := systray.AddMenuItem("重启服务", "重启 dsh web")
  31. systray.AddSeparator()
  32. mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务(无窗口、不打开浏览器)", isAutoStartEnabled())
  33. mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh(已是最新则跳过)", cfg.AutoUpdate)
  34. mUpdateNow := systray.AddMenuItem("立即更新 DSH", "检查并安装最新版(先停服务;已是最新则秒回)")
  35. systray.AddSeparator()
  36. mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
  37. mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
  38. mConfig := systray.AddMenuItem("打开配置文件", "打开 config.json")
  39. systray.AddSeparator()
  40. mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘")
  41. mStop.Disable()
  42. mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址)
  43. // 临时通知:覆盖状态标题若干秒,让"点完看不到变化"的操作有回显。
  44. var noticeMu sync.Mutex
  45. noticeText := ""
  46. var noticeUntil time.Time
  47. setNotice := func(text string, d time.Duration) {
  48. noticeMu.Lock()
  49. noticeText, noticeUntil = text, time.Now().Add(d)
  50. noticeMu.Unlock()
  51. }
  52. takeNotice := func() string {
  53. noticeMu.Lock()
  54. defer noticeMu.Unlock()
  55. if noticeText == "" || time.Now().After(noticeUntil) {
  56. noticeText = ""
  57. return ""
  58. }
  59. return noticeText
  60. }
  61. // runOp 后台执行耗时操作(停止/重启/更新可能等待数秒到数分钟),
  62. // 保证菜单点击立即响应;同一时刻只允许一个长操作。
  63. var opMu sync.Mutex
  64. runOp := func(name string, fn func()) {
  65. go func() {
  66. if !opMu.TryLock() {
  67. log.Printf("%s:已有操作进行中,忽略本次", name)
  68. setNotice("已有操作进行中…", 5*time.Second)
  69. return
  70. }
  71. defer opMu.Unlock()
  72. fn()
  73. }()
  74. }
  75. // 状态刷新:仅在有变化时写 UI
  76. go func() {
  77. ticker := time.NewTicker(3 * time.Second)
  78. defer ticker.Stop()
  79. var lastTitle, lastTip, lastWebTip string
  80. lastRunning, lastWebEnabled := false, false
  81. for range ticker.C {
  82. st := sup.Status()
  83. title := statusTitle(st)
  84. if n := takeNotice(); n != "" {
  85. title = n
  86. }
  87. if title != lastTitle {
  88. mStatus.SetTitle(title)
  89. lastTitle = title
  90. }
  91. if tip := statusTooltip(st); tip != lastTip {
  92. mStatus.SetTooltip(tip)
  93. lastTip = tip
  94. }
  95. // Web 菜单:标题固定,仅切换可用状态与 tooltip
  96. webEnable, webTip := false, ""
  97. switch {
  98. case st.Ready:
  99. webEnable, webTip = true, "打开:"+st.URL
  100. case st.External:
  101. webEnable, webTip = true, "外部实例:"+st.URL
  102. case st.Running:
  103. webTip = "服务启动中,就绪后自动可用(首次约 10~40 秒)"
  104. default:
  105. webTip = "服务未运行"
  106. }
  107. if webEnable != lastWebEnabled {
  108. if webEnable {
  109. mURL.Enable()
  110. } else {
  111. mURL.Disable()
  112. }
  113. lastWebEnabled = webEnable
  114. }
  115. if webTip != lastWebTip {
  116. mURL.SetTooltip(webTip)
  117. lastWebTip = webTip
  118. }
  119. if st.Running != lastRunning {
  120. if st.Running {
  121. mStart.Disable()
  122. mStop.Enable()
  123. } else {
  124. mStart.Enable()
  125. mStop.Disable()
  126. }
  127. lastRunning = st.Running
  128. }
  129. }
  130. }()
  131. // 版本刷新(启动一次 + 每 10 分钟)
  132. go func() {
  133. for {
  134. v := sup.installedVersion()
  135. if v == "" {
  136. v = "未知"
  137. }
  138. mVersion.SetTitle("版本:" + v)
  139. time.Sleep(10 * time.Minute)
  140. }
  141. }()
  142. // 菜单事件:只做记录与分发(耗时操作交给 runOp,保证菜单不卡)
  143. go func() {
  144. for {
  145. select {
  146. case <-mStart.ClickedCh:
  147. log.Printf("菜单:启动服务")
  148. runOp("启动服务", sup.Start)
  149. case <-mStop.ClickedCh:
  150. log.Printf("菜单:停止服务")
  151. runOp("停止服务", sup.Stop)
  152. case <-mRestart.ClickedCh:
  153. log.Printf("菜单:重启服务")
  154. runOp("重启服务", sup.Restart)
  155. case <-mURL.ClickedCh:
  156. st := sup.Status()
  157. if !st.Ready && !st.External {
  158. log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)")
  159. setNotice("Web 界面启动中,请稍候…", 4*time.Second)
  160. continue
  161. }
  162. log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL))
  163. if err := openExternal(st.URL); err != nil {
  164. log.Printf("打开 Web 界面失败: %v", err)
  165. setNotice("打开失败(详见日志)", 6*time.Second)
  166. }
  167. case <-mAutoStart.ClickedCh:
  168. next := !mAutoStart.Checked()
  169. if err := setAutoStart(next); err != nil {
  170. log.Printf("设置开机自启失败: %v", err)
  171. setNotice("设置开机自启失败", 6*time.Second)
  172. continue
  173. }
  174. if next {
  175. mAutoStart.Check()
  176. } else {
  177. mAutoStart.Uncheck()
  178. }
  179. if err := sup.SetAutoStart(next); err != nil {
  180. log.Printf("保存配置失败: %v", err)
  181. }
  182. log.Printf("开机自启 = %v", next)
  183. if next {
  184. setNotice("开机自启:已开启", 4*time.Second)
  185. } else {
  186. setNotice("开机自启:已关闭", 4*time.Second)
  187. }
  188. case <-mAutoUpdate.ClickedCh:
  189. next := !mAutoUpdate.Checked()
  190. if next {
  191. mAutoUpdate.Check()
  192. } else {
  193. mAutoUpdate.Uncheck()
  194. }
  195. if err := sup.SetAutoUpdate(next); err != nil {
  196. log.Printf("保存配置失败: %v", err)
  197. }
  198. log.Printf("定期自动更新 = %v", next)
  199. if next {
  200. setNotice("定期自动更新:已开启", 4*time.Second)
  201. } else {
  202. setNotice("定期自动更新:已关闭", 4*time.Second)
  203. }
  204. case <-mUpdateNow.ClickedCh:
  205. log.Printf("菜单:立即更新")
  206. runOp("立即更新", func() {
  207. text, err := sup.UpdateAndRestart()
  208. if err != nil {
  209. log.Printf("更新失败: %v", err)
  210. setNotice("更新失败(详见日志)", 8*time.Second)
  211. } else {
  212. log.Printf("更新完成: %s", firstLine(text))
  213. setNotice(firstLine(text), 8*time.Second)
  214. }
  215. mVersion.SetTitle("版本:" + sup.installedVersion())
  216. })
  217. case <-mLogs.ClickedCh:
  218. log.Printf("菜单:查看日志 %s", logDir())
  219. if err := openExternal(logDir()); err != nil {
  220. log.Printf("打开日志失败: %v", err)
  221. setNotice("打开日志失败(详见日志)", 6*time.Second)
  222. }
  223. case <-mConfig.ClickedCh:
  224. log.Printf("菜单:打开配置文件 %s", configPath())
  225. if err := openExternal(configPath()); err != nil {
  226. log.Printf("打开配置失败: %v", err)
  227. setNotice("打开配置失败(详见日志)", 6*time.Second)
  228. }
  229. case <-mQuit.ClickedCh:
  230. log.Printf("菜单:退出")
  231. systray.Quit()
  232. return
  233. }
  234. }
  235. }()
  236. sup.Start()
  237. if cfg.AutoUpdate {
  238. sup.startAutoUpdate()
  239. }
  240. }
  241. func statusTitle(st Status) string {
  242. switch {
  243. case st.Updating:
  244. return "正在更新…"
  245. case st.Running && !st.Ready:
  246. return fmt.Sprintf("启动中 · PID %d", st.PID)
  247. case st.Running:
  248. return fmt.Sprintf("运行中 · PID %d", st.PID)
  249. case st.External:
  250. return "外部实例运行中"
  251. default:
  252. return "已停止"
  253. }
  254. }
  255. func statusTooltip(st Status) string {
  256. parts := make([]string, 0, 4)
  257. if st.Running {
  258. parts = append(parts, "运行 "+humanDuration(st.Uptime))
  259. }
  260. if st.Running && !st.Ready {
  261. parts = append(parts, "Web 界面启动中")
  262. }
  263. if st.Restarts > 0 {
  264. parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))
  265. }
  266. if st.LastError != "" {
  267. parts = append(parts, "最近错误:"+st.LastError)
  268. }
  269. if len(parts) == 0 {
  270. return "服务未运行"
  271. }
  272. return strings.Join(parts, ";")
  273. }
  274. // firstLine 取多行输出的首行,便于日志摘要。
  275. func firstLine(s string) string {
  276. if i := strings.IndexByte(s, '\n'); i >= 0 {
  277. return s[:i]
  278. }
  279. return s
  280. }