package main import ( _ "embed" "fmt" "strings" "sync" "time" "github.com/getlantern/systray" ) //go:embed icon.ico var iconData []byte func runTray(cfg *Config, log *Logger) { sup := NewSupervisor(cfg, log) systray.Run(func() { systray.SetIcon(iconData) systray.SetTooltip("DeepSeek Tray - dsh web 守护") trayReady(sup, cfg, log) }, func() { log.Printf("托盘退出,停止服务") sup.Stop() }) } func trayReady(sup *Supervisor, cfg *Config, log *Logger) { // 菜单标题保持短文本(长 URL 会撑宽整个菜单、显得不协调),详情放 tooltip。 mStatus := systray.AddMenuItem("初始化…", "当前服务状态") 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", "检查并安装最新版(先停服务;已是最新则秒回)") systray.AddSeparator() mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本") mLogs := systray.AddMenuItem("查看日志", "打开日志目录") mConfig := systray.AddMenuItem("打开配置文件", "打开 config.json") systray.AddSeparator() mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘") mStop.Disable() mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址) // 临时通知:覆盖状态标题若干秒,让"点完看不到变化"的操作有回显。 var noticeMu sync.Mutex noticeText := "" var noticeUntil time.Time setNotice := func(text string, d time.Duration) { noticeMu.Lock() noticeText, noticeUntil = text, time.Now().Add(d) noticeMu.Unlock() } takeNotice := func() string { noticeMu.Lock() defer noticeMu.Unlock() if noticeText == "" || time.Now().After(noticeUntil) { noticeText = "" return "" } return noticeText } // runOp 后台执行耗时操作(停止/重启/更新可能等待数秒到数分钟), // 保证菜单点击立即响应;同一时刻只允许一个长操作。 var opMu sync.Mutex runOp := func(name string, fn func()) { go func() { if !opMu.TryLock() { log.Printf("%s:已有操作进行中,忽略本次", name) setNotice("已有操作进行中…", 5*time.Second) return } defer opMu.Unlock() fn() }() } // 状态刷新:仅在有变化时写 UI go func() { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() var lastTitle, lastTip, lastWebTip string lastRunning, lastWebEnabled := false, false for range ticker.C { st := sup.Status() title := statusTitle(st) if n := takeNotice(); n != "" { title = n } if title != lastTitle { mStatus.SetTitle(title) lastTitle = title } if tip := statusTooltip(st); tip != lastTip { mStatus.SetTooltip(tip) lastTip = tip } // Web 菜单:标题固定,仅切换可用状态与 tooltip webEnable, webTip := false, "" switch { case st.Ready: webEnable, webTip = true, "打开:"+st.URL case st.External: webEnable, webTip = true, "外部实例:"+st.URL case st.Running: webTip = "服务启动中,就绪后自动可用(首次约 10~40 秒)" default: webTip = "服务未运行" } if webEnable != lastWebEnabled { if webEnable { mURL.Enable() } else { mURL.Disable() } lastWebEnabled = webEnable } if webTip != lastWebTip { mURL.SetTooltip(webTip) lastWebTip = webTip } if st.Running != lastRunning { if st.Running { mStart.Disable() mStop.Enable() } else { mStart.Enable() mStop.Disable() } lastRunning = st.Running } } }() // 版本刷新(启动一次 + 每 10 分钟) go func() { for { v := sup.installedVersion() if v == "" { v = "未知" } mVersion.SetTitle("版本:" + v) time.Sleep(10 * time.Minute) } }() // 菜单事件:只做记录与分发(耗时操作交给 runOp,保证菜单不卡) go func() { for { select { case <-mStart.ClickedCh: log.Printf("菜单:启动服务") runOp("启动服务", sup.Start) case <-mStop.ClickedCh: log.Printf("菜单:停止服务") runOp("停止服务", sup.Stop) case <-mRestart.ClickedCh: log.Printf("菜单:重启服务") runOp("重启服务", sup.Restart) case <-mURL.ClickedCh: st := sup.Status() if !st.Ready && !st.External { log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)") setNotice("Web 界面启动中,请稍候…", 4*time.Second) continue } log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL)) if err := openExternal(st.URL); err != nil { log.Printf("打开 Web 界面失败: %v", err) setNotice("打开失败(详见日志)", 6*time.Second) } case <-mAutoStart.ClickedCh: next := !mAutoStart.Checked() if err := setAutoStart(next); err != nil { log.Printf("设置开机自启失败: %v", err) setNotice("设置开机自启失败", 6*time.Second) continue } if next { mAutoStart.Check() } else { mAutoStart.Uncheck() } if err := sup.SetAutoStart(next); err != nil { log.Printf("保存配置失败: %v", err) } log.Printf("开机自启 = %v", next) if next { setNotice("开机自启:已开启", 4*time.Second) } else { setNotice("开机自启:已关闭", 4*time.Second) } case <-mAutoUpdate.ClickedCh: next := !mAutoUpdate.Checked() if next { mAutoUpdate.Check() } else { mAutoUpdate.Uncheck() } if err := sup.SetAutoUpdate(next); err != nil { log.Printf("保存配置失败: %v", err) } log.Printf("定期自动更新 = %v", next) if next { setNotice("定期自动更新:已开启", 4*time.Second) } else { setNotice("定期自动更新:已关闭", 4*time.Second) } case <-mUpdateNow.ClickedCh: log.Printf("菜单:立即更新") runOp("立即更新", func() { text, err := sup.UpdateAndRestart() if err != nil { log.Printf("更新失败: %v", err) setNotice("更新失败(详见日志)", 8*time.Second) } else { log.Printf("更新完成: %s", firstLine(text)) setNotice(firstLine(text), 8*time.Second) } mVersion.SetTitle("版本:" + sup.installedVersion()) }) case <-mLogs.ClickedCh: log.Printf("菜单:查看日志 %s", logDir()) if err := openExternal(logDir()); err != nil { log.Printf("打开日志失败: %v", err) setNotice("打开日志失败(详见日志)", 6*time.Second) } case <-mConfig.ClickedCh: log.Printf("菜单:打开配置文件 %s", configPath()) if err := openExternal(configPath()); err != nil { log.Printf("打开配置失败: %v", err) setNotice("打开配置失败(详见日志)", 6*time.Second) } case <-mQuit.ClickedCh: log.Printf("菜单:退出") systray.Quit() return } } }() sup.Start() if cfg.AutoUpdate { sup.startAutoUpdate() } } 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: return "外部实例运行中" default: return "已停止" } } func statusTooltip(st Status) string { parts := make([]string, 0, 4) if st.Running { parts = append(parts, "运行 "+humanDuration(st.Uptime)) } if st.Running && !st.Ready { parts = append(parts, "Web 界面启动中") } if st.Restarts > 0 { parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts)) } if st.LastError != "" { parts = append(parts, "最近错误:"+st.LastError) } if len(parts) == 0 { return "服务未运行" } return strings.Join(parts, ";") } // firstLine 取多行输出的首行,便于日志摘要。 func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }