From be142da78ba6b35c46915258db3b4b49253c70b5 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 14 Sep 2026 17:40:01 +0800 Subject: [PATCH] ++ --- app.go | 153 ++++++++++++++++++------------ frontend/src/App.vue | 65 ++++++++++--- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + main.go | 6 ++ scripts/build-windows.bat | 34 ++++--- 6 files changed, 172 insertions(+), 92 deletions(-) diff --git a/app.go b/app.go index f2ac98a..a0b66ea 100644 --- a/app.go +++ b/app.go @@ -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": diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 91546c4..5d39112 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { CheckEnv, Start, Stop, GetStatus, InstallDsh, UpdateDsh, CheckUpdate, - ShowWindow, HideWindow, + ShowWindow, HideWindow, OpenURL, } from '../wailsjs/go/main/App' import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' @@ -167,6 +167,20 @@ async function doStart() { } } +// reopenBrowser 重新打开 dsh web 页面。 +// 注意: dsh 0.1.5-rc.1 用 cookie session 验证身份,关浏览器再开(尤其是换浏览器) +// 会丢 cookie → 服务端 401,这个按钮无法绕过。换浏览器场景需要在同一个 +// 浏览器开、或重新 dsh login 后再启动。 +async function reopenBrowser() { + if (!status.value.url) return + appendLog('info', `重新打开页面: ${status.value.url}`) + try { + await OpenURL(status.value.url) + } catch (e) { + appendLog('err', `打开页面失败: ${e?.message || e}`) + } +} + async function doStop() { busy.value.stop = true lastError.value = '' @@ -442,21 +456,28 @@ const showSession = computed(() => running.value)
当前会话 - {{ sessionMeta }} +
+ {{ sessionMeta }} + +
-
+
URL {{ status.url || '等待 URL…' }}
-
+
PID {{ status.pid || '—' }} -
-
- 启动于 + 启动于 {{ status.startedAt || '—' }}
@@ -817,6 +838,11 @@ p { margin: 0; } justify-content: space-between; margin-bottom: 16px; } +.panel-head-right { + display: flex; + align-items: center; + gap: 10px; +} .panel-title { font-family: var(--font-mono); font-size: 11px; @@ -914,19 +940,30 @@ p { margin: 0; } /* ─── session ──────────────────────────────────────────────────── */ .session { - display: grid; - grid-template-columns: 1fr 1fr 1fr; + display: flex; + flex-direction: column; gap: 1px; background: var(--border); border-radius: var(--radius); - overflow: hidden; } -.session-cell { +.session-row { background: var(--surface); - padding: 14px 16px; + padding: 12px 16px; display: flex; - flex-direction: column; - gap: 4px; + align-items: baseline; + gap: 12px; + min-width: 0; +} +.session-row:first-child { border-radius: var(--radius) var(--radius) 0 0; } +.session-row:last-child { border-radius: 0 0 var(--radius) var(--radius); } + +/* 一行内 PID + 启动于 横向并排 */ +.session-row .session-value + .session-label, +.session-row .session-label.session-label-2 { + margin-left: 16px; +} +.session-label.session-label-2 { + margin-left: auto; } .session-label { font-family: var(--font-mono); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d78b55b..106990f 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -12,6 +12,8 @@ export function HideWindow():Promise; export function InstallDsh():Promise; +export function OpenURL(arg1:string):Promise; + export function ShowWindow():Promise; export function Start():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 86debee..fbcdd60 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -22,6 +22,10 @@ export function InstallDsh() { return window['go']['main']['App']['InstallDsh'](); } +export function OpenURL(arg1) { + return window['go']['main']['App']['OpenURL'](arg1); +} + export function ShowWindow() { return window['go']['main']['App']['ShowWindow'](); } diff --git a/main.go b/main.go index 5b1b1cb..094aa19 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "embed" "fmt" "os" + "time" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -33,6 +34,11 @@ func main() { for _, p := range pids { fmt.Printf(" - %s\n", p) } + fmt.Println("=== waitForPort test (3080, 1s) ===") + fmt.Printf("port ready: %v\n", waitForPort(3080, 1*time.Second)) + fmt.Println("=== freePort test (3080) ===") + killed := freePort(3080) + fmt.Printf("killed: %d pid(s)\n", killed) fmt.Println("=== done, exiting ===") return } diff --git a/scripts/build-windows.bat b/scripts/build-windows.bat index 4afbbbb..4d213da 100644 --- a/scripts/build-windows.bat +++ b/scripts/build-windows.bat @@ -1,28 +1,28 @@ @echo off REM Build dsh-launcher.exe for Windows (amd64). REM -REM 用法: scripts\build-windows.bat +REM Usage: scripts\build-windows.bat REM -REM 前置依赖(Windows 上装好后): -REM - Go 1.21+ https://go.dev/dl/ -REM - Node.js 18+ https://nodejs.org/ -REM - Wails CLI go install github.com/wailsapp/wails/v2/cmd/wails@latest -REM - WebView2 Runtime Win10 1803+ 自带,否则 https://aka.ms/webview2 -REM - MSVC build tools https://visualstudio.microsoft.com/downloads/ (Build Tools for Visual Studio, 勾选 "Desktop development with C++") +REM Prerequisites on Windows: +REM - Go 1.21+ https://go.dev/dl/ +REM - Node.js 18+ https://nodejs.org/ +REM - Wails CLI go install github.com/wailsapp/wails/v2/cmd/wails@latest +REM - WebView2 Runtime Win10 1803+ ships it, else https://aka.ms/webview2 +REM - MSVC build tools https://visualstudio.microsoft.com/downloads/ (Build Tools, "Desktop development with C++") REM -REM 注意: Wails v2 的 Windows build 需要 MSVC toolchain (cgo 编译 webview2 loader)。 -REM 不能在 macOS / Linux 上 cross-compile 出 .exe。 +REM Note: Wails v2 Windows build needs MSVC toolchain (cgo for webview2 loader). +REM Cannot cross-compile .exe from macOS / Linux. REM -REM 产物: build\bin\dsh-launcher.exe +REM Output: build\bin\dsh-launcher.exe setlocal enabledelayedexpansion cd /d "%~dp0\.." -echo ^>^> dsh-launcher · Windows build +echo ^>^> dsh-launcher Windows build echo. -REM 1. 基础工具检查 +REM 1. Tool check where go >nul 2>nul if errorlevel 1 ( echo [X] go not installed @@ -44,13 +44,13 @@ if errorlevel 1 ( exit /b 1 ) -REM 2. 检查 WINDOWS 环境 +REM 2. Must run on Windows if not "%OS%"=="Windows_NT" ( echo [X] must run on Windows, current: %OS% exit /b 1 ) -REM 3. 同步前端依赖 +REM 3. Sync frontend deps echo ^>^> npm install cd frontend call npm install @@ -60,7 +60,7 @@ if errorlevel 1 ( ) cd .. -REM 4. 编译 +REM 4. Build echo. echo ^>^> wails build wails build -clean -platform windows/amd64 @@ -69,7 +69,7 @@ if errorlevel 1 ( exit /b 1 ) -REM 5. 校验产物 +REM 5. Verify set EXE=build\bin\dsh-launcher.exe if not exist "%EXE%" ( echo [X] %EXE% not found @@ -78,8 +78,6 @@ if not exist "%EXE%" ( echo. echo [OK] exe: %EXE% -echo size: %~z1 bytes -echo. echo run: %EXE% echo build dir: build\bin\ echo.