perf(installer): 消除全部 UI 冻结 + Write-Host 清零

## P0: 子进程 UI 阻塞 → 非阻塞轮询
- 新增 Wait-ProcessWithUI: 替代所有 WaitForExit(), 每 200ms
  泵 DoEvents(), 进度窗口保持响应, 显示等待计时
- 新增 Run-CmdWithUI: 替代 & cmd 2>&1 | ForEach-Object 模式,
  所有子进程输出走临时文件→日志, 不阻塞 UI

## P1: Phase 3 git clone/pull 无超时 → 带超时 + UI 泵
- 9 处 git 调用全部改为 Run-CmdWithUI (120s/180s 超时)
- 消除 Push-Location/Pop-Location, 改用 git -C <dir>

## P1: Phase 1 winget/npm 阻塞 → 带超时 + UI 泵
- winget install: 5 分钟超时 + 进度状态显示
- npm i -g: 2-3 分钟超时 + 进度状态显示
- Claude Code npm install: 3 分钟超时

## P2: Write-Host 归零
- 27 处非注释 Write-Host 全部替换为 Bw-Log + Update-Progress-SubStatus
- EXE 在 PS2EXE -NoOutput 下不再有任何静默丢失的输出

验证: 7/7 补丁字符串确认编译; Write-Host 仅剩 1 处 (注释内)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
bookworm 2026-04-10 00:54:28 +08:00
parent 1e8a7250c2
commit 7d60911f65

View File

@ -146,6 +146,77 @@ function Close-ProgressForm {
function Test-Cmd($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) }
# ─── 非阻塞子进程执行 (解决 PS2EXE UI 冻结) ───────────
# 所有耗时子进程都必须经过这两个函数, 保持 GUI 消息泵活跃
function Wait-ProcessWithUI {
<# 替代 System.Diagnostics.Process.WaitForExit(N)
在等待期间每 200ms 泵一次 DoEvents, 防止 "(未响应)" #>
param(
[System.Diagnostics.Process]$proc,
[int]$timeoutMs = 60000,
[string]$label = ""
)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while (-not $proc.HasExited -and $sw.ElapsedMilliseconds -lt $timeoutMs) {
[System.Windows.Forms.Application]::DoEvents()
# 每 5 秒更新一次副状态, 显示等待耗时
if ($label -and ($sw.ElapsedMilliseconds % 5000) -lt 250) {
$elapsed = [int]($sw.ElapsedMilliseconds / 1000)
Update-Progress-SubStatus "$label ($($elapsed)s)"
}
Start-Sleep -Milliseconds 200
}
if (-not $proc.HasExited) {
try { $proc.Kill() } catch {}
Bw-Log "WARN" "子进程超时 ($timeoutMs ms): $label"
return $false
}
return $true
}
function Run-CmdWithUI {
<# 替代 & cmd args 2>&1 | ForEach-Object { Write-Host }
将阻塞调用转为 Start-Process + Wait-ProcessWithUI #>
param(
[string]$exe,
[string[]]$arguments,
[string]$label = "",
[int]$timeoutMs = 180000, # 默认 3 分钟
[switch]$captureOutput # 返回 stdout 内容
)
Bw-Log "CMD" "$exe $($arguments -join ' ')"
Update-Progress-SubStatus $label
$outFile = Join-Path $env:TEMP "bw-cmd-out-$(Get-Random).tmp"
$errFile = Join-Path $env:TEMP "bw-cmd-err-$(Get-Random).tmp"
try {
$proc = Start-Process -FilePath $exe -ArgumentList $arguments `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outFile `
-RedirectStandardError $errFile
$ok = Wait-ProcessWithUI $proc $timeoutMs $label
$exitCode = if ($proc.HasExited) { $proc.ExitCode } else { -1 }
# 日志记录 stdout/stderr (不超过 20 行)
if (Test-Path $outFile) {
$out = Get-Content $outFile -TotalCount 20 -ErrorAction SilentlyContinue
if ($out) { $out | ForEach-Object { Bw-Log "OUT" $_ } }
}
if (Test-Path $errFile) {
$err = Get-Content $errFile -TotalCount 10 -ErrorAction SilentlyContinue
if ($err) { $err | ForEach-Object { Bw-Log "ERR" $_ } }
}
if ($captureOutput -and (Test-Path $outFile)) {
return @{ OK = ($ok -and $exitCode -eq 0); Output = (Get-Content $outFile -Raw -ErrorAction SilentlyContinue); ExitCode = $exitCode }
}
return @{ OK = ($ok -and $exitCode -eq 0); ExitCode = $exitCode }
} finally {
Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
# ─── GUI 对话框 ─────────────────────────────────────
function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = "Information") {
@ -407,9 +478,7 @@ foreach ($dep in $deps) {
if ($dep.WingetId -and $hasWinget) {
try {
$output = winget install $dep.WingetId --accept-source-agreements --accept-package-agreements 2>&1
$output | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
# 刷新 PATH
$r = Run-CmdWithUI "winget" @("install", $dep.WingetId, "--accept-source-agreements", "--accept-package-agreements") "安装 $($dep.Name)" 300000
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
if (Test-Cmd $dep.Cmd) {
Log-OK "$($dep.Name) 安装成功"
@ -423,8 +492,7 @@ foreach ($dep in $deps) {
}
elseif ($dep.NpmPkg -and (Test-Cmd "npm")) {
try {
Write-Host " npm i -g $($dep.NpmPkg) ..." -ForegroundColor Gray
npm i -g $dep.NpmPkg 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
$r = Run-CmdWithUI "npm" @("i", "-g", $dep.NpmPkg) "npm 安装 $($dep.Name)" 120000
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
if (Test-Cmd $dep.Cmd) {
Log-OK "$($dep.Name) 安装成功"
@ -442,7 +510,7 @@ foreach ($dep in $deps) {
# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查
if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) {
Log-Info "安装 Claude Code..."
npm i -g @anthropic-ai/claude-code 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
$r = Run-CmdWithUI "npm" @("i", "-g", "@anthropic-ai/claude-code") "安装 Claude Code (首次约 2 分钟)" 180000
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
if (Test-Cmd "claude") { Log-OK "Claude Code 安装成功" } else { Log-Fail "Claude Code 安装失败" }
}
@ -638,7 +706,6 @@ if (-not $proxyFound) {
Log-OK "NO_PROXY: bww.letcareme.com, code.letcareme.com"
# 连通性测试
Write-Host ""
Log-Info "测试网络连通性..."
$netTests = @(
@ -677,33 +744,24 @@ Log-Phase 3 "同步 Bookworm 配置"
# 配置 git credential helper
git config --global credential.helper store 2>$null
# 克隆/更新 config 仓库 (.claude/)
# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结
if (Test-Path (Join-Path $ClaudeDir ".git")) {
Log-Info "配置仓库已存在, 更新中..."
Push-Location $ClaudeDir
try {
$stash = git stash 2>&1
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($stash -notmatch 'No local changes') { git stash pop 2>&1 | Out-Null }
Log-OK "配置仓库已更新"
Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash") "git stash" 15000 | Out-Null
$r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase") "同步配置仓库" 120000
if ($r.OK) { Log-OK "配置仓库已更新" } else { Log-Warn "git pull 失败, 使用本地版本" }
Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash", "pop") "git stash pop" 15000 | Out-Null
} catch { Log-Warn "git pull 失败, 使用本地版本" }
finally { Pop-Location }
}
elseif (Test-Path $ClaudeDir) {
# 已有 .claude 但非 git — 备份后克隆
Log-Info "备份现有 .claude/ 并克隆..."
if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force }
Rename-Item $ClaudeDir $BackupDir
# 可能需要 Gitea 凭证
$cred = Show-GiteaCredentialDialog
if ($cred) {
$credUrl = $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@"
git clone --depth 1 $credUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
} else {
git clone --depth 1 $GitUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
$cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功 (旧目录已备份)"
} else {
@ -714,16 +772,10 @@ elseif (Test-Path $ClaudeDir) {
}
}
else {
# 全新安装
Log-Info "首次安装, 克隆配置仓库..."
$cred = Show-GiteaCredentialDialog
if ($cred) {
$credUrl = $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@"
git clone --depth 1 $credUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
} else {
git clone --depth 1 $GitUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
$cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功"
} else {
@ -743,21 +795,15 @@ foreach ($d in $dirs) {
# ─── 克隆/更新 bookworm-boot (含 crypto-helper.js + secrets-*.enc + install.ps1) ───
if (Test-Path (Join-Path $BootDir ".git")) {
Log-Info "boot 仓库已存在, 更新中..."
Push-Location $BootDir
try {
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Log-OK "boot 仓库已更新"
$r = Run-CmdWithUI "git" @("-C", $BootDir, "pull", "--rebase") "同步 boot 仓库" 120000
if ($r.OK) { Log-OK "boot 仓库已更新" } else { Log-Warn "boot 仓库更新失败, 使用本地版本" }
} catch { Log-Warn "boot 仓库更新失败, 使用本地版本" }
finally { Pop-Location }
} else {
Log-Info "克隆 boot 仓库 (含解密工具与凭证)..."
if (-not $cred) { $cred = Show-GiteaCredentialDialog }
if ($cred) {
$bootCredUrl = $BootUrl -replace '://', "://$($cred.User):$($cred.Pass)@"
git clone --depth 1 $bootCredUrl $BootDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
} else {
git clone --depth 1 $BootUrl $BootDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
$bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $BootUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库" 180000
if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) {
Log-Fail "boot 仓库克隆失败 (crypto-helper.js 缺失)"
Show-MsgBox "boot 仓库克隆失败。`n请检查网络和 Gitea 凭证。" "克隆失败" "OK" "Error"
@ -945,16 +991,13 @@ foreach ($c in $checks) {
}
# ── 6b: API 凭证检查 ──
Write-Host ""
Log-Info "API 凭证检查..."
if ($env:ANTHROPIC_API_KEY) { Log-OK "ANTHROPIC_API_KEY 已配置" } else { Log-Fail "ANTHROPIC_API_KEY 未配置" }
if ($env:ANTHROPIC_BASE_URL) { Log-OK "ANTHROPIC_BASE_URL 已配置" } else { Log-Warn "ANTHROPIC_BASE_URL 未配置 (将使用默认)" }
# ── 6c: MCP npx 包预缓存 ──
Write-Host ""
# ── 6c: MCP npx 包预缓存 (非阻塞 UI) ──
Log-Info "MCP 预安装 (npx 包预缓存)..."
# Tier 1: 纯 npx, 无需 Key
$npxPackages = @(
@{ Name = "context7"; Pkg = "@upstash/context7-mcp@2.1.1" }
@{ Name = "sequential-thinking"; Pkg = "@modelcontextprotocol/server-sequential-thinking@2025.12.18" }
@ -963,7 +1006,6 @@ $npxPackages = @(
@{ Name = "notebooklm"; Pkg = "notebooklm-mcp@latest" }
@{ Name = "cloudflare-docs"; Pkg = "mcp-remote" }
@{ Name = "chrome-devtools"; Pkg = "chrome-devtools-mcp@0.18.1" }
# Tier 2: npx + Key
@{ Name = "github"; Pkg = "@modelcontextprotocol/server-github" }
@{ Name = "slack"; Pkg = "@modelcontextprotocol/server-slack" }
@{ Name = "firecrawl"; Pkg = "firecrawl-mcp" }
@ -974,35 +1016,44 @@ $npxPackages = @(
$mcpOK = 0; $mcpFail = 0
foreach ($mcp in $npxPackages) {
$idx = $mcpOK + $mcpFail + 1
Write-Host " [$idx/$($npxPackages.Count)] $($mcp.Name) " -NoNewline -ForegroundColor Gray
$label = "[$idx/$($npxPackages.Count)] $($mcp.Name)"
Update-Progress-SubStatus "$label ..."
try {
# npm cache add 只下载到缓存, 不执行 (MCP 是 stdio 长驻进程, 不能用 --help)
$proc = Start-Process npm.cmd -ArgumentList "cache", "add", $mcp.Pkg -NoNewWindow -PassThru -RedirectStandardOutput "NUL" -RedirectStandardError "NUL"
$exited = $proc.WaitForExit(60000) # 60 秒超时
if (-not $exited) { $proc.Kill(); throw "timeout" }
if ($proc.ExitCode -eq 0) {
Write-Host "[cached]" -ForegroundColor Green
$outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp"
$errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp"
$proc = Start-Process npm.cmd -ArgumentList "cache", "add", $mcp.Pkg `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outTmp `
-RedirectStandardError $errTmp
$ok = Wait-ProcessWithUI $proc 60000 $label
if ($ok -and $proc.ExitCode -eq 0) {
Bw-Log "OK" "$label cached"
$mcpOK++
} else { throw "exit $($proc.ExitCode)" }
} else { throw "exit=$($proc.ExitCode)" }
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
} catch {
Write-Host "[FAIL: $_]" -ForegroundColor Red
Bw-Log "WARN" "$label failed: $_"
$mcpFail++
}
}
Log-OK "npx 预缓存: $mcpOK 成功, $mcpFail 失败 (共 $($npxPackages.Count))"
Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功"
# ── 6d: Playwright 浏览器安装 ──
Write-Host ""
# ── 6d: Playwright 浏览器安装 (非阻塞 UI) ──
Log-Info "Playwright 浏览器安装..."
try {
$pwBrowserPath = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright"
if (Test-Path (Join-Path $pwBrowserPath "chromium-*")) {
Log-OK "Playwright Chromium 已存在"
} else {
Write-Host " 下载 Chromium (首次约 150MB, 最长等 5 分钟)..." -ForegroundColor Gray
$pwProc = Start-Process npx.cmd -ArgumentList "-y", "playwright", "install", "chromium" -NoNewWindow -PassThru
$pwExited = $pwProc.WaitForExit(300000) # 5 分钟超时
if (-not $pwExited) { $pwProc.Kill(); Log-Warn "Playwright 下载超时, 跳过" }
$outTmp = Join-Path $env:TEMP "bw-playwright.tmp"
$errTmp = Join-Path $env:TEMP "bw-playwright-err.tmp"
$pwProc = Start-Process npx.cmd -ArgumentList "-y", "playwright", "install", "chromium" `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outTmp `
-RedirectStandardError $errTmp
$pwOk = Wait-ProcessWithUI $pwProc 300000 "下载 Chromium (~150MB)"
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
if (-not $pwOk) { Log-Warn "Playwright 下载超时, 跳过" }
elseif (Test-Path (Join-Path $pwBrowserPath "chromium-*")) {
Log-OK "Playwright Chromium 安装成功"
} else {
@ -1011,31 +1062,31 @@ try {
}
} catch { Log-Warn "Playwright 浏览器安装失败: $_ (不影响核心功能)" }
# ── 6e: Python MCP (uvx) 验证 ──
Write-Host ""
# ── 6e: Python MCP (uvx) 验证 (非阻塞 UI) ──
if (Test-Cmd "uvx") {
Log-Info "Python MCP 验证 (uvx)..."
$uvxPackages = @(
@{ Name = "windows-mcp"; Args = @("--python", "3.13", "windows-mcp") }
@{ Name = "atlassian"; Args = @("mcp-atlassian") }
)
foreach ($pkg in $uvxPackages) {
Write-Host " $($pkg.Name) " -NoNewline -ForegroundColor Gray
try {
# uv tool install 只下载不启动 (MCP 是 stdio 长驻进程, --help 会挂起)
$outTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name).tmp"
$errTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name)-err.tmp"
$installArgs = @("tool", "install") + $pkg.Args
$proc = Start-Process uv -ArgumentList $installArgs -NoNewWindow -PassThru -RedirectStandardOutput "NUL" -RedirectStandardError "NUL"
$exited = $proc.WaitForExit(90000) # 90 秒超时 (Python 包较大)
if (-not $exited) { $proc.Kill(); throw "timeout" }
Write-Host "[ready]" -ForegroundColor Green
$proc = Start-Process uv -ArgumentList $installArgs `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outTmp `
-RedirectStandardError $errTmp
$ok = Wait-ProcessWithUI $proc 90000 "uvx $($pkg.Name)"
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
if ($ok) { Bw-Log "OK" "uvx $($pkg.Name) ready" }
} catch {
Write-Host "[skip: $_]" -ForegroundColor Yellow
Bw-Log "WARN" "uvx $($pkg.Name): $_"
}
}
} else {
Log-Warn "uvx 不可用, 跳过 Python MCP (windows-mcp, atlassian)"
Bw-Log "INFO" "uvx 不可用, 跳过 Python MCP"
}
# ── 6f: 可选 API Key 提示 ──
@ -1049,9 +1100,7 @@ $optional = @(
)
$missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") }
if ($missingOpt.Count -gt 0) {
Write-Host ""
Write-Host " 可选 MCP (Key 未配置, 不影响核心功能):" -ForegroundColor DarkGray
foreach ($m in $missingOpt) { Write-Host " [-] $($m.Name) ($($m.Key))" -ForegroundColor DarkGray }
foreach ($m in $missingOpt) { Bw-Log "INFO" "可选 Key 未配置: $($m.Name) ($($m.Key))" }
}
# ========================================================================