Procházet zdrojové kódy

feat: 重写为 dsh web 托盘守护程序(v2.0.0)

- 隐藏终端启动 dsh web(node + bin.js, CREATE_NO_WINDOW)

- daemon 守护:异常退出自动重启(可配置延迟)

- 托盘菜单:状态/启停/重启/开机自启/更新/日志/配置

- 一键更新 + 可选定期后台更新

- 捕获带 token 的 Web 地址,直接打开已认证界面

- 单实例保护;配置支持 UTF-8 BOM

- 移除旧余额查询功能(旧代码归档于 legacy-v1 标签)

- 安全:.env 不再入库(已从工作区移除)
caesar před 1 dnem
rodič
revize
70a8f52af1
17 změnil soubory, kde provedl 1093 přidání a 704 odebrání
  1. 0 5
      .env
  2. 11 23
      .gitignore
  3. 73 60
      README.md
  4. 0 55
      app.go
  5. 46 0
      autostart_windows.go
  6. 0 294
      balance.go
  7. 65 53
      config.go
  8. 0 65
      feature.go
  9. 5 3
      go.mod
  10. 0 17
      harness.go
  11. 49 0
      logger.go
  12. 82 16
      main.go
  13. 98 0
      proc_windows.go
  14. 317 0
      supervisor.go
  15. 185 0
      tray.go
  16. 162 0
      updater.go
  17. 0 113
      windows.go

+ 0 - 5
.env

@@ -1,5 +0,0 @@
-# DeepSeek API Key(必填)
-DEEPSEEK_API_KEY=sk-93378505bdf7492fb34556dc225db3ce
-
-# DeepSeek 余额查询接口(可选)
-# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance

+ 11 - 23
.gitignore

@@ -1,26 +1,14 @@
-# ---> Go
-# Compiled Object files, Static and Dynamic libs (Shared Objects)
-*.o
-*.a
-*.so
-
-# Folders
-_obj
-_test
-
-# Architecture specific extensions/prefixes
-*.[568vq]
-[568vq].out
-
-*.cgo1.go
-*.cgo2.c
-_cgo_defun.c
-_cgo_gotypes.go
-_cgo_export.*
-
-_testmain.go
-
+# 编译产物
 *.exe
 *.test
-*.prof
+*.out
+
+# 环境与密钥(严禁入库)
+.env
+.env.*
+*.key
 
+# 构建/临时
+/dist/
+/build/
+*.log

+ 73 - 60
README.md

@@ -1,85 +1,98 @@
 # deepseek-tray
 
-DeepSeek 余额查询系统托盘工具,基于 Go + `getlantern/systray` 实现。
+DeepSeek Harness **Web 版桌面包装**(Windows 系统托盘 + 守护进程)。
 
-## 特性
-
-- **单文件分发**:图标使用 `//go:embed` 编译进 EXE,无外部依赖
-- **.env 配置**:API Key 存储在 EXE 同级目录的 `.env` 文件中,右键菜单可一键编辑
-- **托盘常驻**:系统托盘显示实时余额,鼠标悬停查看详情
-- **自动刷新**:可配置定时刷新间隔,支持开关切换
-- **低余额告警**:余额低于 ¥1 时弹出 Windows 提示框,亦可开关
-- **无感运行**:编译 `-ldflags -H=windowsgui` 隐藏命令行窗口,适合 NSSM 做成 Windows 服务
-- **资源占用极低**:内存 ~5 MB,CPU 几乎为零
+把 `dsh web` 变成一个常驻托盘应用:**全程隐藏终端**、异常自动重启、一键开机自启、一键更新。
 
-## 快速开始
+## 特性
 
-### 1. 放置 .env 文件
+- **隐藏终端**:以 `node <dsh>/lib/bin.js web --no-open` 直接启动(无 shell 包装),子进程使用 `CREATE_NO_WINDOW` + `HideWindow`,无任何命令行窗口
+- **守护重启**:进程异常退出后自动重启(可配置延迟),托盘显示运行状态、PID、重启次数
+- **启停管理**:托盘菜单一键启动 / 停止 / 重启,实时状态查询
+- **开机自启**:一键开关(写 HKCU Run 键,无需管理员权限)
+- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`,可开启定期后台更新(发现新版本才更新并重启)
+- **带 token 打开**:自动捕获 `dsh web` 输出的访问地址(含 token),点菜单直接进入已认证界面
+- **单实例保护**:命名互斥体,避免重复守护
+- **零外部依赖**:除托盘库外无运行时依赖,单文件分发(约 5 MB)
 
-在 `deepseek-tray.exe` 同目录创建 `.env` 文件:
+## 构建
 
-```
-DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
+```powershell
+go build -ldflags "-H=windowsgui -s -w" -o deepseek-tray.exe .
 ```
 
-也可在托盘右键菜单中选择「打开 .env 配置文件」自动创建模板并编辑。
+## 运行方式
 
-可选配置:
+| 命令 | 说明 |
+|---|---|
+| `deepseek-tray.exe` | 托盘模式(默认) |
+| `deepseek-tray.exe --silent` | 静默托盘(开机自启使用) |
+| `deepseek-tray.exe daemon` | 无 UI 守护模式(适合注册为 Windows 服务) |
+| `deepseek-tray.exe status` | 命令行状态查询 |
+| `deepseek-tray.exe version` | 版本号 |
 
-| 环境变量 | 默认值 | 说明 |
-|----------|--------|------|
-| `DEEPSEEK_API_KEY` | (必填) | DeepSeek API 密钥 |
-| `DEEPSEEK_BALANCE_URL` | `https://api.deepseek.com/user/balance` | 余额查询接口 |
+### 注册为 Windows 服务(可选)
 
-> 配置优先级:系统环境变量 > .env 文件 > 默认值。.env 不覆盖已存在的环境变量。
+使用 [NSSM](https://nssm.cc/):
 
-### 2. 运行
-
-```cmd
-.\deepseek-tray.exe
+```powershell
+nssm install DeepSeekTray "C:\path\to\deepseek-tray.exe" daemon
+nssm start DeepSeekTray
 ```
 
-图标将出现在系统托盘中,右键菜单包含:
-
-- **余额显示**:实时显示当前余额
-- **刷新余额**:手动触发一次查询
-- **自动刷新: 开启/关闭**:切换定时刷新
-- **低余额告警: 开启/关闭**:切换余额不足提醒(默认阈值 ¥1)
-- **打开 .env 配置文件**:用记事本打开编辑 Key
-- **关于**:查看当前运行状态
-- **退出**:关闭程序
-
-### 3. 使用 NSSM 设为 Windows 服务(开机自启)
-
-```cmd
-nssm install DeepSeekTray "C:\路径\deepseek-tray.exe"
-nssm set DeepSeekTray AppDirectory "C:\路径\"
-nssm start DeepSeekTray
+## 托盘菜单
+
+- 状态:运行中 (PID) / 已停止 / 正在更新
+- 打开 Web 界面(含 token,直接进入)
+- 启动服务 / 停止服务 / 重启服务
+- 开机自启(勾选)
+- 定期自动更新(勾选)
+- 立即更新 DSH
+- 版本:当前安装的 dsh 版本
+- 查看日志 / 打开配置文件
+- 退出(停止服务并退出)
+
+## 配置
+
+位置:`%APPDATA%\deepseek-tray\config.json`(首次运行自动生成)
+
+```json
+{
+  "webPort": 3080,
+  "webHost": "127.0.0.1",
+  "nodePath": "",
+  "dshBinJs": "",
+  "autoStart": false,
+  "autoUpdate": false,
+  "updateIntervalHours": 24,
+  "restartDelaySeconds": 5
+}
 ```
 
-EXE 同目录下的 `.env` 文件会被服务自动加载。
+| 字段 | 说明 |
+|---|---|
+| `webPort` / `webHost` | dsh web 监听端口与地址(默认 3080 / 127.0.0.1) |
+| `nodePath` / `dshBinJs` | 留空自动探测(`node` + `npm root -g` 下的 dsh 入口) |
+| `autoStart` | 开机自启(与托盘勾选同步) |
+| `autoUpdate` / `updateIntervalHours` | 定期后台更新开关与间隔 |
+| `restartDelaySeconds` | 异常退出后的重启延迟 |
 
-### 4. 开机自启(手动方式,可选)
+> 配置支持 UTF-8 BOM(记事本编辑后仍可解析)。
 
-`Win + R` → 输入 `shell:startup` → 将 `deepseek-tray.exe` 快捷方式拖入。
+## 日志
 
-## 开发编译
+`%APPDATA%\deepseek-tray\logs\tray-YYYYMMDD.log`(含 dsh web 的 stdout/stderr)
 
-```bash
-go build -ldflags="-H=windowsgui" -o deepseek-tray.exe
-```
+## 环境要求
 
-## 项目结构
+- Windows 10/11
+- 已全局安装 dsh:`npm install -g @deepseek-ai/dsh@latest --allow-scripts='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs'`
+- `node` 在 PATH 中
 
-```
-deepseek-tray/
-├── main.go              # 主逻辑:托盘、菜单、事件循环、.env 读取、API 调用
-├── msgbox_windows.go    # Windows MessageBox 封装 + 记事本打开
-├── icon.ico             # 托盘图标(编译时嵌入)
-├── go.mod / go.sum      # Go 模块依赖
-└── README.md
-```
+## 许可证
+
+见 [LICENSE](LICENSE)。
 
-## 许可
+## 版本
 
-CC0 1.0 Universal
+- v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能已归档于 `legacy-v1` 标签)

+ 0 - 55
app.go

@@ -1,55 +0,0 @@
-package main
-
-import (
-	"context"
-	"sync"
-
-	"github.com/getlantern/systray"
-)
-
-// App owns the tray icon lifecycle and the registered features.
-type App struct {
-	features []Feature
-}
-
-// newApp assembles the feature list. New features register here — nothing else
-// in the tray lifecycle needs to change.
-func newApp(cfg Config, dotenvPath string) *App {
-	return &App{
-		features: []Feature{
-			newBalanceFeature(cfg, dotenvPath),
-			// The harness daemon feature registers here in a later step.
-		},
-	}
-}
-
-// Run blocks until the tray is closed, then shuts every feature down.
-func (a *App) Run() {
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	var wg sync.WaitGroup
-
-	systray.Run(func() {
-		systray.SetIcon(appIcon)
-		systray.SetTitle("DeepSeek")
-
-		menu := &Menu{}
-		for _, f := range a.features {
-			f.Install(menu)
-		}
-		for _, f := range a.features {
-			wg.Add(1)
-			go func(f Feature) {
-				defer wg.Done()
-				f.Run(ctx)
-			}(f)
-		}
-	}, func() {
-		cancel()
-		for _, f := range a.features {
-			f.Stop()
-		}
-		wg.Wait()
-	})
-}

+ 46 - 0
autostart_windows.go

@@ -0,0 +1,46 @@
+//go:build windows
+
+package main
+
+import (
+	"os"
+
+	"golang.org/x/sys/windows/registry"
+)
+
+const (
+	runKeyPath   = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
+	runValueName = "DeepSeekTray"
+)
+
+// isAutoStartEnabled 查询开机自启是否已启用。
+func isAutoStartEnabled() bool {
+	k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
+	if err != nil {
+		return false
+	}
+	defer k.Close()
+	v, _, err := k.GetStringValue(runValueName)
+	return err == nil && v != ""
+}
+
+// setAutoStart 写入/删除 HKCU Run 键(无需管理员权限)。
+func setAutoStart(enabled bool) error {
+	k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
+	if err != nil {
+		return err
+	}
+	defer k.Close()
+	if !enabled {
+		err := k.DeleteValue(runValueName)
+		if err == registry.ErrNotExist {
+			return nil
+		}
+		return err
+	}
+	exe, err := os.Executable()
+	if err != nil {
+		return err
+	}
+	return k.SetStringValue(runValueName, "\""+exe+"\" --silent")
+}

+ 0 - 294
balance.go

@@ -1,294 +0,0 @@
-package main
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"time"
-)
-
-const (
-	balanceHTTPTimeout  = 15 * time.Second
-	balanceRefreshMin   = 10  // auto-refresh interval in minutes
-	lowBalanceThreshold = 1.0 // alert when CNY total drops below this (元)
-)
-
-// envTemplate is written to .env when the user opens a missing config file.
-const envTemplate = "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
-
-// BalanceAPI abstracts the balance endpoint so the tray feature is testable
-// without the network and without globals.
-type BalanceAPI interface {
-	Query(ctx context.Context, apiKey string) (Balance, error)
-}
-
-// Balance is a successful balance response.
-type Balance struct {
-	Available bool
-	Items     []BalanceItem
-}
-
-// BalanceItem is one currency row from the DeepSeek balance endpoint.
-type BalanceItem struct {
-	Currency        string `json:"currency"`
-	TotalBalance    string `json:"total_balance"`
-	GrantedBalance  string `json:"granted_balance"`
-	ToppedUpBalance string `json:"topped_up_balance"`
-}
-
-type balanceResponse struct {
-	IsAvailable  bool          `json:"is_available"`
-	BalanceInfos []BalanceItem `json:"balance_infos"`
-}
-
-// httpBalanceAPI is the production BalanceAPI implementation.
-type httpBalanceAPI struct {
-	URL    string
-	Client *http.Client
-}
-
-func (a httpBalanceAPI) Query(ctx context.Context, apiKey string) (Balance, error) {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
-	if err != nil {
-		return Balance{}, fmt.Errorf("创建请求失败: %w", err)
-	}
-	req.Header.Set("Authorization", "Bearer "+apiKey)
-	req.Header.Set("Accept", "application/json")
-
-	resp, err := a.Client.Do(req)
-	if err != nil {
-		return Balance{}, fmt.Errorf("网络错误: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		if resp.StatusCode == http.StatusUnauthorized {
-			return Balance{}, fmt.Errorf("API Key 无效")
-		}
-		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
-		return Balance{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
-	}
-
-	var api balanceResponse
-	if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
-		return Balance{}, fmt.Errorf("解析响应失败: %w", err)
-	}
-	if !api.IsAvailable || len(api.BalanceInfos) == 0 {
-		return Balance{}, fmt.Errorf("余额不可用")
-	}
-	return Balance{Available: true, Items: api.BalanceInfos}, nil
-}
-
-// Display returns the primary display amount and its currency.
-func (b Balance) Display() (text, currency string) {
-	if len(b.Items) == 0 {
-		return "", ""
-	}
-	return b.Items[0].TotalBalance, b.Items[0].Currency
-}
-
-// CNYTotal sums every CNY row, used by the low-balance alert.
-func (b Balance) CNYTotal() float64 {
-	var sum float64
-	for _, it := range b.Items {
-		if it.Currency == "CNY" {
-			var v float64
-			fmt.Sscanf(it.TotalBalance, "%f", &v)
-			sum += v
-		}
-	}
-	return sum
-}
-
-// balanceFeature implements Feature for the DeepSeek balance tray UI.
-type balanceFeature struct {
-	cfg        Config
-	api        BalanceAPI
-	dotenvPath string
-
-	autoRefresh bool // owned by the Run goroutine only
-	alertOn     bool // owned by the Run goroutine only
-
-	menu *balanceMenu
-}
-
-type balanceMenu struct {
-	balance *Item
-	refresh *Item
-	auto    *Item
-	alert   *Item
-	dotenv  *Item
-	about   *Item
-	quit    *Item
-}
-
-func newBalanceFeature(cfg Config, dotenvPath string) *balanceFeature {
-	return &balanceFeature{
-		cfg:         cfg,
-		api:         httpBalanceAPI{URL: cfg.BalanceURL, Client: &http.Client{Timeout: balanceHTTPTimeout}},
-		dotenvPath:  dotenvPath,
-		autoRefresh: true,
-	}
-}
-
-func (b *balanceFeature) Name() string { return "balance" }
-
-func (b *balanceFeature) Install(m *Menu) {
-	b.menu = &balanceMenu{
-		balance: m.AddItem("余额: 查询中...", ""),
-		refresh: m.AddItem("刷新余额", "手动刷新"),
-		auto:    m.AddItem("自动刷新: 开启", "切换自动刷新"),
-		alert:   m.AddItem("低余额告警: 关闭", "切换低余额告警"),
-	}
-	b.menu.balance.Disable()
-	m.AddSeparator()
-	b.menu.dotenv = m.AddItem("打开 .env 配置文件", "编辑 API Key")
-	b.menu.about = m.AddItem("关于", "状态信息")
-	b.menu.quit = m.AddItem("退出", "关闭程序")
-
-	if b.cfg.APIKey != "" {
-		setTrayTooltip("DeepSeek 余额查询 - 正在获取...")
-	} else {
-		setTrayTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
-	}
-}
-
-func (b *balanceFeature) Stop() {}
-
-func (b *balanceFeature) Run(ctx context.Context) {
-	results := make(chan queryResult, 1)
-
-	go func() {
-		if b.cfg.APIKey == "" {
-			b.menu.balance.SetTitle("余额: 未配置 Key")
-			b.menu.balance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
-			return
-		}
-		b.queryInto(b.menu.balance, results)
-	}()
-
-	ticker := time.NewTicker(balanceRefreshMin * time.Minute)
-	defer ticker.Stop()
-
-	for {
-		select {
-		case r := <-results:
-			if b.alertOn && r.cny > 0 && r.cny < lowBalanceThreshold {
-				alertLowBalance(r.cny)
-			}
-
-		case <-ticker.C:
-			if b.cfg.APIKey == "" || !b.autoRefresh {
-				continue
-			}
-			b.queryInto(b.menu.balance, results)
-
-		case <-b.menu.refresh.Clicked:
-			if b.cfg.APIKey == "" {
-				showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
-				continue
-			}
-			b.queryInto(b.menu.balance, results)
-
-		case <-b.menu.auto.Clicked:
-			b.autoRefresh = !b.autoRefresh
-			if b.autoRefresh {
-				b.menu.auto.SetTitle("自动刷新: 开启")
-			} else {
-				b.menu.auto.SetTitle("自动刷新: 关闭")
-			}
-
-		case <-b.menu.alert.Clicked:
-			b.alertOn = !b.alertOn
-			if b.alertOn {
-				b.menu.alert.SetTitle("低余额告警: 开启")
-				if b.cfg.APIKey != "" {
-					b.queryInto(b.menu.balance, results)
-				}
-			} else {
-				b.menu.alert.SetTitle("低余额告警: 关闭")
-			}
-
-		case <-b.menu.dotenv.Clicked:
-			openDotEnvFile(b.dotenvPath)
-
-		case <-b.menu.about.Clicked:
-			showAbout(b.autoRefresh, b.alertOn)
-
-		case <-b.menu.quit.Clicked:
-			quitTray()
-			return
-
-		case <-ctx.Done():
-			return
-		}
-	}
-}
-
-// queryResult is a snapshot used for the low-balance alert decision.
-type queryResult struct {
-	ok  bool
-	cny float64
-}
-
-// queryInto queries the balance endpoint, updates the tray UI, and reports the
-// result for the alert path (non-blocking).
-func (b *balanceFeature) queryInto(it *Item, results chan<- queryResult) {
-	bal, err := b.api.Query(context.Background(), b.cfg.APIKey)
-	now := time.Now().Format("15:04:05")
-
-	var r queryResult
-	if err == nil {
-		text, currency := bal.Display()
-		display := text + " " + currency
-		tip := fmt.Sprintf("余额: %s | %s", display, now)
-		setTrayTooltip(tip)
-		it.SetTitle("余额: " + display)
-		it.SetTooltip(tip)
-		setTrayTitle(display)
-		r = queryResult{ok: true, cny: bal.CNYTotal()}
-	} else {
-		tip := fmt.Sprintf("%s | %s", err.Error(), now)
-		setTrayTooltip(tip)
-		it.SetTitle(err.Error())
-		it.SetTooltip(tip)
-		setTrayTitle("DeepSeek")
-	}
-
-	select {
-	case results <- r:
-	default:
-	}
-}
-
-func alertLowBalance(cny float64) {
-	showMessageBox("DeepSeek 余额不足",
-		fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceThreshold))
-}
-
-func showAbout(autoRefresh, alertOn bool) {
-	showMessageBox("DeepSeek Balance Tray",
-		fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
-			yesNo(autoRefresh), balanceRefreshMin, yesNo(alertOn), lowBalanceThreshold))
-}
-
-func yesNo(b bool) string {
-	if b {
-		return "是"
-	}
-	return "否"
-}
-
-// openDotEnvFile opens the .env file, creating a template first when absent.
-func openDotEnvFile(path string) {
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		if err := os.WriteFile(path, []byte(envTemplate), 0644); err != nil {
-			showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
-			return
-		}
-	}
-	runNotepad(path)
-}

+ 65 - 53
config.go

@@ -1,75 +1,87 @@
 package main
 
 import (
+	"bytes"
+	"encoding/json"
 	"os"
 	"path/filepath"
-	"strings"
 )
 
-const defaultBalanceURL = "https://api.deepseek.com/user/balance"
+const appDirName = "deepseek-tray"
 
-// Config is the fully-resolved runtime configuration. Values are loaded with
-// precedence: process environment > .env file > built-in default.
+// Config 托盘程序配置,存于 %APPDATA%\deepseek-tray\config.json。
 type Config struct {
-	APIKey     string
-	BalanceURL string
-
-	// Harness daemon settings; consumed by the harness feature in a later step.
-	Harness harnessConfig
+	WebPort         int    `json:"webPort"`             // dsh web 监听端口(默认 3080)
+	WebHost         string `json:"webHost"`             // 绑定地址(默认 127.0.0.1)
+	NodePath        string `json:"nodePath"`            // node 可执行路径(留空自动探测)
+	DshBinJS        string `json:"dshBinJs"`            // dsh 入口 JS(留空自动探测)
+	AutoStart       bool   `json:"autoStart"`           // 开机自启(注册表 Run 键)
+	AutoUpdate      bool   `json:"autoUpdate"`          // 定期后台更新
+	UpdateIntervalH int    `json:"updateIntervalHours"` // 定期更新间隔(小时)
+	RestartDelaySec int    `json:"restartDelaySeconds"` // 异常退出后重启延迟(秒)
 }
 
-// LoadConfig resolves configuration from the .env file in dir (best-effort),
-// overlaid with process environment variables, then defaults.
-func LoadConfig(dir string) Config {
-	dotenv := readDotEnv(filepath.Join(dir, ".env"))
-
-	cfg := Config{
-		BalanceURL: defaultBalanceURL,
-		Harness: harnessConfig{
-			Enabled:    true,
-			Command:    "npx --yes @deepseek-ai/dsh web",
-			URL:        "http://127.0.0.1:3080",
-			StartDelay: 15,
-			LogFile:    "harness.log",
-		},
+func defaultConfig() *Config {
+	return &Config{
+		WebPort:         3080,
+		WebHost:         "127.0.0.1",
+		AutoStart:       false,
+		AutoUpdate:      false,
+		UpdateIntervalH: 24,
+		RestartDelaySec: 5,
 	}
-
-	cfg.APIKey = firstNonEmpty(os.Getenv("DEEPSEEK_API_KEY"), dotenv["DEEPSEEK_API_KEY"])
-	if v := firstNonEmpty(os.Getenv("DEEPSEEK_BALANCE_URL"), dotenv["DEEPSEEK_BALANCE_URL"]); v != "" {
-		cfg.BalanceURL = v
-	}
-	return cfg
 }
 
-func firstNonEmpty(values ...string) string {
-	for _, v := range values {
-		if v != "" {
-			return v
+// appDataDir 返回配置与日志目录(不存在时创建)。
+func appDataDir() string {
+	base := os.Getenv("APPDATA")
+	if base == "" {
+		if home, err := os.UserHomeDir(); err == nil {
+			base = home
+		} else {
+			base = "."
 		}
 	}
-	return ""
+	dir := filepath.Join(base, appDirName)
+	_ = os.MkdirAll(dir, 0o755)
+	return dir
 }
 
-// readDotEnv parses a simple KEY=VALUE file. Missing files yield an empty map.
-// Lines are trimmed; '#' starts a comment; quotes are stripped from values.
-func readDotEnv(path string) map[string]string {
-	data, err := os.ReadFile(path)
+func configPath() string {
+	return filepath.Join(appDataDir(), "config.json")
+}
+
+func loadConfig() *Config {
+	cfg := defaultConfig()
+	data, err := os.ReadFile(configPath())
 	if err != nil {
-		return nil
+		_ = saveConfig(cfg)
+		return cfg
 	}
-	m := make(map[string]string)
-	for _, raw := range strings.Split(string(data), "\n") {
-		line := strings.TrimSpace(raw)
-		if line == "" || line[0] == '#' {
-			continue
-		}
-		eq := strings.IndexByte(line, '=')
-		if eq < 1 {
-			continue
-		}
-		k := strings.TrimSpace(line[:eq])
-		v := strings.TrimSpace(line[eq+1:])
-		m[k] = strings.Trim(v, `"'`)
+	// 容忍 UTF-8 BOM(Windows 记事本等编辑器写入的配置会带 BOM)
+	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
+	if err := json.Unmarshal(data, cfg); err != nil {
+		return defaultConfig()
+	}
+	if cfg.WebPort <= 0 {
+		cfg.WebPort = 3080
+	}
+	if cfg.WebHost == "" {
+		cfg.WebHost = "127.0.0.1"
+	}
+	if cfg.UpdateIntervalH <= 0 {
+		cfg.UpdateIntervalH = 24
+	}
+	if cfg.RestartDelaySec <= 0 {
+		cfg.RestartDelaySec = 5
+	}
+	return cfg
+}
+
+func saveConfig(cfg *Config) error {
+	data, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return err
 	}
-	return m
+	return os.WriteFile(configPath(), data, 0o644)
 }

+ 0 - 65
feature.go

@@ -1,65 +0,0 @@
-package main
-
-import (
-	"context"
-
-	"github.com/getlantern/systray"
-)
-
-// Feature is a self-contained tray capability. It owns its menu items,
-// background loops, and mutable state. Adding a feature means implementing
-// this interface and registering it in app.go's feature list — the app's
-// event loop itself never changes.
-type Feature interface {
-	// Name identifies the feature (ordering and logging).
-	Name() string
-
-	// Install creates this feature's tray menu items. It is called exactly
-	// once, from the systray onReady goroutine, before Run.
-	Install(m *Menu)
-
-	// Run starts the feature's background work. It returns when the feature
-	// shuts down: either Stop is called, or ctx is cancelled at app exit.
-	Run(ctx context.Context)
-
-	// Stop requests shutdown and releases resources (goroutines, child
-	// processes). It must be safe to call multiple times and before Run.
-	Stop()
-}
-
-// Menu is a thin wrapper over the systray package so features never import
-// systray directly. It centralizes menu construction behind one seam.
-type Menu struct{}
-
-// Item is a handle to one tray menu entry.
-type Item struct {
-	mi      *systray.MenuItem
-	Clicked chan struct{}
-}
-
-// AddItem creates a clickable menu entry.
-func (Menu) AddItem(title, tooltip string) *Item {
-	mi := systray.AddMenuItem(title, tooltip)
-	return &Item{mi: mi, Clicked: mi.ClickedCh}
-}
-
-// AddSeparator inserts a menu divider.
-func (Menu) AddSeparator() { systray.AddSeparator() }
-
-// SetTitle updates the item's visible label.
-func (it *Item) SetTitle(t string) { it.mi.SetTitle(t) }
-
-// SetTooltip updates the item's hover text.
-func (it *Item) SetTooltip(t string) { it.mi.SetTooltip(t) }
-
-// Disable greys the item out.
-func (it *Item) Disable() { it.mi.Disable() }
-
-// Enable makes a disabled item clickable again.
-func (it *Item) Enable() { it.mi.Enable() }
-
-// setTrayTitle / setTrayTooltip / quitTray are package-level systray shims so
-// features can update the tray icon state without importing systray directly.
-func setTrayTitle(t string)   { systray.SetTitle(t) }
-func setTrayTooltip(t string) { systray.SetTooltip(t) }
-func quitTray()               { systray.Quit() }

+ 5 - 3
go.mod

@@ -1,8 +1,11 @@
 module deepseek-tray
 
-go 1.26.3
+go 1.26
 
-require github.com/getlantern/systray v1.2.2
+require (
+	github.com/getlantern/systray v1.2.2
+	golang.org/x/sys v0.1.0
+)
 
 require (
 	github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
@@ -13,5 +16,4 @@ require (
 	github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
 	github.com/go-stack/stack v1.8.0 // indirect
 	github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
-	golang.org/x/sys v0.1.0 // indirect
 )

+ 0 - 17
harness.go

@@ -1,17 +0,0 @@
-package main
-
-// The harness daemon feature will supervise the DeepSeek Harness web server:
-// start `npx @deepseek-ai/dsh web` hidden at login, health-check it,
-// reopen the browser UI on demand, and restart it on crash. It plugs into the
-// same Feature interface as balance (see balance.go) and registers in app.go.
-
-// harnessConfig carries the harness feature's configuration slice. It is
-// resolved by LoadConfig (see config.go) and consumed by the harness feature
-// in a later step.
-type harnessConfig struct {
-	Enabled    bool
-	Command    string
-	URL        string
-	StartDelay int
-	LogFile    string
-}

+ 49 - 0
logger.go

@@ -0,0 +1,49 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+)
+
+// Logger 简单文件日志(windowsgui 子系统无控制台,统一落盘)。
+type Logger struct {
+	mu   sync.Mutex
+	file *os.File
+	path string
+}
+
+func NewLogger() *Logger {
+	dir := filepath.Join(appDataDir(), "logs")
+	_ = os.MkdirAll(dir, 0o755)
+	p := filepath.Join(dir, "tray-"+time.Now().Format("20060102")+".log")
+	f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+	if err != nil {
+		f = nil
+	}
+	return &Logger{file: f, path: p}
+}
+
+func (l *Logger) Printf(format string, args ...any) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), fmt.Sprintf(format, args...))
+	if l.file != nil {
+		_, _ = l.file.WriteString(line)
+	}
+}
+
+func (l *Logger) Path() string {
+	return l.path
+}
+
+func (l *Logger) Close() {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.file != nil {
+		_ = l.file.Close()
+		l.file = nil
+	}
+}

+ 82 - 16
main.go

@@ -1,29 +1,95 @@
 package main
 
 import (
+	"fmt"
+	"net"
 	"os"
-	"path/filepath"
-
-	_ "embed"
+	"strings"
+	"time"
 )
 
-//go:embed icon.ico
-var appIcon []byte
+const appVersion = "2.0.0"
+
+const mutexName = "Global\\DeepSeekTraySingleton"
 
 func main() {
-	relaunchInUserSession()
+	args := os.Args[1:]
+	cfg := loadConfig()
+	log := NewLogger()
+	defer log.Close()
+	log.Printf("=== deepseek-tray %s 启动 args=%v ===", appVersion, args)
+
+	if len(args) > 0 {
+		switch strings.ToLower(strings.TrimLeft(args[0], "-/")) {
+		case "version":
+			attachConsole()
+			fmt.Println(appVersion)
+			return
+		case "status":
+			attachConsole()
+			printStatus(cfg, log)
+			return
+		case "daemon":
+			runDaemon(cfg, log)
+			return
+		}
+	}
 
-	dir := executableDir()
-	app := newApp(LoadConfig(dir), filepath.Join(dir, ".env"))
-	app.Run()
+	inst, ok := acquireSingleInstance(mutexName)
+	if !ok {
+		attachConsole()
+		fmt.Println("deepseek-tray 已在运行(托盘或守护进程)")
+		return
+	}
+	defer inst.release()
+
+	runTray(cfg, log)
 }
 
-// executableDir returns the directory containing the running executable,
-// falling back to the working directory when os.Executable fails.
-func executableDir() string {
-	if exe, err := os.Executable(); err == nil {
-		return filepath.Dir(exe)
+// runDaemon 无 UI 守护模式(适合 NSSM 注册为 Windows 服务)。
+func runDaemon(cfg *Config, log *Logger) {
+	log.Printf("以守护模式启动(无托盘)")
+	inst, ok := acquireSingleInstance(mutexName)
+	if !ok {
+		log.Printf("已有实例在运行,退出")
+		return
+	}
+	defer inst.release()
+
+	sup := NewSupervisor(cfg, log)
+	sup.Start()
+	if cfg.AutoUpdate {
+		sup.startAutoUpdate()
+	}
+	select {}
+}
+
+// printStatus 命令行状态查询。
+func printStatus(cfg *Config, log *Logger) {
+	sup := NewSupervisor(cfg, log)
+	version := sup.installedVersion()
+	if version == "" {
+		version = "未知(未找到 dsh)"
+	}
+	url := fmt.Sprintf("http://%s:%d", cfg.WebHost, cfg.WebPort)
+	fmt.Printf("deepseek-tray %s\n", appVersion)
+	fmt.Printf("DSH 版本    : %s\n", version)
+	fmt.Printf("Web 地址    : %s\n", url)
+	fmt.Printf("端口监听    : %v\n", portOpen(cfg.WebHost, cfg.WebPort))
+	fmt.Printf("开机自启    : %v\n", isAutoStartEnabled())
+	fmt.Printf("定期自动更新: %v(间隔 %d 小时)\n", cfg.AutoUpdate, cfg.UpdateIntervalH)
+	fmt.Printf("配置文件    : %s\n", configPath())
+	fmt.Printf("日志文件    : %s\n", log.Path())
+}
+
+func portOpen(host string, port int) bool {
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 1500*time.Millisecond)
+	if err != nil {
+		return false
 	}
-	wd, _ := os.Getwd()
-	return wd
+	_ = conn.Close()
+	return true
 }

+ 98 - 0
proc_windows.go

@@ -0,0 +1,98 @@
+//go:build windows
+
+package main
+
+import (
+	"os"
+	"os/exec"
+	"strconv"
+	"syscall"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+)
+
+const createNoWindow = 0x08000000
+
+// hideWindow 让子进程完全不显示终端窗口(CREATE_NO_WINDOW + HideWindow)。
+func hideWindow(cmd *exec.Cmd) {
+	if cmd.SysProcAttr == nil {
+		cmd.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	cmd.SysProcAttr.HideWindow = true
+	cmd.SysProcAttr.CreationFlags |= createNoWindow
+}
+
+// killTree 强制结束进程树(含 node 派生的子进程)。
+func killTree(pid int) error {
+	if pid <= 0 {
+		return nil
+	}
+	cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
+	hideWindow(cmd)
+	return cmd.Run()
+}
+
+// processAlive 判断进程是否仍在运行。
+func processAlive(pid int) bool {
+	if pid <= 0 {
+		return false
+	}
+	h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
+	if err != nil {
+		return false
+	}
+	defer windows.CloseHandle(h)
+	var code uint32
+	if err := windows.GetExitCodeProcess(h, &code); err != nil {
+		return false
+	}
+	return code == 259 // STILL_ACTIVE
+}
+
+var (
+	kernel32        = windows.NewLazySystemDLL("kernel32.dll")
+	procCreateMutex = kernel32.NewProc("CreateMutexW")
+)
+
+// singleInstance 基于命名互斥体的单实例保护。
+type singleInstance struct {
+	handle windows.Handle
+}
+
+func acquireSingleInstance(name string) (*singleInstance, bool) {
+	n, err := windows.UTF16PtrFromString(name)
+	if err != nil {
+		return nil, true
+	}
+	h, _, callErr := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(n)))
+	if h == 0 {
+		return nil, true // 创建失败时放行,避免误伤
+	}
+	if errno, ok := callErr.(syscall.Errno); ok && errno == syscall.ERROR_ALREADY_EXISTS {
+		_ = windows.CloseHandle(windows.Handle(h))
+		return nil, false
+	}
+	return &singleInstance{handle: windows.Handle(h)}, true
+}
+
+func (s *singleInstance) release() {
+	if s != nil && s.handle != 0 {
+		_ = windows.CloseHandle(s.handle)
+		s.handle = 0
+	}
+}
+
+// attachConsole 让 windowsgui 子系统程序能输出到调用它的终端(CLI 子命令用)。
+func attachConsole() {
+	const attachParentProcess = 0xFFFFFFFF
+	procAttach := kernel32.NewProc("AttachConsole")
+	ret, _, _ := procAttach.Call(uintptr(attachParentProcess))
+	if ret == 0 {
+		return
+	}
+	if f, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil {
+		os.Stdout = f
+		os.Stderr = f
+	}
+}

+ 317 - 0
supervisor.go

@@ -0,0 +1,317 @@
+package main
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+// Status 服务状态快照。
+type Status struct {
+	Running   bool
+	PID       int
+	Restarts  int
+	StartedAt time.Time
+	LastError string
+	URL       string
+	Updating  bool
+}
+
+// Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启。
+type Supervisor struct {
+	mu        sync.Mutex
+	cfg       *Config
+	log       *Logger
+	cmd       *exec.Cmd
+	pid       int
+	running   bool
+	desired   bool
+	restarts  int
+	startedAt time.Time
+	lastError string
+	updating  bool
+	binCache  string
+	nodeCache string
+	webURL    string // dsh web 输出的完整 URL(含访问 token)
+}
+
+func NewSupervisor(cfg *Config, log *Logger) *Supervisor {
+	return &Supervisor{cfg: cfg, log: log}
+}
+
+// Start 设置期望运行并拉起守护循环。
+func (s *Supervisor) Start() {
+	s.mu.Lock()
+	if s.desired {
+		s.mu.Unlock()
+		return
+	}
+	s.desired = true
+	s.mu.Unlock()
+	go s.monitor()
+}
+
+// Stop 停止守护并结束子进程树。
+func (s *Supervisor) Stop() {
+	s.mu.Lock()
+	s.desired = false
+	pid := s.pid
+	s.webURL = ""
+	s.mu.Unlock()
+	if pid > 0 {
+		s.log.Printf("停止 dsh web pid=%d", pid)
+		_ = killTree(pid)
+	}
+}
+
+// Restart 重启服务。
+func (s *Supervisor) Restart() {
+	s.log.Printf("重启 dsh web")
+	s.Stop()
+	time.Sleep(500 * time.Millisecond)
+	s.Start()
+}
+
+func (s *Supervisor) Status() Status {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	host := s.cfg.WebHost
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	url := fmt.Sprintf("http://%s:%d", host, s.cfg.WebPort)
+	if s.webURL != "" {
+		url = s.webURL // 优先使用 dsh web 输出的带 token URL
+	}
+	return Status{
+		Running:   s.running,
+		PID:       s.pid,
+		Restarts:  s.restarts,
+		StartedAt: s.startedAt,
+		LastError: s.lastError,
+		URL:       url,
+		Updating:  s.updating,
+	}
+}
+
+func (s *Supervisor) setError(err error) {
+	s.mu.Lock()
+	s.lastError = err.Error()
+	s.mu.Unlock()
+	s.log.Printf("守护错误: %v", err)
+}
+
+func (s *Supervisor) delay() time.Duration {
+	sec := s.cfg.RestartDelaySec
+	if sec <= 0 {
+		sec = 5
+	}
+	return time.Duration(sec) * time.Second
+}
+
+// monitor 守护循环:启动 -> 等待退出 -> 期望运行时延时重启。
+func (s *Supervisor) monitor() {
+	for {
+		s.mu.Lock()
+		desired := s.desired
+		s.mu.Unlock()
+		if !desired {
+			return
+		}
+
+		exe, args, err := s.resolveCommand()
+		if err != nil {
+			s.setError(err)
+			s.sleepInterruptible(s.delay())
+			continue
+		}
+
+		cmd := exec.Command(exe, args...)
+		hideWindow(cmd)
+		stdout, errOut := cmd.StdoutPipe()
+		stderr, errErr := cmd.StderrPipe()
+		if errOut != nil || errErr != nil {
+			s.setError(errors.New("无法创建输出管道"))
+			s.sleepInterruptible(s.delay())
+			continue
+		}
+		if err := cmd.Start(); err != nil {
+			s.setError(err)
+			s.sleepInterruptible(s.delay())
+			continue
+		}
+
+		pid := cmd.Process.Pid
+		s.mu.Lock()
+		s.cmd = cmd
+		s.pid = pid
+		s.running = true
+		s.startedAt = time.Now()
+		s.lastError = ""
+		s.webURL = ""
+		s.mu.Unlock()
+		s.log.Printf("dsh web 已启动 pid=%d: %s %s", pid, exe, strings.Join(args, " "))
+
+		go s.pipeLog("out", stdout)
+		go s.pipeLog("err", stderr)
+
+		waitErr := cmd.Wait()
+
+		s.mu.Lock()
+		s.running = false
+		s.cmd = nil
+		s.pid = 0
+		stillDesired := s.desired
+		if stillDesired {
+			s.restarts++
+		}
+		s.mu.Unlock()
+		s.log.Printf("dsh web 退出(err=%v),期望运行=%v", waitErr, stillDesired)
+
+		if !stillDesired {
+			return
+		}
+		sleep := s.delay()
+		s.log.Printf("%v 后自动重启", sleep)
+		s.sleepInterruptible(sleep)
+	}
+}
+
+func (s *Supervisor) sleepInterruptible(d time.Duration) {
+	deadline := time.Now().Add(d)
+	for time.Now().Before(deadline) {
+		s.mu.Lock()
+		desired := s.desired
+		s.mu.Unlock()
+		if !desired {
+			return
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+}
+
+// pipeLog 把子进程输出写入日志文件。
+func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
+	if r == nil {
+		return
+	}
+	sc := bufio.NewScanner(r)
+	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	for sc.Scan() {
+		line := sc.Text()
+		s.log.Printf("[dsh %s] %s", tag, line)
+		// 捕获 dsh web 启动时输出的访问地址(含 token),供托盘直接打开
+		if idx := strings.Index(line, "http://"); idx >= 0 {
+			url := strings.TrimSpace(line[idx:])
+			if i := strings.IndexAny(url, " \t"); i > 0 {
+				url = url[:i]
+			}
+			if strings.HasPrefix(url, "http://") && len(url) > len("http://") {
+				s.mu.Lock()
+				if url != s.webURL {
+					s.webURL = url
+					s.log.Printf("捕获 Web 访问地址(含 token)")
+				}
+				s.mu.Unlock()
+			}
+		}
+	}
+}
+
+// resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,最小化窗口风险)。
+func (s *Supervisor) resolveCommand() (string, []string, error) {
+	node := s.cfg.NodePath
+	if node == "" {
+		node = s.nodePath()
+	}
+	binJS := s.cfg.DshBinJS
+	if binJS == "" {
+		binJS = s.dshBinJS()
+	}
+	if node != "" && binJS != "" {
+		args := []string{binJS, "web", "--no-open", "--host", s.cfg.WebHost, "--port", strconv.Itoa(s.cfg.WebPort)}
+		return node, args, nil
+	}
+	if p, err := exec.LookPath("dsh.cmd"); err == nil {
+		args := []string{"/c", p, "web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
+		return "cmd.exe", args, nil
+	}
+	if p, err := exec.LookPath("dsh"); err == nil {
+		args := []string{"web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
+		return p, args, nil
+	}
+	return "", nil, errors.New("未找到 dsh 入口:请确认已全局安装 @deepseek-ai/dsh,或在配置中指定 nodePath / dshBinJs")
+}
+
+func (s *Supervisor) nodePath() string {
+	s.mu.Lock()
+	if s.nodeCache != "" {
+		v := s.nodeCache
+		s.mu.Unlock()
+		return v
+	}
+	s.mu.Unlock()
+	p, err := exec.LookPath("node")
+	if err != nil {
+		return ""
+	}
+	s.mu.Lock()
+	s.nodeCache = p
+	s.mu.Unlock()
+	return p
+}
+
+// dshBinJS 探测 dsh 的 bin.js:APPDATA\npm 与 npm root -g。
+func (s *Supervisor) dshBinJS() string {
+	s.mu.Lock()
+	if s.binCache != "" {
+		v := s.binCache
+		s.mu.Unlock()
+		return v
+	}
+	s.mu.Unlock()
+
+	var candidates []string
+	if appdata := os.Getenv("APPDATA"); appdata != "" {
+		candidates = append(candidates, filepath.Join(appdata, "npm", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"))
+	}
+	if root := npmGlobalRoot(); root != "" {
+		candidates = append(candidates, filepath.Join(root, "@deepseek-ai", "dsh", "lib", "bin.js"))
+	}
+	for _, c := range candidates {
+		if fileExists(c) {
+			s.mu.Lock()
+			s.binCache = c
+			s.mu.Unlock()
+			s.log.Printf("dsh 入口: %s", c)
+			return c
+		}
+	}
+	return ""
+}
+
+// installedVersion 读取已安装 dsh 版本。
+func (s *Supervisor) installedVersion() string {
+	binJS := s.cfg.DshBinJS
+	if binJS == "" {
+		binJS = s.dshBinJS()
+	}
+	if binJS == "" {
+		return ""
+	}
+	pkg := filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json")
+	return readPackageVersion(pkg)
+}
+
+func fileExists(p string) bool {
+	st, err := os.Stat(p)
+	return err == nil && !st.IsDir()
+}

+ 185 - 0
tray.go

@@ -0,0 +1,185 @@
+package main
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"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) {
+	mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
+	mURL := systray.AddMenuItem("打开 Web 界面", cfgURL(cfg))
+	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")
+	systray.AddSeparator()
+	mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
+	mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
+	mConfig := systray.AddMenuItem("打开配置文件", "打开 config.json")
+	systray.AddSeparator()
+	mQuit := systray.AddMenuItem("退出", "停止服务并退出托盘")
+
+	mStop.Disable()
+
+	// 状态刷新(3 秒)
+	go func() {
+		ticker := time.NewTicker(3 * time.Second)
+		defer ticker.Stop()
+		for range ticker.C {
+			st := sup.Status()
+			switch {
+			case st.Updating:
+				mStatus.SetTitle("状态:正在更新…")
+			case st.Running:
+				mStatus.SetTitle(fmt.Sprintf("状态:运行中 (PID %d)", st.PID))
+			default:
+				mStatus.SetTitle("状态:已停止")
+			}
+			if st.Restarts > 0 {
+				mStatus.SetTooltip(fmt.Sprintf("自动重启次数:%d;最近错误:%s", st.Restarts, st.LastError))
+			} else {
+				mStatus.SetTooltip("服务运行正常")
+			}
+			mURL.SetTitle("打开 Web 界面:" + shortURL(st.URL))
+			if st.Running {
+				mStart.Disable()
+				mStop.Enable()
+			} else {
+				mStart.Enable()
+				mStop.Disable()
+			}
+		}
+	}()
+
+	// 版本刷新(启动一次 + 每 10 分钟)
+	go func() {
+		for {
+			v := sup.installedVersion()
+			if v == "" {
+				v = "未知"
+			}
+			mVersion.SetTitle("版本:" + v)
+			time.Sleep(10 * time.Minute)
+		}
+	}()
+
+	// 菜单事件
+	go func() {
+		for {
+			select {
+			case <-mStart.ClickedCh:
+				log.Printf("菜单:启动服务")
+				sup.Start()
+			case <-mStop.ClickedCh:
+				log.Printf("菜单:停止服务")
+				sup.Stop()
+			case <-mRestart.ClickedCh:
+				log.Printf("菜单:重启服务")
+				sup.Restart()
+			case <-mURL.ClickedCh:
+				status := sup.Status()
+				log.Printf("菜单:打开 Web %s", status.URL)
+				openURL(status.URL)
+			case <-mAutoStart.ClickedCh:
+				next := !mAutoStart.Checked()
+				if err := setAutoStart(next); err != nil {
+					log.Printf("设置开机自启失败: %v", err)
+					continue
+				}
+				if next {
+					mAutoStart.Check()
+				} else {
+					mAutoStart.Uncheck()
+				}
+				cfg.AutoStart = next
+				_ = saveConfig(cfg)
+				log.Printf("开机自启 = %v", next)
+			case <-mAutoUpdate.ClickedCh:
+				next := !mAutoUpdate.Checked()
+				if next {
+					mAutoUpdate.Check()
+				} else {
+					mAutoUpdate.Uncheck()
+				}
+				cfg.AutoUpdate = next
+				_ = saveConfig(cfg)
+				log.Printf("定期自动更新 = %v(间隔 %d 小时)", next, cfg.UpdateIntervalH)
+				if next {
+					sup.startAutoUpdate()
+				}
+			case <-mUpdateNow.ClickedCh:
+				go func() {
+					log.Printf("菜单:立即更新")
+					if _, err := sup.Update(); err != nil {
+						log.Printf("更新失败: %v", err)
+						return
+					}
+					log.Printf("更新完成,重启服务")
+					sup.Restart()
+					mVersion.SetTitle("版本:" + sup.installedVersion())
+				}()
+			case <-mLogs.ClickedCh:
+				openURL(appDataDir() + string(os.PathSeparator) + "logs")
+			case <-mConfig.ClickedCh:
+				openURL(configPath())
+			case <-mQuit.ClickedCh:
+				log.Printf("菜单:退出")
+				systray.Quit()
+				return
+			}
+		}
+	}()
+
+	sup.Start()
+	if cfg.AutoUpdate {
+		sup.startAutoUpdate()
+	}
+}
+
+func cfgURL(cfg *Config) string {
+	host := cfg.WebHost
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	return fmt.Sprintf("打开 Web 界面:http://%s:%d", host, cfg.WebPort)
+}
+
+// shortURL 菜单标题只显示 origin,避免 token 撑爆标题。
+func shortURL(u string) string {
+	if i := strings.Index(u, "?"); i > 0 {
+		return u[:i]
+	}
+	return u
+}
+
+// openURL 用系统默认程序打开 URL 或路径(隐藏窗口)。
+func openURL(target string) {
+	cmd := exec.Command("cmd", "/c", "start", "", target)
+	hideWindow(cmd)
+	_ = cmd.Start()
+}

+ 162 - 0
updater.go

@@ -0,0 +1,162 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+const (
+	dshPackage   = "@deepseek-ai/dsh"
+	allowScripts = "@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs"
+)
+
+// npmGlobalRoot 执行 npm root -g(隐藏窗口),返回全局 node_modules 路径。
+func npmGlobalRoot() string {
+	exe, args := npmCommandFor("root", "-g")
+	cmd := exec.Command(exe, args...)
+	hideWindow(cmd)
+	out, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+
+// npmCommandFor 组装 npm 调用:优先 node + npm-cli.js,回退 cmd /c npm。
+func npmCommandFor(args ...string) (string, []string) {
+	if node, err := exec.LookPath("node"); err == nil {
+		if cli := detectNpmCLI(node); cli != "" {
+			return node, append([]string{cli}, args...)
+		}
+	}
+	return "cmd.exe", append([]string{"/c", "npm"}, args...)
+}
+
+func detectNpmCLI(node string) string {
+	base := filepath.Dir(node)
+	candidates := []string{
+		filepath.Join(base, "node_modules", "npm", "bin", "npm-cli.js"),
+		filepath.Join(base, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
+	}
+	for _, c := range candidates {
+		if fileExists(c) {
+			return c
+		}
+	}
+	return ""
+}
+
+// readPackageVersion 读取 package.json 的 version。
+func readPackageVersion(path string) string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return ""
+	}
+	var v struct {
+		Version string `json:"version"`
+	}
+	if err := json.Unmarshal(data, &v); err != nil {
+		return ""
+	}
+	return v.Version
+}
+
+// Update 执行全局更新:npm install -g @deepseek-ai/dsh@latest --allow-scripts=...
+func (s *Supervisor) Update() (string, error) {
+	s.mu.Lock()
+	if s.updating {
+		s.mu.Unlock()
+		return "", errors.New("更新已在进行中")
+	}
+	s.updating = true
+	s.mu.Unlock()
+	defer func() {
+		s.mu.Lock()
+		s.updating = false
+		s.mu.Unlock()
+	}()
+
+	exe, args := npmCommandFor("install", "-g", dshPackage+"@latest", "--allow-scripts="+allowScripts)
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, exe, args...)
+	hideWindow(cmd)
+	s.log.Printf("执行更新: %s %s", exe, strings.Join(args, " "))
+	out, err := cmd.CombinedOutput()
+	text := strings.TrimSpace(string(out))
+	if text != "" {
+		s.log.Printf("更新输出: %s", text)
+	}
+	if err != nil {
+		return text, fmt.Errorf("更新失败: %w", err)
+	}
+	s.mu.Lock()
+	s.binCache = ""
+	s.mu.Unlock()
+	return text, nil
+}
+
+// LatestVersion 查询 npm 上最新版本。
+func (s *Supervisor) LatestVersion() (string, error) {
+	exe, args := npmCommandFor("view", dshPackage, "version")
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, exe, args...)
+	hideWindow(cmd)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// startAutoUpdate 定期后台更新:按间隔轮询,发现新版本才更新并重启。
+func (s *Supervisor) startAutoUpdate() {
+	go func() {
+		for {
+			s.mu.Lock()
+			enabled := s.cfg.AutoUpdate
+			hours := s.cfg.UpdateIntervalH
+			s.mu.Unlock()
+			if !enabled {
+				s.log.Printf("定期更新:已关闭")
+				return
+			}
+			if hours <= 0 {
+				hours = 24
+			}
+			time.Sleep(time.Duration(hours) * time.Hour)
+
+			s.mu.Lock()
+			enabled = s.cfg.AutoUpdate
+			s.mu.Unlock()
+			if !enabled {
+				return
+			}
+
+			latest, err := s.LatestVersion()
+			if err != nil {
+				s.log.Printf("定期更新:查询最新版本失败: %v", err)
+				continue
+			}
+			current := s.installedVersion()
+			if latest == "" || current == latest {
+				s.log.Printf("定期更新:已是最新(%s)", current)
+				continue
+			}
+			s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
+			if _, err := s.Update(); err != nil {
+				s.log.Printf("定期更新失败: %v", err)
+				continue
+			}
+			s.Restart()
+		}
+	}()
+}

+ 0 - 113
windows.go

@@ -1,113 +0,0 @@
-package main
-
-import (
-	"os"
-	"os/exec"
-	"syscall"
-	"unsafe"
-)
-
-var (
-	user32   = syscall.NewLazyDLL("user32.dll")
-	kernel32 = syscall.NewLazyDLL("kernel32.dll")
-	wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
-
-	msgBoxW          = user32.NewProc("MessageBoxW")
-	consoleSessionID = kernel32.NewProc("WTSGetActiveConsoleSessionId")
-	queryUserToken   = wtsapi32.NewProc("WTSQueryUserToken")
-	createEnvBlock   = user32.NewProc("CreateEnvironmentBlock")
-	destroyEnvBlock  = user32.NewProc("DestroyEnvironmentBlock")
-	createAsUser     = kernel32.NewProc("CreateProcessAsUserW")
-)
-
-const (
-	mbOK            = 0x00000000
-	mbIconWarning   = 0x00000030
-	mbSetForeground = 0x00010000
-	mbTopmost       = 0x00040000
-)
-
-func mustUTF16Ptr(s string) *uint16 {
-	p, _ := syscall.UTF16PtrFromString(s)
-	return p
-}
-
-func showMessageBox(title, msg string) {
-	msgBoxW.Call(0,
-		uintptr(unsafe.Pointer(mustUTF16Ptr(msg))),
-		uintptr(unsafe.Pointer(mustUTF16Ptr(title))),
-		uintptr(mbOK|mbIconWarning|mbSetForeground|mbTopmost),
-	)
-}
-
-func runNotepad(path string) {
-	if err := exec.Command("notepad.exe", path).Start(); err != nil {
-		showMessageBox("错误", "无法打开记事本: "+err.Error())
-	}
-}
-
-// openBrowser launches the default browser at url without a console window.
-func openBrowser(url string) error {
-	return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
-}
-
-// relaunchInUserSession relaunches the process in the active user session when
-// the current process runs in Session 0 (e.g. started by a Windows service).
-func relaunchInUserSession() {
-	sessionID, _, _ := consoleSessionID.Call()
-	if sessionID == 0 {
-		return
-	}
-
-	var ourSession uint32
-	kernel32.NewProc("ProcessIdToSessionId").Call(
-		uintptr(os.Getpid()), uintptr(unsafe.Pointer(&ourSession)),
-	)
-	if ourSession != 0 {
-		return
-	}
-
-	var token syscall.Handle
-	ret, _, _ := queryUserToken.Call(sessionID, uintptr(unsafe.Pointer(&token)))
-	if ret == 0 || token == 0 {
-		return
-	}
-	defer syscall.CloseHandle(token)
-
-	var envBlock unsafe.Pointer
-	ret, _, _ = createEnvBlock.Call(uintptr(unsafe.Pointer(&envBlock)), uintptr(token), 0)
-	if ret == 0 {
-		return
-	}
-	defer destroyEnvBlock.Call(uintptr(envBlock))
-
-	exePath, err := os.Executable()
-	if err != nil {
-		return
-	}
-	exePtr := mustUTF16Ptr(exePath)
-
-	var si syscall.StartupInfo
-	si.Cb = uint32(unsafe.Sizeof(si))
-	var pi syscall.ProcessInformation
-
-	ret, _, _ = createAsUser.Call(
-		uintptr(token),
-		0,
-		uintptr(unsafe.Pointer(exePtr)),
-		0,
-		0,
-		0,
-		0,
-		uintptr(unsafe.Pointer(envBlock)),
-		0,
-		uintptr(unsafe.Pointer(&si)),
-		uintptr(unsafe.Pointer(&pi)),
-	)
-	if ret != 0 {
-		syscall.CloseHandle(pi.Thread)
-		syscall.CloseHandle(pi.Process)
-	}
-
-	os.Exit(0)
-}