tray.go 9.6 KB

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