|
|
|
|
@ -10,7 +10,6 @@ import ( |
|
|
|
|
"os" |
|
|
|
|
"os/exec" |
|
|
|
|
"path/filepath" |
|
|
|
|
"regexp" |
|
|
|
|
"runtime" |
|
|
|
|
"runtime/debug" |
|
|
|
|
"strconv" |
|
|
|
|
@ -207,7 +206,12 @@ func (a *App) CheckEnv() (res CheckResult) { |
|
|
|
|
return res |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Start launches dsh web, captures the token URL and opens the browser.
|
|
|
|
|
// 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 { |
|
|
|
|
@ -216,6 +220,10 @@ func (a *App) Start() StartResult { |
|
|
|
|
} |
|
|
|
|
a.mu.Unlock() |
|
|
|
|
|
|
|
|
|
// 启动前先清理:杀残留 dsh 进程,再强杀占 3080 端口的残留 PID(孤儿 node 等)
|
|
|
|
|
killDSHTree() |
|
|
|
|
freePort(3080) |
|
|
|
|
|
|
|
|
|
dshBin := lookPath("dsh") |
|
|
|
|
if dshBin == "" { |
|
|
|
|
if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { |
|
|
|
|
@ -229,7 +237,7 @@ func (a *App) Start() StartResult { |
|
|
|
|
return StartResult{OK: false, Error: "找不到 dsh 可执行文件"} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// --no-open 让 dsh 只打印 URL,我们自己用系统 open 打开
|
|
|
|
|
// --no-open 让 dsh 只起服务不自动开浏览器,我们自己开
|
|
|
|
|
cmd := exec.Command(dshBin, "--profile", "web", "--no-open") |
|
|
|
|
cmd.Env = buildChildEnv() |
|
|
|
|
|
|
|
|
|
@ -253,11 +261,9 @@ func (a *App) Start() StartResult { |
|
|
|
|
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) }) |
|
|
|
|
// drain 掉 stdout/stderr 避免阻塞
|
|
|
|
|
safeGo("drainStdout", func() { drainLog(stdout, "dsh") }) |
|
|
|
|
safeGo("drainStderr", func() { drainLog(stderr, "dsh") }) |
|
|
|
|
|
|
|
|
|
// 监听子进程退出
|
|
|
|
|
safeGo("dshWait", func() { |
|
|
|
|
@ -267,26 +273,74 @@ func (a *App) Start() StartResult { |
|
|
|
|
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()} |
|
|
|
|
// 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 |
|
|
|
|
} |
|
|
|
|
// 启动成功 + 已打开浏览器 → 最小化到 Dock(用 WindowMinimise 不用 WindowHide,
|
|
|
|
|
// 这样点 Dock 图标系统会自动 unminimise 恢复窗口,而不是再也打不开)。
|
|
|
|
|
if a.ctx != nil { |
|
|
|
|
wailsruntime.WindowMinimise(a.ctx) |
|
|
|
|
if time.Now().After(deadline) { |
|
|
|
|
return false |
|
|
|
|
} |
|
|
|
|
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)"} |
|
|
|
|
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() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -305,6 +359,9 @@ func (a *App) Stop() StartResult { |
|
|
|
|
// 不杀全,dsh-pet 会在 EPIPE 时弹 JavaScript 错误弹窗。
|
|
|
|
|
killDSHTree() |
|
|
|
|
|
|
|
|
|
// 再精准 kill 占 3080 的残留 PID(孤儿 node、TIME_WAIT 等)
|
|
|
|
|
freePort(3080) |
|
|
|
|
|
|
|
|
|
time.Sleep(500 * time.Millisecond) |
|
|
|
|
|
|
|
|
|
a.mu.Lock() |
|
|
|
|
@ -326,6 +383,12 @@ func (a *App) ShowWindow() { |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 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 不会自动恢复)。
|
|
|
|
|
@ -359,7 +422,11 @@ func (a *App) stopDSH() { |
|
|
|
|
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。
|
|
|
|
|
@ -462,40 +529,6 @@ func cachedNpmPrefix() (string, bool) { |
|
|
|
|
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": |
|
|
|
|
|