You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
910 lines
24 KiB
910 lines
24 KiB
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strconv"
|
|
"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, waits for the service port, and opens the browser.
|
|
//
|
|
// 新版本 dsh (0.1.5-rc.1) 不再在 stdout 打印带 token 的 URL,
|
|
// 改用 cookie session 自动登录。所以这里改 polling 端口 3080 LISTEN 状态:
|
|
// - 一旦端口 LISTEN,服务就绪
|
|
// - 直接 open http://127.0.0.1:3080/,浏览器带本地 cookie 自动登录
|
|
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()
|
|
|
|
// 启动前先清理:杀残留 dsh 进程,再强杀占 3080 端口的残留 PID(孤儿 node 等)
|
|
killDSHTree()
|
|
freePort(3080)
|
|
|
|
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 只起服务不自动开浏览器,我们自己开
|
|
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()
|
|
|
|
// drain 掉 stdout/stderr 避免阻塞
|
|
safeGo("drainStdout", func() { drainLog(stdout, "dsh") })
|
|
safeGo("drainStderr", func() { drainLog(stderr, "dsh") })
|
|
|
|
// 监听子进程退出
|
|
safeGo("dshWait", func() {
|
|
_ = cmd.Wait()
|
|
a.mu.Lock()
|
|
a.running = false
|
|
a.mu.Unlock()
|
|
})
|
|
|
|
// polling 等端口 3080 LISTEN(替代等 URL 字符串)
|
|
const port = 3080
|
|
const timeout = 30 * time.Second
|
|
ready := waitForPort(port, timeout)
|
|
if !ready {
|
|
_ = cmd.Process.Kill()
|
|
return StartResult{OK: false, Error: "等待端口 3080 监听超时(30s),dsh 可能没正常启动"}
|
|
}
|
|
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/", port)
|
|
a.mu.Lock()
|
|
a.dshURL = url
|
|
a.mu.Unlock()
|
|
|
|
// 用系统 open 唤起浏览器(cookie session 自动登录)
|
|
if openErr := openBrowser(url); openErr != nil {
|
|
return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()}
|
|
}
|
|
|
|
// 启动成功 + 已打开浏览器 → 最小化到 Dock
|
|
if a.ctx != nil {
|
|
wailsruntime.WindowMinimise(a.ctx)
|
|
}
|
|
return StartResult{OK: true, URL: url, Hidden: true}
|
|
}
|
|
|
|
// waitForPort 轮询检查端口是否处于 LISTEN,timeout 内返回 true。
|
|
func waitForPort(port int, timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
if pids, _ := lsofPort(port); len(pids) > 0 {
|
|
return true
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// freePort 强制释放端口: lsof 拿到 LISTEN 的所有 PID,逐一 kill -9。
|
|
// 返回被杀的 PID 数量。
|
|
// 适用于:
|
|
// - Start 前:如果旧 dsh 进程杀完但端口还被占(孤儿 node 进程),再补刀
|
|
// - Stop 后:如果 dsh 进程杀完但端口还在 TIME_WAIT,精准 kill 残留 PID
|
|
// - OnShutdown:app 退出前保证不残留任何 dsh 进程 + 端口
|
|
func freePort(port int) int {
|
|
pids, _ := lsofPort(port)
|
|
if len(pids) == 0 {
|
|
return 0
|
|
}
|
|
log.Printf("freePort: %d pid(s) holding :%d", len(pids), port)
|
|
for _, pid := range pids {
|
|
if err := exec.Command("kill", "-9", pid).Run(); err != nil {
|
|
log.Printf("freePort: kill -9 %s: %v", pid, err)
|
|
}
|
|
}
|
|
// 等系统释放 socket
|
|
time.Sleep(300 * time.Millisecond)
|
|
return len(pids)
|
|
}
|
|
|
|
// drainLog 读子进程输出并丢弃(不阻塞子进程 stdout 缓冲区)。
|
|
func drainLog(r io.Reader, tag string) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
_ = scanner.Text()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
|
|
// 兜底:把 dsh 整个进程树都杀(dsh 自己 + dsh-pet Electron helper + GPU/renderer/utility 等)
|
|
// 不杀全,dsh-pet 会在 EPIPE 时弹 JavaScript 错误弹窗。
|
|
killDSHTree()
|
|
|
|
// 再精准 kill 占 3080 的残留 PID(孤儿 node、TIME_WAIT 等)
|
|
freePort(3080)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// OpenURL opens a URL in the system's default browser via the OS.
|
|
// 前端可以重复调用而不弹"验证不对"(cookie session 复用)。
|
|
func (a *App) OpenURL(url string) error {
|
|
return openBrowser(url)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
// 按 X 关 app / Stop 按钮 / OnShutdown 统一都走这个流程:
|
|
// 1) 杀整个 dsh 进程树(dsh + dsh-pet)
|
|
// 2) 再精准 kill 占 3080 端口的残留 PID
|
|
killDSHTree()
|
|
freePort(3080)
|
|
}
|
|
|
|
// 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 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"`
|
|
}
|
|
|
|
// killDSHTree kills all processes related to the dsh service:
|
|
// - the dsh CLI itself (npm/node dsh/lib/bin.js)
|
|
// - dsh-pet Electron helper (main / renderer / gpu / utility / network)
|
|
// - any Electron app launched from ~/.dsh/electron
|
|
//
|
|
// 不只 kill 主进程: dsh-pet 还在跑时,EPIPE 会在 pipe 关闭后让它抛
|
|
// "Uncaught Exception" 弹窗,污染退出 UX。
|
|
func killDSHTree() {
|
|
pids := listDSHPids()
|
|
if len(pids) == 0 {
|
|
return
|
|
}
|
|
log.Printf("killDSHTree: %d pids to kill", len(pids))
|
|
for _, pid := range pids {
|
|
// kill -9 强杀,避免 dsh-pet 还在做 graceful shutdown 又触发 EPIPE
|
|
if err := exec.Command("kill", "-9", pid).Run(); err != nil {
|
|
log.Printf("killDSHTree: kill %s: %v", pid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// listDSHPids 列出所有跟 dsh 相关的进程 PID(去重 + 排除自身)。
|
|
// 用 ps + grep 而不是 pgrep,以兼容 macOS 默认 PATH 不带 /usr/bin 的情况。
|
|
func listDSHPids() []string {
|
|
out, err := runQuiet("ps", "-axo", "pid=,command=")
|
|
if err != nil {
|
|
log.Printf("listDSHPids: ps err: %v", err)
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var pids []string
|
|
self := os.Getpid()
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// 格式: "PID COMMAND..."
|
|
sp := strings.IndexByte(line, ' ')
|
|
if sp <= 0 {
|
|
continue
|
|
}
|
|
pidStr := line[:sp]
|
|
cmd := line[sp+1:]
|
|
// 跳过自身进程,免得自杀
|
|
if pid, _ := strconv.Atoi(pidStr); pid == self {
|
|
continue
|
|
}
|
|
// 匹配规则: dsh 启动的所有相关进程
|
|
if isDSHProcess(cmd) {
|
|
if !seen[pidStr] {
|
|
seen[pidStr] = true
|
|
pids = append(pids, pidStr)
|
|
}
|
|
}
|
|
}
|
|
return pids
|
|
}
|
|
|
|
// isDSHProcess 判断一条 ps command 是不是 dsh 相关的进程。
|
|
// 规则:
|
|
func isDSHProcess(cmd string) bool {
|
|
matches := []string{
|
|
"dsh/lib/bin.js", // dsh CLI 主进程
|
|
"dsh-pet", // dsh-pet Electron helper
|
|
".dsh/electron", // dsh 内置的 Electron runtime
|
|
"dsh-pet-electron-helper", // dsh-pet user-data-dir
|
|
}
|
|
for _, m := range matches {
|
|
if strings.Contains(cmd, m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
|
|
|