chore: initial commit
- Wails v2 + Go 1.21+ + Vue 3 dark UI - 3 buttons: Check env (auto-install dsh) / Start dsh web / Stop - 升级 / 检查版本 一键按钮 - 启动后窗口自动最小化到 Dock - 顶部 logo 鼠标 hover 显示大图预览 - scripts/: build-mac.sh, build-windows.bat, install-app.sh - ui-design/: 视觉设计稿 (HTML + tokens)
This commit is contained in:
@@ -0,0 +1,810 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// App is the Wails application backend.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
dshCmd *exec.Cmd
|
||||
dshURL string
|
||||
startedAt time.Time
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct.
|
||||
func NewApp() *App {
|
||||
initCrashLog()
|
||||
// 在进程启动时预先初始化 npm prefix 缓存,后续 buildChildEnv 直接读缓存。
|
||||
// 这步可能 exec npm 子命令(限 2s 超时),但不会和 buildChildEnv 互相递归。
|
||||
initNpmPrefix()
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// initNpmPrefix triggers the cached npm prefix lookup exactly once.
|
||||
// 必须在启动早期(且不依赖 buildChildEnv)调用,避免 buildChildEnv → cachedNpmPrefix
|
||||
// 第一次执行时栈还未充分展开导致后续路径无法解析。
|
||||
func initNpmPrefix() {
|
||||
_, _ = cachedNpmPrefix()
|
||||
}
|
||||
|
||||
// initCrashLog wires a panic logger that writes to ~/Library/Logs/dsh-launcher.log.
|
||||
// macOS GUI 进程 stderr 没终端,panic 信息看不到,落盘最稳。
|
||||
var crashLogOnce sync.Once
|
||||
|
||||
func initCrashLog() {
|
||||
crashLogOnce.Do(func() {
|
||||
home, _ := os.UserHomeDir()
|
||||
logPath := filepath.Join(home, "Library", "Logs", "dsh-launcher.log")
|
||||
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.SetOutput(f)
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile)
|
||||
})
|
||||
}
|
||||
|
||||
// safeGo wraps a goroutine with recover so a panic in any background
|
||||
// task can't take down the whole Wails app.
|
||||
func safeGo(name string, fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[panic:%s] %v\n%s", name, r, debugStack())
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
func debugStack() string {
|
||||
return string(debug.Stack())
|
||||
}
|
||||
|
||||
// startup is called when the app starts.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// shutdown is called when the app is closing. Ensure dsh is killed
|
||||
// so we never leak a child process.
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
a.stopDSH()
|
||||
}
|
||||
|
||||
// ---- Types exposed to the frontend ----
|
||||
|
||||
// CheckResult is returned by CheckEnv.
|
||||
type CheckResult struct {
|
||||
NodePath string `json:"nodePath"`
|
||||
NodeVersion string `json:"nodeVersion"`
|
||||
NodeOK bool `json:"nodeOK"`
|
||||
DshPath string `json:"dshPath"`
|
||||
DshVersion string `json:"dshVersion"`
|
||||
CredFile string `json:"credFile"`
|
||||
CredExists bool `json:"credExists"`
|
||||
HomeDshDir string `json:"homeDshDir"`
|
||||
PortBusy bool `json:"portBusy"`
|
||||
PortBusyBy string `json:"portBusyBy"`
|
||||
Issues []string `json:"issues"` // 始终非 nil,前端可放心访问
|
||||
}
|
||||
|
||||
// StartResult is returned by Start.
|
||||
type StartResult struct {
|
||||
OK bool `json:"ok"`
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error"`
|
||||
Output string `json:"output"`
|
||||
Hidden bool `json:"hidden"` // 是否已自动隐藏窗口
|
||||
}
|
||||
|
||||
// StatusResult is returned by GetStatus.
|
||||
type StatusResult struct {
|
||||
Running bool `json:"running"`
|
||||
URL string `json:"url"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
Pid int `json:"pid"`
|
||||
}
|
||||
|
||||
// ---- 3 core methods ----
|
||||
|
||||
// CheckEnv inspects Node, dsh, ~/.dsh credentials and port 3080.
|
||||
// 所有外部命令都套超时 + panic recover,任何一个失败不能让整个 app 闪退。
|
||||
func (a *App) CheckEnv() (res CheckResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[panic:CheckEnv] %v\n%s", r, debugStack())
|
||||
res.Issues = append(res.Issues, fmt.Sprintf("CheckEnv 内部错误: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
home = "/tmp"
|
||||
}
|
||||
res = CheckResult{
|
||||
HomeDshDir: filepath.Join(home, ".dsh"),
|
||||
CredFile: filepath.Join(home, ".dsh", ".credentials.yaml"),
|
||||
PortBusy: false,
|
||||
Issues: []string{}, // 确保 JSON 序列化为 [] 而非 null
|
||||
}
|
||||
|
||||
// node
|
||||
res.NodePath = lookPath("node")
|
||||
if res.NodePath != "" {
|
||||
if v, err := runQuiet(res.NodePath, "-v"); err == nil {
|
||||
res.NodeVersion = strings.TrimSpace(v)
|
||||
if nodeMajor(res.NodeVersion) >= 18 {
|
||||
res.NodeOK = true
|
||||
}
|
||||
} else {
|
||||
log.Printf("CheckEnv node -v err: %v", err)
|
||||
}
|
||||
}
|
||||
if !res.NodeOK {
|
||||
res.Issues = append(res.Issues, "Node >= 18 未安装或不可用")
|
||||
}
|
||||
|
||||
// dsh
|
||||
res.DshPath = lookPath("dsh")
|
||||
if res.DshPath == "" {
|
||||
// fallback to npm prefix bin
|
||||
if npm, err := runQuiet("npm", "prefix", "-g"); err == nil {
|
||||
prefix := strings.TrimSpace(npm)
|
||||
candidate := filepath.Join(prefix, "bin", "dsh")
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
res.DshPath = candidate
|
||||
}
|
||||
} else {
|
||||
log.Printf("CheckEnv npm prefix err: %v", err)
|
||||
}
|
||||
}
|
||||
if res.DshPath != "" {
|
||||
if v, err := runQuiet(res.DshPath, "--version"); err == nil {
|
||||
res.DshVersion = strings.TrimSpace(v)
|
||||
} else {
|
||||
log.Printf("CheckEnv dsh --version err: %v", err)
|
||||
}
|
||||
} else {
|
||||
res.Issues = append(res.Issues, "dsh 命令未找到(请 npm i -g @deepseek-ai/dsh)")
|
||||
}
|
||||
|
||||
// credentials
|
||||
if _, err := os.Stat(res.CredFile); err == nil {
|
||||
res.CredExists = true
|
||||
} else {
|
||||
res.Issues = append(res.Issues, "缺少 ~/.dsh/.credentials.yaml,请先 dsh login")
|
||||
}
|
||||
|
||||
// port 3080 — lsof 在受限进程下可能挂死,超时保护
|
||||
if pids, err := lsofPort(3080); err == nil && len(pids) > 0 {
|
||||
res.PortBusy = true
|
||||
res.PortBusyBy = strings.Join(pids, ", ")
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// Start launches dsh web, captures the token URL and opens the browser.
|
||||
func (a *App) Start() StartResult {
|
||||
a.mu.Lock()
|
||||
if a.running {
|
||||
a.mu.Unlock()
|
||||
return StartResult{OK: false, URL: a.dshURL, Error: "已在运行中"}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
dshBin := lookPath("dsh")
|
||||
if dshBin == "" {
|
||||
if npm, err := runQuiet("npm", "prefix", "-g"); err == nil {
|
||||
candidate := filepath.Join(strings.TrimSpace(npm), "bin", "dsh")
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
dshBin = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if dshBin == "" {
|
||||
return StartResult{OK: false, Error: "找不到 dsh 可执行文件"}
|
||||
}
|
||||
|
||||
// --no-open 让 dsh 只打印 URL,我们自己用系统 open 打开
|
||||
cmd := exec.Command(dshBin, "--profile", "web", "--no-open")
|
||||
cmd.Env = buildChildEnv()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return StartResult{OK: false, Error: "无法创建 stdout 管道: " + err.Error()}
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return StartResult{OK: false, Error: "无法创建 stderr 管道: " + err.Error()}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return StartResult{OK: false, Error: "启动 dsh 失败: " + err.Error()}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.dshCmd = cmd
|
||||
a.running = true
|
||||
a.startedAt = time.Now()
|
||||
a.dshURL = ""
|
||||
a.mu.Unlock()
|
||||
|
||||
// 异步读 stdout/stderr,抓 URL
|
||||
urlCh := make(chan string, 1)
|
||||
errCh := make(chan string, 1)
|
||||
safeGo("scanForURL", func() { scanForURL(stdout, urlCh) })
|
||||
safeGo("drainStderr", func() { drainStderr(stderr, errCh) })
|
||||
|
||||
// 监听子进程退出
|
||||
safeGo("dshWait", func() {
|
||||
_ = cmd.Wait()
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
})
|
||||
|
||||
// 等 URL(最多 30 秒)
|
||||
select {
|
||||
case url := <-urlCh:
|
||||
a.mu.Lock()
|
||||
a.dshURL = url
|
||||
a.mu.Unlock()
|
||||
// 用系统 open 唤起浏览器
|
||||
if openErr := openBrowser(url); openErr != nil {
|
||||
return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()}
|
||||
}
|
||||
// 启动成功 + 已打开浏览器 → 最小化到 Dock(用 WindowMinimise 不用 WindowHide,
|
||||
// 这样点 Dock 图标系统会自动 unminimise 恢复窗口,而不是再也打不开)。
|
||||
if a.ctx != nil {
|
||||
wailsruntime.WindowMinimise(a.ctx)
|
||||
}
|
||||
return StartResult{OK: true, URL: url, Hidden: true}
|
||||
case errMsg := <-errCh:
|
||||
return StartResult{OK: false, Error: "dsh 输出错误: " + errMsg}
|
||||
case <-time.After(30 * time.Second):
|
||||
return StartResult{OK: false, Error: "等待 dsh URL 超时(30s)"}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop kills the dsh process and verifies the port is released.
|
||||
func (a *App) Stop() StartResult {
|
||||
a.mu.Lock()
|
||||
cmd := a.dshCmd
|
||||
a.mu.Unlock()
|
||||
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
// 没记录,直接 pkill 兜底
|
||||
if pkill := lookPath("pkill"); pkill != "" {
|
||||
_ = exec.Command(pkill, "-f", "dsh/lib/bin.js").Run()
|
||||
}
|
||||
} else {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
|
||||
// 兜底 pkill,确保所有 dsh 子进程都死
|
||||
if pkill := lookPath("pkill"); pkill != "" {
|
||||
_ = exec.Command(pkill, "-f", "dsh/lib/bin.js").Run()
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
a.mu.Lock()
|
||||
a.dshCmd = nil
|
||||
a.dshURL = ""
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
|
||||
if pids, err := lsofPort(3080); err == nil && len(pids) > 0 {
|
||||
return StartResult{OK: false, Error: "端口 3080 仍被占用: " + strings.Join(pids, ", ")}
|
||||
}
|
||||
return StartResult{OK: true}
|
||||
}
|
||||
|
||||
// ShowWindow brings the application window to the front.
|
||||
func (a *App) ShowWindow() {
|
||||
if a.ctx != nil {
|
||||
wailsruntime.WindowShow(a.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// HideWindow minimises the application window to Dock.
|
||||
// 用 WindowMinimise 而不是 WindowHide,这样 macOS 上点 Dock 图标系统会
|
||||
// 自动 unminimise 恢复窗口(WindowHide 之后点 Dock 不会自动恢复)。
|
||||
func (a *App) HideWindow() {
|
||||
if a.ctx != nil {
|
||||
wailsruntime.WindowMinimise(a.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current dsh state.
|
||||
func (a *App) GetStatus() StatusResult {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
res := StatusResult{Running: a.running, URL: a.dshURL}
|
||||
if a.dshCmd != nil && a.dshCmd.Process != nil {
|
||||
res.Pid = a.dshCmd.Process.Pid
|
||||
}
|
||||
if !a.startedAt.IsZero() {
|
||||
res.StartedAt = a.startedAt.Format(time.RFC3339)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
func (a *App) stopDSH() {
|
||||
a.mu.Lock()
|
||||
cmd := a.dshCmd
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if pkill := lookPath("pkill"); pkill != "" {
|
||||
_ = exec.Command(pkill, "-f", "dsh/lib/bin.js").Run()
|
||||
}
|
||||
}
|
||||
|
||||
// buildChildEnv 手动把 homebrew 路径塞进子进程 PATH。
|
||||
// macOS GUI 启动的进程不读 ~/.zshrc,直接拿环境变量 PATH 经常缺 /opt/homebrew/bin。
|
||||
// 注意:本函数不能调任何 exec 子命令(包括 npm),否则会触发栈溢出。
|
||||
// npm 全局 bin 路径由 cachedNpmPrefix() 在 initNpmPrefix() 中预先算好。
|
||||
func buildChildEnv() []string {
|
||||
env := os.Environ()
|
||||
home := os.Getenv("HOME")
|
||||
extra := []string{
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
}
|
||||
// 加上缓存好的 npm 全局 bin(如果有)
|
||||
if prefix, ok := cachedNpmPrefix(); ok {
|
||||
extra = append(extra, filepath.Join(prefix, "bin"))
|
||||
}
|
||||
|
||||
hasPath := false
|
||||
for i, e := range env {
|
||||
if strings.HasPrefix(e, "PATH=") {
|
||||
hasPath = true
|
||||
cur := strings.TrimPrefix(e, "PATH=")
|
||||
merged := mergePath(cur, extra)
|
||||
env[i] = "PATH=" + merged
|
||||
}
|
||||
}
|
||||
if !hasPath {
|
||||
env = append(env, "PATH="+strings.Join(extra, ":"))
|
||||
}
|
||||
if home == "" {
|
||||
env = append(env, "HOME=/tmp")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// mergePath 拼接 PATH。绝对不能调 runQuiet / 任何 exec 子命令,
|
||||
// 否则会触发 buildChildEnv → mergePath 的无限递归 → 栈溢出。
|
||||
// npm 全局 bin 用 sync.Once 懒加载 + 超时兜底,在 initEnvOnce 里完成。
|
||||
func mergePath(cur string, extra []string) string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
for _, p := range strings.Split(cur, ":") {
|
||||
add(p)
|
||||
}
|
||||
for _, p := range extra {
|
||||
add(p)
|
||||
}
|
||||
return strings.Join(out, ":")
|
||||
}
|
||||
|
||||
// npmPrefixCache 用 sync.Once 缓存 `npm prefix -g` 的输出,
|
||||
// 避免每次 buildChildEnv 都 exec npm。
|
||||
var (
|
||||
npmPrefixOnce sync.Once
|
||||
npmPrefixValue string
|
||||
npmPrefixReady bool
|
||||
)
|
||||
|
||||
func cachedNpmPrefix() (string, bool) {
|
||||
npmPrefixOnce.Do(func() {
|
||||
// 只在确实有 npm 时才跑
|
||||
bin := lookPath("npm")
|
||||
if bin == "" {
|
||||
return
|
||||
}
|
||||
// 硬超时 2s,失败就算了,不致命。
|
||||
// 注意:这里直接 exec 用 os.Environ(),不走 buildChildEnv,
|
||||
// 否则初始化期间可能再次触发 buildChildEnv 形成循环。
|
||||
cmd := exec.Command(bin, "prefix", "-g")
|
||||
cmd.Env = os.Environ()
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
npmPrefixValue = strings.TrimSpace(buf.String())
|
||||
npmPrefixReady = npmPrefixValue != ""
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
})
|
||||
return npmPrefixValue, npmPrefixReady
|
||||
}
|
||||
|
||||
func scanForURL(r io.Reader, urlCh chan<- string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
// dsh web: http://127.0.0.1:3080/?token=xxx
|
||||
// 兼容多种格式
|
||||
re := regexp.MustCompile(`https?://[^\s)]+token=[A-Za-z0-9._\-]+`)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if m := re.FindString(line); m != "" {
|
||||
urlCh <- m
|
||||
return
|
||||
}
|
||||
// 兜底:任何 http URL 也接受
|
||||
if strings.Contains(line, "http://") || strings.Contains(line, "https://") {
|
||||
if m := regexp.MustCompile(`https?://[^\s]+`).FindString(line); m != "" {
|
||||
urlCh <- strings.TrimRight(m, ".,;)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drainStderr(r io.Reader, errCh chan<- string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
// 把 stderr 行发给 errCh,Start 收到后会返回
|
||||
select {
|
||||
case errCh <- scanner.Text():
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
bin := lookPath("open")
|
||||
if bin == "" {
|
||||
bin = "/usr/bin/open"
|
||||
}
|
||||
return exec.Command(bin, url).Start()
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
default:
|
||||
bin := lookPath("xdg-open")
|
||||
if bin == "" {
|
||||
return fmt.Errorf("xdg-open not found")
|
||||
}
|
||||
return exec.Command(bin, url).Start()
|
||||
}
|
||||
}
|
||||
|
||||
func lookPath(name string) string {
|
||||
// 先用 PATH
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
// macOS GUI 进程常缺 PATH,兜底几个常见位置
|
||||
for _, dir := range []string{"/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"} {
|
||||
candidate := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runQuiet(name string, args ...string) (string, error) {
|
||||
return runQuietTimeout(name, 3*time.Second, args...)
|
||||
}
|
||||
|
||||
// runQuietTimeout runs a command with a hard timeout. If it doesn't finish in time,
|
||||
// the child is killed and an error is yielded. macOS 上某些 fork-exec 失败场景,
|
||||
// 没有超时保护会一直 hang,进而拖垮调用方。
|
||||
func runQuietTimeout(name string, timeout time.Duration, args ...string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("empty command")
|
||||
}
|
||||
// 用我们自己的 lookPath(GUI 进程 PATH 缺失时也能找到 /opt/homebrew/bin 等),
|
||||
// 不能用 exec.LookPath —— 它只读系统 PATH,看不到我们手动塞的路径。
|
||||
bin := lookPath(name)
|
||||
if bin == "" {
|
||||
return "", fmt.Errorf("not found: %s (也没在 /opt/homebrew/bin 找到)", name)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = buildChildEnv()
|
||||
// 输出大小限制,防止 npm 输出爆 buffer 卡死
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start %s: %w", bin, err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return buf.String(), err
|
||||
case <-time.After(timeout):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
return "", fmt.Errorf("timeout after %s: %s %s", timeout, bin, strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
func nodeMajor(v string) int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.SplitN(v, ".", 2)
|
||||
if len(parts) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, ch := range parts[0] {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// lsofPort 返回占用该端口的 PID 列表(macOS)。带超时 + panic recover。
|
||||
func lsofPort(port int) (res []string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[panic:lsofPort] %v\n%s", r, debugStack())
|
||||
err = fmt.Errorf("lsof panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
lsofBin := lookPath("lsof")
|
||||
if lsofBin == "" {
|
||||
return nil, nil // 没 lsof,跳过
|
||||
}
|
||||
cmd := exec.Command(lsofBin, "-nP", fmt.Sprintf("-iTCP:%d", port), "-sTCP:LISTEN", "-t")
|
||||
cmd.Env = buildChildEnv()
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("lsof start: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
out := buf.Bytes()
|
||||
if err != nil && len(out) == 0 {
|
||||
// lsof 无命中时退出码非 0 + 空输出 = 端口空闲
|
||||
return nil, nil
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
res = append(res, l)
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
return nil, fmt.Errorf("lsof timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Install / Update ----
|
||||
|
||||
const dshPkg = "@deepseek-ai/dsh"
|
||||
|
||||
// UpdateInfo describes the available update for dsh.
|
||||
type UpdateInfo struct {
|
||||
Installed string `json:"installed"`
|
||||
Latest string `json:"latest"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// InstallResult is returned by InstallDsh / UpdateDsh.
|
||||
type InstallResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// CheckUpdate runs `npm view <pkg> version` to get the latest version on the registry.
|
||||
// 不依赖 outdated — outdated 需要本地有 installed record,registry-only 查不到。
|
||||
func (a *App) CheckUpdate() UpdateInfo {
|
||||
installed := ""
|
||||
if p := lookPath("dsh"); p != "" {
|
||||
if v, err := runQuietTimeout(p, 5*time.Second, "--version"); err == nil {
|
||||
installed = strings.TrimSpace(v)
|
||||
} else {
|
||||
log.Printf("CheckUpdate dsh --version err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
latest, raw := latestVersionFromNPM(dshPkg)
|
||||
|
||||
info := UpdateInfo{
|
||||
Installed: installed,
|
||||
Latest: latest,
|
||||
Raw: raw,
|
||||
}
|
||||
if installed != "" && latest != "" && installed != latest {
|
||||
info.HasUpdate = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// latestVersionFromNPM 用 `npm view <pkg> version` 取最新版。
|
||||
// 带 10 秒超时,带 stderr 捕获以便诊断。
|
||||
func latestVersionFromNPM(pkg string) (string, string) {
|
||||
npmBin := lookPath("npm")
|
||||
if npmBin == "" {
|
||||
return "", "npm not found in PATH"
|
||||
}
|
||||
cmd := exec.Command(npmBin, "view", pkg, "version", "--registry=https://registry.npmjs.org/")
|
||||
cmd.Env = buildChildEnv()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", "start npm: " + err.Error()
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if err != nil {
|
||||
raw := errMsg
|
||||
if raw == "" {
|
||||
raw = err.Error()
|
||||
}
|
||||
return "", raw
|
||||
}
|
||||
// npm 输出可能带换行 / 警告,只取第一行 token
|
||||
first := out
|
||||
if i := strings.IndexAny(out, "\n\r"); i >= 0 {
|
||||
first = strings.TrimSpace(out[:i])
|
||||
}
|
||||
// 清理 npm 有时会附加的 @ 标签
|
||||
first = strings.TrimPrefix(first, "@")
|
||||
return first, out
|
||||
case <-time.After(15 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
return "", "npm view 超时(15s),请检查网络"
|
||||
}
|
||||
}
|
||||
|
||||
// InstallDsh runs `npm install -g @deepseek-ai/dsh` and streams output via Wails events.
|
||||
// Returns InstallResult once the command exits.
|
||||
func (a *App) InstallDsh() InstallResult {
|
||||
return a.runNpmEvent("install", []string{"install", "-g", dshPkg}, "dsh:install:done")
|
||||
}
|
||||
|
||||
// UpdateDsh runs `npm install -g @deepseek-ai/dsh@latest` and streams output.
|
||||
func (a *App) UpdateDsh() InstallResult {
|
||||
return a.runNpmEvent("update", []string{"install", "-g", dshPkg + "@latest"}, "dsh:update:done")
|
||||
}
|
||||
|
||||
func (a *App) runNpmEvent(phase string, args []string, doneEvent string) InstallResult {
|
||||
npmBin := lookPath("npm")
|
||||
if npmBin == "" {
|
||||
errMsg := "找不到 npm(系统 PATH 和 /opt/homebrew/bin 都没找到)"
|
||||
if a.ctx != nil {
|
||||
wailsruntime.EventsEmit(a.ctx, "dsh:"+phase+":log", "✗ "+errMsg+"\n")
|
||||
wailsruntime.EventsEmit(a.ctx, doneEvent, InstallResult{Error: errMsg})
|
||||
}
|
||||
return InstallResult{Error: errMsg}
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wailsruntime.EventsEmit(a.ctx, "dsh:"+phase+":log", "▶ "+npmBin+" "+strings.Join(args, " ")+"\n")
|
||||
}
|
||||
|
||||
cmd := exec.Command(npmBin, args...)
|
||||
cmd.Env = buildChildEnv()
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return InstallResult{Error: err.Error()}
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return InstallResult{Error: err.Error()}
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return InstallResult{Error: "启动 npm 失败: " + err.Error()}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
var mu sync.Mutex
|
||||
collect := func(line string) {
|
||||
mu.Lock()
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\n")
|
||||
mu.Unlock()
|
||||
if a.ctx != nil {
|
||||
wailsruntime.EventsEmit(a.ctx, "dsh:"+phase+":log", line+"\n")
|
||||
}
|
||||
}
|
||||
safeGo("scanStdout", func() { scanLines(stdout, collect) })
|
||||
safeGo("scanStderr", func() { scanLines(stderr, collect) })
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
mu.Lock()
|
||||
out := buf.String()
|
||||
mu.Unlock()
|
||||
|
||||
res := InstallResult{Output: out}
|
||||
if waitErr != nil {
|
||||
res.Error = waitErr.Error()
|
||||
} else {
|
||||
res.OK = true
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wailsruntime.EventsEmit(a.ctx, doneEvent, res)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func scanLines(r io.Reader, fn func(string)) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
fn(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user