Jack 6 days ago
parent 96b15ab6ca
commit be142da78b
  1. 137
      app.go
  2. 63
      frontend/src/App.vue
  3. 2
      frontend/wailsjs/go/main/App.d.ts
  4. 4
      frontend/wailsjs/go/main/App.js
  5. 6
      main.go
  6. 28
      scripts/build-windows.bat

137
app.go

@ -10,7 +10,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"strconv" "strconv"
@ -207,7 +206,12 @@ func (a *App) CheckEnv() (res CheckResult) {
return res 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 { func (a *App) Start() StartResult {
a.mu.Lock() a.mu.Lock()
if a.running { if a.running {
@ -216,6 +220,10 @@ func (a *App) Start() StartResult {
} }
a.mu.Unlock() a.mu.Unlock()
// 启动前先清理:杀残留 dsh 进程,再强杀占 3080 端口的残留 PID(孤儿 node 等)
killDSHTree()
freePort(3080)
dshBin := lookPath("dsh") dshBin := lookPath("dsh")
if dshBin == "" { if dshBin == "" {
if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { if npm, err := runQuiet("npm", "prefix", "-g"); err == nil {
@ -229,7 +237,7 @@ func (a *App) Start() StartResult {
return StartResult{OK: false, Error: "找不到 dsh 可执行文件"} return StartResult{OK: false, Error: "找不到 dsh 可执行文件"}
} }
// --no-open 让 dsh 只打印 URL,我们自己用系统 open 打 // --no-open 让 dsh 只起服务不自动开浏览器,我们自己
cmd := exec.Command(dshBin, "--profile", "web", "--no-open") cmd := exec.Command(dshBin, "--profile", "web", "--no-open")
cmd.Env = buildChildEnv() cmd.Env = buildChildEnv()
@ -253,11 +261,9 @@ func (a *App) Start() StartResult {
a.dshURL = "" a.dshURL = ""
a.mu.Unlock() a.mu.Unlock()
// 异步读 stdout/stderr,抓 URL // drain 掉 stdout/stderr 避免阻塞
urlCh := make(chan string, 1) safeGo("drainStdout", func() { drainLog(stdout, "dsh") })
errCh := make(chan string, 1) safeGo("drainStderr", func() { drainLog(stderr, "dsh") })
safeGo("scanForURL", func() { scanForURL(stdout, urlCh) })
safeGo("drainStderr", func() { drainStderr(stderr, errCh) })
// 监听子进程退出 // 监听子进程退出
safeGo("dshWait", func() { safeGo("dshWait", func() {
@ -267,26 +273,74 @@ func (a *App) Start() StartResult {
a.mu.Unlock() a.mu.Unlock()
}) })
// 等 URL(最多 30 秒) // polling 等端口 3080 LISTEN(替代等 URL 字符串)
select { const port = 3080
case url := <-urlCh: 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.mu.Lock()
a.dshURL = url a.dshURL = url
a.mu.Unlock() a.mu.Unlock()
// 用系统 open 唤起浏览器
// 用系统 open 唤起浏览器(cookie session 自动登录)
if openErr := openBrowser(url); openErr != nil { if openErr := openBrowser(url); openErr != nil {
return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()} return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()}
} }
// 启动成功 + 已打开浏览器 → 最小化到 Dock(用 WindowMinimise 不用 WindowHide,
// 这样点 Dock 图标系统会自动 unminimise 恢复窗口,而不是再也打不开)。 // 启动成功 + 已打开浏览器 → 最小化到 Dock
if a.ctx != nil { if a.ctx != nil {
wailsruntime.WindowMinimise(a.ctx) wailsruntime.WindowMinimise(a.ctx)
} }
return StartResult{OK: true, URL: url, Hidden: true} return StartResult{OK: true, URL: url, Hidden: true}
case errMsg := <-errCh: }
return StartResult{OK: false, Error: "dsh 输出错误: " + errMsg}
case <-time.After(30 * time.Second): // waitForPort 轮询检查端口是否处于 LISTEN,timeout 内返回 true。
return StartResult{OK: false, Error: "等待 dsh URL 超时(30s)"} 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()
} }
} }
@ -305,6 +359,9 @@ func (a *App) Stop() StartResult {
// 不杀全,dsh-pet 会在 EPIPE 时弹 JavaScript 错误弹窗。 // 不杀全,dsh-pet 会在 EPIPE 时弹 JavaScript 错误弹窗。
killDSHTree() killDSHTree()
// 再精准 kill 占 3080 的残留 PID(孤儿 node、TIME_WAIT 等)
freePort(3080)
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
a.mu.Lock() 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. // HideWindow minimises the application window to Dock.
// 用 WindowMinimise 而不是 WindowHide,这样 macOS 上点 Dock 图标系统会 // 用 WindowMinimise 而不是 WindowHide,这样 macOS 上点 Dock 图标系统会
// 自动 unminimise 恢复窗口(WindowHide 之后点 Dock 不会自动恢复)。 // 自动 unminimise 恢复窗口(WindowHide 之后点 Dock 不会自动恢复)。
@ -359,7 +422,11 @@ func (a *App) stopDSH() {
if cmd != nil && cmd.Process != nil { if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
} }
// 按 X 关 app / Stop 按钮 / OnShutdown 统一都走这个流程:
// 1) 杀整个 dsh 进程树(dsh + dsh-pet)
// 2) 再精准 kill 占 3080 端口的残留 PID
killDSHTree() killDSHTree()
freePort(3080)
} }
// buildChildEnv 手动把 homebrew 路径塞进子进程 PATH。 // buildChildEnv 手动把 homebrew 路径塞进子进程 PATH。
@ -462,40 +529,6 @@ func cachedNpmPrefix() (string, bool) {
return npmPrefixValue, npmPrefixReady 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 { func openBrowser(url string) error {
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":

@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { import {
CheckEnv, Start, Stop, GetStatus, CheckEnv, Start, Stop, GetStatus,
InstallDsh, UpdateDsh, CheckUpdate, InstallDsh, UpdateDsh, CheckUpdate,
ShowWindow, HideWindow, ShowWindow, HideWindow, OpenURL,
} from '../wailsjs/go/main/App' } from '../wailsjs/go/main/App'
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' 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() { async function doStop() {
busy.value.stop = true busy.value.stop = true
lastError.value = '' lastError.value = ''
@ -442,21 +456,28 @@ const showSession = computed(() => running.value)
<section v-if="showSession" class="panel"> <section v-if="showSession" class="panel">
<div class="panel-head"> <div class="panel-head">
<span class="panel-title">当前会话</span> <span class="panel-title">当前会话</span>
<div class="panel-head-right">
<span class="panel-meta">{{ sessionMeta }}</span> <span class="panel-meta">{{ sessionMeta }}</span>
<button
class="panel-action"
:disabled="!status.url"
@click="reopenBrowser"
>
打开页面
</button>
</div>
</div> </div>
<div class="session"> <div class="session">
<div class="session-cell"> <div class="session-row">
<span class="session-label">URL</span> <span class="session-label">URL</span>
<span class="session-value" :title="status.url"> <span class="session-value" :title="status.url">
{{ status.url || '等待 URL…' }} {{ status.url || '等待 URL…' }}
</span> </span>
</div> </div>
<div class="session-cell"> <div class="session-row">
<span class="session-label">PID</span> <span class="session-label">PID</span>
<span class="session-value">{{ status.pid || '—' }}</span> <span class="session-value">{{ status.pid || '—' }}</span>
</div> <span class="session-label session-label-2">启动于</span>
<div class="session-cell">
<span class="session-label">启动于</span>
<span class="session-value">{{ status.startedAt || '—' }}</span> <span class="session-value">{{ status.startedAt || '—' }}</span>
</div> </div>
</div> </div>
@ -817,6 +838,11 @@ p { margin: 0; }
justify-content: space-between; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
} }
.panel-head-right {
display: flex;
align-items: center;
gap: 10px;
}
.panel-title { .panel-title {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@ -914,19 +940,30 @@ p { margin: 0; }
/* ─── session ──────────────────────────────────────────────────── */ /* ─── session ──────────────────────────────────────────────────── */
.session { .session {
display: grid; display: flex;
grid-template-columns: 1fr 1fr 1fr; flex-direction: column;
gap: 1px; gap: 1px;
background: var(--border); background: var(--border);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden;
} }
.session-cell { .session-row {
background: var(--surface); background: var(--surface);
padding: 14px 16px; padding: 12px 16px;
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 4px; 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 { .session-label {
font-family: var(--font-mono); font-family: var(--font-mono);

@ -12,6 +12,8 @@ export function HideWindow():Promise<void>;
export function InstallDsh():Promise<main.InstallResult>; export function InstallDsh():Promise<main.InstallResult>;
export function OpenURL(arg1:string):Promise<void>;
export function ShowWindow():Promise<void>; export function ShowWindow():Promise<void>;
export function Start():Promise<main.StartResult>; export function Start():Promise<main.StartResult>;

@ -22,6 +22,10 @@ export function InstallDsh() {
return window['go']['main']['App']['InstallDsh'](); return window['go']['main']['App']['InstallDsh']();
} }
export function OpenURL(arg1) {
return window['go']['main']['App']['OpenURL'](arg1);
}
export function ShowWindow() { export function ShowWindow() {
return window['go']['main']['App']['ShowWindow'](); return window['go']['main']['App']['ShowWindow']();
} }

@ -4,6 +4,7 @@ import (
"embed" "embed"
"fmt" "fmt"
"os" "os"
"time"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@ -33,6 +34,11 @@ func main() {
for _, p := range pids { for _, p := range pids {
fmt.Printf(" - %s\n", p) 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 ===") fmt.Println("=== done, exiting ===")
return return
} }

@ -1,28 +1,28 @@
@echo off @echo off
REM Build dsh-launcher.exe for Windows (amd64). REM Build dsh-launcher.exe for Windows (amd64).
REM REM
REM 用法: scripts\build-windows.bat REM Usage: scripts\build-windows.bat
REM REM
REM 前置依赖(Windows 上装好后): REM Prerequisites on Windows:
REM - Go 1.21+ https://go.dev/dl/ REM - Go 1.21+ https://go.dev/dl/
REM - Node.js 18+ https://nodejs.org/ REM - Node.js 18+ https://nodejs.org/
REM - Wails CLI go install github.com/wailsapp/wails/v2/cmd/wails@latest REM - Wails CLI go install github.com/wailsapp/wails/v2/cmd/wails@latest
REM - WebView2 Runtime Win10 1803+ 自带,否则 https://aka.ms/webview2 REM - WebView2 Runtime Win10 1803+ ships it, else https://aka.ms/webview2
REM - MSVC build tools https://visualstudio.microsoft.com/downloads/ (Build Tools for Visual Studio, 勾选 "Desktop development with C++") REM - MSVC build tools https://visualstudio.microsoft.com/downloads/ (Build Tools, "Desktop development with C++")
REM REM
REM 注意: Wails v2 的 Windows build 需要 MSVC toolchain (cgo 编译 webview2 loader)。 REM Note: Wails v2 Windows build needs MSVC toolchain (cgo for webview2 loader).
REM 不能在 macOS / Linux 上 cross-compile 出 .exe。 REM Cannot cross-compile .exe from macOS / Linux.
REM REM
REM 产物: build\bin\dsh-launcher.exe REM Output: build\bin\dsh-launcher.exe
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
cd /d "%~dp0\.." cd /d "%~dp0\.."
echo ^>^> dsh-launcher · Windows build echo ^>^> dsh-launcher Windows build
echo. echo.
REM 1. 基础工具检查 REM 1. Tool check
where go >nul 2>nul where go >nul 2>nul
if errorlevel 1 ( if errorlevel 1 (
echo [X] go not installed echo [X] go not installed
@ -44,13 +44,13 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
REM 2. 检查 WINDOWS 环境 REM 2. Must run on Windows
if not "%OS%"=="Windows_NT" ( if not "%OS%"=="Windows_NT" (
echo [X] must run on Windows, current: %OS% echo [X] must run on Windows, current: %OS%
exit /b 1 exit /b 1
) )
REM 3. 同步前端依赖 REM 3. Sync frontend deps
echo ^>^> npm install echo ^>^> npm install
cd frontend cd frontend
call npm install call npm install
@ -60,7 +60,7 @@ if errorlevel 1 (
) )
cd .. cd ..
REM 4. 编译 REM 4. Build
echo. echo.
echo ^>^> wails build echo ^>^> wails build
wails build -clean -platform windows/amd64 wails build -clean -platform windows/amd64
@ -69,7 +69,7 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
REM 5. 校验产物 REM 5. Verify
set EXE=build\bin\dsh-launcher.exe set EXE=build\bin\dsh-launcher.exe
if not exist "%EXE%" ( if not exist "%EXE%" (
echo [X] %EXE% not found echo [X] %EXE% not found
@ -78,8 +78,6 @@ if not exist "%EXE%" (
echo. echo.
echo [OK] exe: %EXE% echo [OK] exe: %EXE%
echo size: %~z1 bytes
echo.
echo run: %EXE% echo run: %EXE%
echo build dir: build\bin\ echo build dir: build\bin\
echo. echo.

Loading…
Cancel
Save