|
|
@@ -3,8 +3,8 @@ package main
|
|
|
import (
|
|
|
_ "embed"
|
|
|
"fmt"
|
|
|
- "os/exec"
|
|
|
"strings"
|
|
|
+ "sync"
|
|
|
"time"
|
|
|
|
|
|
"github.com/getlantern/systray"
|
|
|
@@ -27,15 +27,15 @@ func runTray(cfg *Config, log *Logger) {
|
|
|
|
|
|
func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
|
|
|
mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
|
|
|
- mURL := systray.AddMenuItem("打开 Web 界面", "")
|
|
|
+ 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", "npm install -g @deepseek-ai/dsh@latest")
|
|
|
+ mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh(已是最新则跳过)", cfg.AutoUpdate)
|
|
|
+ mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest(先停服务)")
|
|
|
systray.AddSeparator()
|
|
|
mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
|
|
|
mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
|
|
|
@@ -44,15 +44,30 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
|
|
|
mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘")
|
|
|
|
|
|
mStop.Disable()
|
|
|
+ mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址)
|
|
|
|
|
|
- // 状态刷新:仅在有变化时写 UI(减少 systray 调用)
|
|
|
+ // runOp 在后台执行耗时操作(停止/重启可能等待数秒),避免阻塞菜单响应;同一时刻只允许一个。
|
|
|
+ var opMu sync.Mutex
|
|
|
+ runOp := func(name string, fn func()) {
|
|
|
+ go func() {
|
|
|
+ if !opMu.TryLock() {
|
|
|
+ log.Printf("%s:已有操作进行中,忽略本次", name)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ defer opMu.Unlock()
|
|
|
+ fn()
|
|
|
+ }()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 状态刷新:仅在有变化时写 UI
|
|
|
go func() {
|
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
|
defer ticker.Stop()
|
|
|
var lastTitle, lastTip, lastURL string
|
|
|
- lastRunning := false
|
|
|
+ lastRunning, lastWebEnabled := false, false
|
|
|
for range ticker.C {
|
|
|
st := sup.Status()
|
|
|
+
|
|
|
if title := statusTitle(st); title != lastTitle {
|
|
|
mStatus.SetTitle(title)
|
|
|
lastTitle = title
|
|
|
@@ -61,10 +76,32 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
|
|
|
mStatus.SetTooltip(tip)
|
|
|
lastTip = tip
|
|
|
}
|
|
|
- if u := "打开 Web 界面:" + shortURL(st.URL); u != lastURL {
|
|
|
- mURL.SetTitle(u)
|
|
|
- lastURL = u
|
|
|
+
|
|
|
+ // Web 菜单:就绪前禁用,避免"打开后无法访问"
|
|
|
+ webText, webEnabled := "", true
|
|
|
+ switch {
|
|
|
+ case st.Ready:
|
|
|
+ webText = "打开 Web 界面:" + shortURL(st.URL)
|
|
|
+ case st.External:
|
|
|
+ webText = "打开 Web 界面(外部实例):" + shortURL(st.URL)
|
|
|
+ case st.Running:
|
|
|
+ webText, webEnabled = "Web 界面:服务启动中…", false
|
|
|
+ default:
|
|
|
+ webText, webEnabled = "打开 Web 界面(服务未运行)", false
|
|
|
+ }
|
|
|
+ if webText != lastURL {
|
|
|
+ mURL.SetTitle(webText)
|
|
|
+ lastURL = webText
|
|
|
+ }
|
|
|
+ if webEnabled != lastWebEnabled {
|
|
|
+ if webEnabled {
|
|
|
+ mURL.Enable()
|
|
|
+ } else {
|
|
|
+ mURL.Disable()
|
|
|
+ }
|
|
|
+ lastWebEnabled = webEnabled
|
|
|
}
|
|
|
+
|
|
|
if st.Running != lastRunning {
|
|
|
if st.Running {
|
|
|
mStart.Disable()
|
|
|
@@ -90,23 +127,29 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
|
|
|
}
|
|
|
}()
|
|
|
|
|
|
- // 菜单事件
|
|
|
+ // 菜单事件:只做记录与分发(耗时操作交给 runOp,保证菜单不卡)
|
|
|
go func() {
|
|
|
for {
|
|
|
select {
|
|
|
case <-mStart.ClickedCh:
|
|
|
log.Printf("菜单:启动服务")
|
|
|
- sup.Start()
|
|
|
+ runOp("启动服务", sup.Start)
|
|
|
case <-mStop.ClickedCh:
|
|
|
log.Printf("菜单:停止服务")
|
|
|
- sup.Stop()
|
|
|
+ runOp("停止服务", sup.Stop)
|
|
|
case <-mRestart.ClickedCh:
|
|
|
log.Printf("菜单:重启服务")
|
|
|
- sup.Restart()
|
|
|
+ runOp("重启服务", sup.Restart)
|
|
|
case <-mURL.ClickedCh:
|
|
|
- target := sup.Status().URL
|
|
|
- log.Printf("菜单:打开 Web 界面")
|
|
|
- openTarget(target)
|
|
|
+ st := sup.Status()
|
|
|
+ if !st.Ready && !st.External {
|
|
|
+ log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)")
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL))
|
|
|
+ if err := openExternal(st.URL); err != nil {
|
|
|
+ log.Printf("打开 Web 界面失败: %v", err)
|
|
|
+ }
|
|
|
case <-mAutoStart.ClickedCh:
|
|
|
next := !mAutoStart.Checked()
|
|
|
if err := setAutoStart(next); err != nil {
|
|
|
@@ -136,17 +179,24 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
|
|
|
case <-mUpdateNow.ClickedCh:
|
|
|
go func() {
|
|
|
log.Printf("菜单:立即更新(先停服务再更新)")
|
|
|
- if _, err := sup.UpdateAndRestart(); err != nil {
|
|
|
+ text, err := sup.UpdateAndRestart()
|
|
|
+ if err != nil {
|
|
|
log.Printf("更新失败: %v", err)
|
|
|
} else {
|
|
|
- log.Printf("更新完成,服务已恢复运行")
|
|
|
+ log.Printf("更新完成: %s", firstLine(text))
|
|
|
}
|
|
|
mVersion.SetTitle("版本:" + sup.installedVersion())
|
|
|
}()
|
|
|
case <-mLogs.ClickedCh:
|
|
|
- openTarget(logDir())
|
|
|
+ log.Printf("菜单:查看日志 %s", logDir())
|
|
|
+ if err := openExternal(logDir()); err != nil {
|
|
|
+ log.Printf("打开日志失败: %v", err)
|
|
|
+ }
|
|
|
case <-mConfig.ClickedCh:
|
|
|
- openTarget(configPath())
|
|
|
+ log.Printf("菜单:打开配置文件 %s", configPath())
|
|
|
+ if err := openExternal(configPath()); err != nil {
|
|
|
+ log.Printf("打开配置失败: %v", err)
|
|
|
+ }
|
|
|
case <-mQuit.ClickedCh:
|
|
|
log.Printf("菜单:退出")
|
|
|
systray.Quit()
|
|
|
@@ -165,6 +215,8 @@ 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:
|
|
|
@@ -179,6 +231,9 @@ func statusTooltip(st Status) string {
|
|
|
if st.Running {
|
|
|
parts = append(parts, "运行 "+humanDuration(st.Uptime))
|
|
|
}
|
|
|
+ if st.Running && !st.Ready {
|
|
|
+ parts = append(parts, "Web 界面启动中(首次约需 30 秒)")
|
|
|
+ }
|
|
|
if st.Restarts > 0 {
|
|
|
parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))
|
|
|
}
|
|
|
@@ -191,17 +246,10 @@ func statusTooltip(st Status) string {
|
|
|
return strings.Join(parts, ";")
|
|
|
}
|
|
|
|
|
|
-// openTarget 用系统默认程序打开 URL 或路径,不经 shell 解析(避免注入面)。
|
|
|
-func openTarget(target string) {
|
|
|
- if target == "" {
|
|
|
- return
|
|
|
- }
|
|
|
- var cmd *exec.Cmd
|
|
|
- if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
|
|
- cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", target)
|
|
|
- } else {
|
|
|
- cmd = exec.Command("explorer.exe", target)
|
|
|
+// firstLine 取多行输出的首行,便于日志摘要。
|
|
|
+func firstLine(s string) string {
|
|
|
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
|
+ return s[:i]
|
|
|
}
|
|
|
- hideWindow(cmd)
|
|
|
- _ = cmd.Start()
|
|
|
+ return s
|
|
|
}
|