tray.go 8.3 KB

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