| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- 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) {
- // 菜单标题一律使用短文本:长文本会撑宽整个菜单、视觉不协调。
- // 因此菜单内不显示访问地址——需要时点"打开 Web 界面"直接打开。
- 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, lastIconTip 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 菜单只切换可用状态,标题恒为短文本
- webEnable := st.Ready || st.External
- if webEnable != lastWebEnabled {
- if webEnable {
- mURL.Enable()
- } else {
- mURL.Disable()
- }
- lastWebEnabled = webEnable
- }
- // 托盘图标 tooltip 同样保持简洁(不含地址)
- iconTip := "DeepSeek Tray · " + statusTitle(st)
- if iconTip != lastIconTip {
- systray.SetTooltip(iconTip)
- lastIconTip = iconTip
- }
- 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, 10); i >= 0 { // 10 = LF
- return s[:i]
- }
- return s
- }
|