++
This commit is contained in:
@@ -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":
|
||||
|
||||
+51
-14
@@ -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)
|
||||
<section v-if="showSession" class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">当前会话</span>
|
||||
<span class="panel-meta">{{ sessionMeta }}</span>
|
||||
<div class="panel-head-right">
|
||||
<span class="panel-meta">{{ sessionMeta }}</span>
|
||||
<button
|
||||
class="panel-action"
|
||||
:disabled="!status.url"
|
||||
@click="reopenBrowser"
|
||||
>
|
||||
打开页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session">
|
||||
<div class="session-cell">
|
||||
<div class="session-row">
|
||||
<span class="session-label">URL</span>
|
||||
<span class="session-value" :title="status.url">
|
||||
{{ status.url || '等待 URL…' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="session-cell">
|
||||
<div class="session-row">
|
||||
<span class="session-label">PID</span>
|
||||
<span class="session-value">{{ status.pid || '—' }}</span>
|
||||
</div>
|
||||
<div class="session-cell">
|
||||
<span class="session-label">启动于</span>
|
||||
<span class="session-label session-label-2">启动于</span>
|
||||
<span class="session-value">{{ status.startedAt || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
Vendored
+2
@@ -12,6 +12,8 @@ export function HideWindow():Promise<void>;
|
||||
|
||||
export function InstallDsh():Promise<main.InstallResult>;
|
||||
|
||||
export function OpenURL(arg1:string):Promise<void>;
|
||||
|
||||
export function ShowWindow():Promise<void>;
|
||||
|
||||
export function Start():Promise<main.StartResult>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+16
-18
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user