1. $TOTAL_PHASES 7→8 (Phase 8 OTA 后 GUI 进度条溢出) 2. OTA config.json 只在不存在时写入 (保留 lastCheck 冷却) 3. remote URL 比对从硬编码改为动态 $expectedBase Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2806 lines
142 KiB
PowerShell
2806 lines
142 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Bookworm Portable - 全自动一键安装器
|
||
.DESCRIPTION
|
||
全新电脑从零到 Bookworm 完全就绪,最大程度自动化。
|
||
8 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 环境加固 → OTA 基础设施 → 启动
|
||
需要人工输入时弹出 GUI 对话框。
|
||
.USAGE
|
||
.\auto-setup.ps1
|
||
.\auto-setup.ps1 -SkipLaunch # 安装但不启动
|
||
#>
|
||
param(
|
||
[switch]$SkipLaunch,
|
||
# DryRun: 只跑指定 Phase 做验证, 不落任何副作用. 空值 = 正常全流程安装
|
||
# 值: env (Phase 1 环境检测) / deps (Phase 2 依赖安装) / net (Phase 3 网络诊断)
|
||
# / repo (Phase 3 git clone) / creds (Phase 4 凭证) / license (Phase 4.5)
|
||
[string]$DryRun = ""
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
# ─── v3.0.7: 顶层 trap 防静默闪退 ───────────────────
|
||
# 场景: PS2EXE 包装的 .exe 在 ErrorActionPreference=Stop 模式下, 任何未处理
|
||
# cmdlet/方法异常直接结束进程. 无 trap 则用户看到"安装到一半窗口消失",
|
||
# 无错误信息可报障. 此 trap 把异常写崩溃日志 + 弹 GUI 让用户截图.
|
||
$global:BWCrashLog = Join-Path $env:TEMP "bw-crash.log"
|
||
trap {
|
||
$e = $_
|
||
$msg = "[$([DateTime]::Now.ToString('s'))] UNHANDLED EXCEPTION`n"
|
||
$msg += "Message : $($e.Exception.Message)`n"
|
||
$msg += "Type : $($e.Exception.GetType().FullName)`n"
|
||
$msg += "Location : $($e.InvocationInfo.ScriptName):$($e.InvocationInfo.ScriptLineNumber):$($e.InvocationInfo.OffsetInLine)`n"
|
||
$msg += "Line : $($e.InvocationInfo.Line.Trim())`n"
|
||
$msg += "Stack:`n$($e.ScriptStackTrace)`n"
|
||
$msg += "---`n"
|
||
try { $msg | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {}
|
||
# 尝试弹 GUI (Add-Type 可能失败, try 包裹)
|
||
try {
|
||
Add-Type -AssemblyName System.Windows.Forms -EA Stop
|
||
$shortMsg = "Bookworm 安装遇到未处理异常, 进程将退出.`n`n"
|
||
$shortMsg += "错误: $($e.Exception.Message)`n"
|
||
$shortMsg += "位置: $(Split-Path $e.InvocationInfo.ScriptName -Leaf):$($e.InvocationInfo.ScriptLineNumber)`n`n"
|
||
$shortMsg += "详细日志已写入:`n $global:BWCrashLog`n`n"
|
||
$shortMsg += "请把该日志文件截图发给管理员报障."
|
||
[System.Windows.Forms.MessageBox]::Show($shortMsg, "Bookworm 安装异常 v$BWVersion", 'OK', 'Error') | Out-Null
|
||
} catch {}
|
||
exit 2
|
||
}
|
||
|
||
# ─── 版本号 (每次更新递增, build.ps1 自动读取) ──────
|
||
$BWVersion = "3.2.0" # feat: Phase 8 OTA 自动更新基础设施 (pubkey/DPAPI凭证/bw-ota.ps1)
|
||
|
||
# DryRun 模式日志标记
|
||
if ($DryRun) { $global:BWDryRun = $DryRun } else { $global:BWDryRun = $null }
|
||
function Is-DryRun($phase) { return ($global:BWDryRun -eq $phase -or $global:BWDryRun -eq "all") }
|
||
|
||
# ─── B4: 单实例保护 (防止双击两次导致竞态) ─────────
|
||
$mutexCreated = $false
|
||
$global:BWMutex = [System.Threading.Mutex]::new($true, "Global\BookwormPortableSetup", [ref]$mutexCreated)
|
||
if (-not $mutexCreated) {
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
[System.Windows.Forms.MessageBox]::Show("Bookworm 安装器已在运行中。`n请勿重复启动。", "提示", "OK", "Information") | Out-Null
|
||
exit 0
|
||
}
|
||
|
||
# ─── 路径定义 ────────────────────────────────────────
|
||
# PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule
|
||
$ScriptDir = if ($PSScriptRoot) {
|
||
$PSScriptRoot
|
||
} elseif ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName -match '\.exe$') {
|
||
Split-Path -Parent ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName)
|
||
} elseif ($MyInvocation.MyCommand.Path) {
|
||
Split-Path -Parent $MyInvocation.MyCommand.Path
|
||
} else {
|
||
$PWD.Path
|
||
}
|
||
$ClaudeDir = Join-Path $env:USERPROFILE ".claude"
|
||
$BackupDir = Join-Path $env:USERPROFILE ".claude.bw-backup"
|
||
$GitUrl = "https://code.letcareme.com/bookworm/bookworm-smart-assistant.git" # v3.2: 统一配置仓库 (public)
|
||
$BootUrl = "https://code.letcareme.com/bookworm/bookworm-boot.git"
|
||
$BootDir = Join-Path $ScriptDir "bookworm-boot"
|
||
$SecretsEnc = Join-Path $BootDir "secrets.enc"
|
||
$TOTAL_PHASES = 8
|
||
|
||
# ─── GUI 初始化 ─────────────────────────────────────
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
Add-Type -AssemblyName System.Drawing
|
||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||
|
||
# ─── 日志 + 进度 (PS2EXE -NoConsole -NoOutput 模式: console 全静默) ──
|
||
# 所有 Log-X 输出走文件 + GUI 进度窗口 (避免被 PS2EXE 弹窗化)
|
||
$BWLogFile = Join-Path $env:TEMP "bookworm-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||
function Bw-Log($level, $msg) {
|
||
try { Add-Content -Path $BWLogFile -Value "[$(Get-Date -Format 'HH:mm:ss')] [$level] $msg" -Encoding utf8 } catch {}
|
||
}
|
||
function Log-OK($msg) { Bw-Log "OK" $msg; Update-Progress-SubStatus "$msg" }
|
||
function Log-Info($msg) { Bw-Log "INFO" $msg; Update-Progress-SubStatus "$msg" }
|
||
function Log-Warn($msg) { Bw-Log "WARN" $msg }
|
||
function Log-Fail($msg) { Bw-Log "FAIL" $msg }
|
||
function Log-Phase($n, $title) {
|
||
Bw-Log "PHASE" "[$n/$TOTAL_PHASES] $title"
|
||
Update-Progress $n $title
|
||
}
|
||
|
||
# ─── GUI 进度窗口 (常驻顶部, 替代 console 输出) ────
|
||
$global:BWProgressForm = $null
|
||
$global:BWPhaseLabel = $null
|
||
$global:BWStatusLabel = $null
|
||
$global:BWProgressBar = $null
|
||
|
||
function Show-ProgressForm {
|
||
# 统一品牌色
|
||
$brandBlue = [System.Drawing.Color]::FromArgb(88, 101, 242) # Bookworm 蓝紫
|
||
$brandDark = [System.Drawing.Color]::FromArgb(30, 31, 46)
|
||
$uiFont = "Segoe UI"
|
||
|
||
$global:BWProgressForm = New-Object System.Windows.Forms.Form
|
||
$global:BWProgressForm.Text = "Bookworm Portable Setup v$BWVersion"
|
||
$global:BWProgressForm.Size = New-Object System.Drawing.Size(520, 230)
|
||
$global:BWProgressForm.StartPosition = "CenterScreen"
|
||
$global:BWProgressForm.FormBorderStyle = "FixedDialog"
|
||
$global:BWProgressForm.MaximizeBox = $false
|
||
$global:BWProgressForm.MinimizeBox = $false
|
||
$global:BWProgressForm.TopMost = $false # P2: 不遮挡其他窗口
|
||
$global:BWProgressForm.ControlBox = $true # P0 F1: 允许关闭 (触发确认)
|
||
$global:BWProgressForm.BackColor = [System.Drawing.Color]::White
|
||
|
||
# X 按钮关闭时弹确认
|
||
$global:BWProgressForm.Add_FormClosing({
|
||
param($s, $e)
|
||
if (-not $global:BWInstallDone) {
|
||
$r = [System.Windows.Forms.MessageBox]::Show(
|
||
"安装尚未完成。`n确定要取消安装吗?",
|
||
"取消安装", "YesNo", "Warning")
|
||
if ($r -eq "No") { $e.Cancel = $true; return }
|
||
Bw-Log "ABORT" "用户手动取消安装"
|
||
}
|
||
})
|
||
|
||
$titleLabel = New-Object System.Windows.Forms.Label
|
||
$titleLabel.Location = New-Object System.Drawing.Point(20, 16)
|
||
$titleLabel.Size = New-Object System.Drawing.Size(480, 26)
|
||
$titleLabel.Text = "Bookworm 智能助手 — 自动安装中"
|
||
$titleLabel.Font = New-Object System.Drawing.Font($uiFont, 12, [System.Drawing.FontStyle]::Bold)
|
||
$titleLabel.ForeColor = $brandDark
|
||
$global:BWProgressForm.Controls.Add($titleLabel)
|
||
|
||
$global:BWPhaseLabel = New-Object System.Windows.Forms.Label
|
||
$global:BWPhaseLabel.Location = New-Object System.Drawing.Point(20, 50)
|
||
$global:BWPhaseLabel.Size = New-Object System.Drawing.Size(480, 22)
|
||
$global:BWPhaseLabel.Text = "[0/$TOTAL_PHASES] 初始化..."
|
||
$global:BWPhaseLabel.Font = New-Object System.Drawing.Font($uiFont, 10)
|
||
$global:BWPhaseLabel.ForeColor = $brandBlue
|
||
$global:BWProgressForm.Controls.Add($global:BWPhaseLabel)
|
||
|
||
$global:BWStatusLabel = New-Object System.Windows.Forms.Label
|
||
$global:BWStatusLabel.Location = New-Object System.Drawing.Point(20, 78)
|
||
$global:BWStatusLabel.Size = New-Object System.Drawing.Size(480, 22)
|
||
$global:BWStatusLabel.Text = ""
|
||
$global:BWStatusLabel.Font = New-Object System.Drawing.Font($uiFont, 9)
|
||
$global:BWStatusLabel.ForeColor = [System.Drawing.Color]::FromArgb(120, 120, 140)
|
||
$global:BWProgressForm.Controls.Add($global:BWStatusLabel)
|
||
|
||
$global:BWProgressBar = New-Object System.Windows.Forms.ProgressBar
|
||
$global:BWProgressBar.Location = New-Object System.Drawing.Point(20, 112)
|
||
$global:BWProgressBar.Size = New-Object System.Drawing.Size(480, 20)
|
||
$global:BWProgressBar.Minimum = 0
|
||
$global:BWProgressBar.Maximum = $TOTAL_PHASES
|
||
$global:BWProgressBar.Value = 0
|
||
$global:BWProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Continuous # P3: 平滑
|
||
$global:BWProgressForm.Controls.Add($global:BWProgressBar)
|
||
|
||
$global:BWElapsedLabel = New-Object System.Windows.Forms.Label
|
||
$global:BWElapsedLabel.Location = New-Object System.Drawing.Point(400, 136)
|
||
$global:BWElapsedLabel.Size = New-Object System.Drawing.Size(100, 18)
|
||
$global:BWElapsedLabel.Text = ""
|
||
$global:BWElapsedLabel.Font = New-Object System.Drawing.Font($uiFont, 8)
|
||
$global:BWElapsedLabel.ForeColor = [System.Drawing.Color]::Silver
|
||
$global:BWElapsedLabel.TextAlign = [System.Drawing.ContentAlignment]::TopRight
|
||
$global:BWProgressForm.Controls.Add($global:BWElapsedLabel)
|
||
|
||
$hint = New-Object System.Windows.Forms.Label
|
||
$hint.Location = New-Object System.Drawing.Point(20, 136)
|
||
$hint.Size = New-Object System.Drawing.Size(380, 32)
|
||
$hint.Text = "首次安装约 5-10 分钟 (依赖下载)`n关闭窗口可取消安装"
|
||
$hint.Font = New-Object System.Drawing.Font($uiFont, 8)
|
||
$hint.ForeColor = [System.Drawing.Color]::Silver
|
||
$global:BWProgressForm.Controls.Add($hint)
|
||
|
||
$global:BWProgressForm.Show() | Out-Null
|
||
$global:BWProgressForm.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
}
|
||
|
||
# 全局安装完成标记 (Close-ProgressForm 前设为 $true, 避免 X 按钮弹确认)
|
||
$global:BWInstallDone = $false
|
||
# 全局计时器
|
||
$global:BWStartTime = [System.Diagnostics.Stopwatch]::StartNew()
|
||
|
||
function Update-Progress($phase, $title) {
|
||
if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) {
|
||
try {
|
||
$global:BWPhaseLabel.Text = "[$phase/$TOTAL_PHASES] $title"
|
||
$global:BWStatusLabel.Text = ""
|
||
$global:BWProgressBar.Value = [Math]::Min($phase, $TOTAL_PHASES)
|
||
$global:BWProgressForm.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
function Update-Progress-SubStatus($msg) {
|
||
if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed -and $global:BWStatusLabel) {
|
||
try {
|
||
$shortMsg = if ($msg.Length -gt 70) { $msg.Substring(0, 67) + "..." } else { $msg }
|
||
$global:BWStatusLabel.Text = $shortMsg
|
||
# 刷新总耗时
|
||
if ($global:BWElapsedLabel -and $global:BWStartTime) {
|
||
$sec = [int]$global:BWStartTime.Elapsed.TotalSeconds
|
||
$global:BWElapsedLabel.Text = "$([int]($sec / 60))m $($sec % 60)s"
|
||
}
|
||
$global:BWStatusLabel.Refresh()
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
function Close-ProgressForm {
|
||
$global:BWInstallDone = $true # 关闭时不再弹 "取消安装?" 确认
|
||
if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) {
|
||
try { $global:BWProgressForm.Close(); $global:BWProgressForm.Dispose() } catch {}
|
||
}
|
||
}
|
||
|
||
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 内容
|
||
)
|
||
# W-01: Windows Start-Process 兼容 — npm/npx 无扩展名是 Unix shell 脚本
|
||
if ($exe -in @("npm", "npx") -and -not $exe.EndsWith(".cmd")) {
|
||
$exe = "$exe.cmd"
|
||
}
|
||
|
||
# B1: 脱敏日志 (去除 URL 内嵌凭证 user:pass@)
|
||
$sanitizedArgs = ($arguments -join ' ') -replace '://[^@]+@', '://***@'
|
||
Bw-Log "CMD" "$exe $sanitizedArgs"
|
||
Update-Progress-SubStatus $label
|
||
|
||
# V-04: 用 GetTempFileName (原子创建+加密随机) 替代 Get-Random
|
||
$outFile = [System.IO.Path]::GetTempFileName()
|
||
$errFile = [System.IO.Path]::GetTempFileName()
|
||
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") {
|
||
[System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon)
|
||
}
|
||
|
||
function Parse-AuthCode-GUI {
|
||
param([string]$code)
|
||
$code = $code.Trim()
|
||
if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null }
|
||
$expiryStr = $Matches[1]
|
||
$token = $Matches[2].ToLower()
|
||
$today = (Get-Date).ToString("yyyyMMdd")
|
||
if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' }
|
||
return $token
|
||
}
|
||
|
||
function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) {
|
||
$form = New-Object System.Windows.Forms.Form
|
||
$form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)"
|
||
$form.Size = New-Object System.Drawing.Size(480, 240)
|
||
$form.StartPosition = "CenterScreen"
|
||
$form.FormBorderStyle = "FixedDialog"
|
||
$form.MaximizeBox = $false
|
||
$form.MinimizeBox = $false
|
||
$form.TopMost = $true
|
||
|
||
$label = New-Object System.Windows.Forms.Label
|
||
$label.Location = New-Object System.Drawing.Point(20, 18)
|
||
$label.Size = New-Object System.Drawing.Size(440, 36)
|
||
$label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX"
|
||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||
$form.Controls.Add($label)
|
||
|
||
# 授权码可见 (用于粘贴验证), 不用 PasswordChar
|
||
$codeBox = New-Object System.Windows.Forms.TextBox
|
||
$codeBox.Location = New-Object System.Drawing.Point(20, 65)
|
||
$codeBox.Size = New-Object System.Drawing.Size(430, 30)
|
||
$codeBox.Font = New-Object System.Drawing.Font("Consolas", 11)
|
||
$codeBox.CharacterCasing = "Upper" # 自动转大写
|
||
$form.Controls.Add($codeBox)
|
||
|
||
$hint = New-Object System.Windows.Forms.Label
|
||
$hint.Location = New-Object System.Drawing.Point(20, 100)
|
||
$hint.Size = New-Object System.Drawing.Size(440, 20)
|
||
$hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)"
|
||
$hint.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
||
$hint.ForeColor = [System.Drawing.Color]::Gray
|
||
$form.Controls.Add($hint)
|
||
|
||
$btnOK = New-Object System.Windows.Forms.Button
|
||
$btnOK.Location = New-Object System.Drawing.Point(250, 145)
|
||
$btnOK.Size = New-Object System.Drawing.Size(90, 35)
|
||
$btnOK.Text = "验证"
|
||
$btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||
$form.AcceptButton = $btnOK
|
||
$form.Controls.Add($btnOK)
|
||
|
||
$btnCancel = New-Object System.Windows.Forms.Button
|
||
$btnCancel.Location = New-Object System.Drawing.Point(350, 145)
|
||
$btnCancel.Size = New-Object System.Drawing.Size(90, 35)
|
||
$btnCancel.Text = "取消安装"
|
||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||
$form.CancelButton = $btnCancel
|
||
$form.Controls.Add($btnCancel)
|
||
|
||
$form.Add_Shown({ $codeBox.Focus() })
|
||
$result = $form.ShowDialog()
|
||
|
||
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
||
return $codeBox.Text.Trim()
|
||
}
|
||
return $null
|
||
}
|
||
|
||
# ─── 中转站 API Key 输入对话框 + 验证 (v2.3 新增) ──
|
||
function Show-ApiKeyDialog($attempt = 1, $maxAttempts = 3, $existingKey = "") {
|
||
$form = New-Object System.Windows.Forms.Form
|
||
$form.Text = "Bookworm - 中转站 API Key ($attempt/$maxAttempts)"
|
||
$form.Size = New-Object System.Drawing.Size(520, 280)
|
||
$form.StartPosition = "CenterScreen"
|
||
$form.FormBorderStyle = "FixedDialog"
|
||
$form.MaximizeBox = $false
|
||
$form.TopMost = $true
|
||
$form.BackColor = [System.Drawing.Color]::White
|
||
|
||
$lblInfo = New-Object System.Windows.Forms.Label
|
||
$lblInfo.Location = New-Object System.Drawing.Point(20, 15)
|
||
$lblInfo.Size = New-Object System.Drawing.Size(470, 50)
|
||
$lblInfo.Text = "请粘贴你的中转站 API Key`n(bww.letcareme.com 注册后在后台获取, sk- 开头)"
|
||
$lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||
$form.Controls.Add($lblInfo)
|
||
|
||
$keyBox = New-Object System.Windows.Forms.TextBox
|
||
$keyBox.Location = New-Object System.Drawing.Point(20, 75)
|
||
$keyBox.Size = New-Object System.Drawing.Size(470, 30)
|
||
$keyBox.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||
$keyBox.Text = $existingKey
|
||
$keyBox.PasswordChar = '*'
|
||
$form.Controls.Add($keyBox)
|
||
|
||
$chkShow = New-Object System.Windows.Forms.CheckBox
|
||
$chkShow.Location = New-Object System.Drawing.Point(20, 110)
|
||
$chkShow.Size = New-Object System.Drawing.Size(150, 25)
|
||
$chkShow.Text = "显示 Key"
|
||
$chkShow.Add_CheckedChanged({ if ($chkShow.Checked) { $keyBox.PasswordChar = [char]0 } else { $keyBox.PasswordChar = '*' } })
|
||
$form.Controls.Add($chkShow)
|
||
|
||
$lblHint = New-Object System.Windows.Forms.Label
|
||
$lblHint.Location = New-Object System.Drawing.Point(20, 140)
|
||
$lblHint.Size = New-Object System.Drawing.Size(470, 40)
|
||
$lblHint.Text = "首次使用请先注册并充值:`nhttps://bww.letcareme.com"
|
||
$lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
||
$lblHint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130)
|
||
$form.Controls.Add($lblHint)
|
||
|
||
$btnOK = New-Object System.Windows.Forms.Button
|
||
$btnOK.Location = New-Object System.Drawing.Point(280, 195)
|
||
$btnOK.Size = New-Object System.Drawing.Size(100, 35)
|
||
$btnOK.Text = "验证并保存"
|
||
$btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||
$form.AcceptButton = $btnOK
|
||
$form.Controls.Add($btnOK)
|
||
|
||
$btnCancel = New-Object System.Windows.Forms.Button
|
||
$btnCancel.Location = New-Object System.Drawing.Point(390, 195)
|
||
$btnCancel.Size = New-Object System.Drawing.Size(100, 35)
|
||
$btnCancel.Text = "取消"
|
||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||
$form.CancelButton = $btnCancel
|
||
$form.Controls.Add($btnCancel)
|
||
|
||
$form.Add_Shown({ $keyBox.Focus() })
|
||
$result = $form.ShowDialog()
|
||
|
||
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
||
return $keyBox.Text.Trim()
|
||
}
|
||
return $null
|
||
}
|
||
|
||
# ─── Bookworm License Key 输入对话框 (v3.0 专用, 格式 BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) ──
|
||
function Show-LicenseKeyDialog($attempt = 1, $maxAttempts = 3) {
|
||
$form = New-Object System.Windows.Forms.Form
|
||
$form.Text = "Bookworm - License 激活 ($attempt/$maxAttempts)"
|
||
$form.Size = New-Object System.Drawing.Size(540, 270)
|
||
$form.StartPosition = "CenterScreen"
|
||
$form.FormBorderStyle = "FixedDialog"
|
||
$form.MaximizeBox = $false
|
||
$form.MinimizeBox = $false
|
||
$form.TopMost = $true
|
||
$form.BackColor = [System.Drawing.Color]::White
|
||
|
||
$lblInfo = New-Object System.Windows.Forms.Label
|
||
$lblInfo.Location = New-Object System.Drawing.Point(20, 15)
|
||
$lblInfo.Size = New-Object System.Drawing.Size(490, 46)
|
||
$lblInfo.Text = "请粘贴 Bookworm License Key`n格式: BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 段 4 位十六进制)"
|
||
$lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||
$form.Controls.Add($lblInfo)
|
||
|
||
$keyBox = New-Object System.Windows.Forms.TextBox
|
||
$keyBox.Location = New-Object System.Drawing.Point(20, 70)
|
||
$keyBox.Size = New-Object System.Drawing.Size(490, 30)
|
||
$keyBox.Font = New-Object System.Drawing.Font("Consolas", 11)
|
||
$keyBox.CharacterCasing = "Upper"
|
||
$keyBox.MaxLength = 34
|
||
$form.Controls.Add($keyBox)
|
||
|
||
$lblHint = New-Object System.Windows.Forms.Label
|
||
$lblHint.Location = New-Object System.Drawing.Point(20, 110)
|
||
$lblHint.Size = New-Object System.Drawing.Size(490, 60)
|
||
$lblHint.Text = "提示: 大写字母 + 数字, 中间 5 个短横线`n常见混淆: O→0 (零), I→1 (一), L→1 (一)`n没 Key? 联系管理员获取"
|
||
$lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
||
$lblHint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130)
|
||
$form.Controls.Add($lblHint)
|
||
|
||
$btnOK = New-Object System.Windows.Forms.Button
|
||
$btnOK.Location = New-Object System.Drawing.Point(300, 185)
|
||
$btnOK.Size = New-Object System.Drawing.Size(100, 35)
|
||
$btnOK.Text = "激活"
|
||
$btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||
$form.AcceptButton = $btnOK
|
||
$form.Controls.Add($btnOK)
|
||
|
||
$btnCancel = New-Object System.Windows.Forms.Button
|
||
$btnCancel.Location = New-Object System.Drawing.Point(410, 185)
|
||
$btnCancel.Size = New-Object System.Drawing.Size(100, 35)
|
||
$btnCancel.Text = "跳过"
|
||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||
$form.CancelButton = $btnCancel
|
||
$form.Controls.Add($btnCancel)
|
||
|
||
$form.Add_Shown({ $keyBox.Focus() })
|
||
$result = $form.ShowDialog()
|
||
|
||
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
||
return $keyBox.Text.Trim()
|
||
}
|
||
return $null
|
||
}
|
||
|
||
# 向中转站发一个最小请求验证 key 是否可用
|
||
function Test-ApiKey([string]$apiKey, [string]$baseUrl = "https://bww.letcareme.com") {
|
||
$script:LastValidatedModel = $null
|
||
$script:LastAuthFailCodes = @()
|
||
if (-not $apiKey -or $apiKey.Length -lt 10) { return $false }
|
||
# v3.0.4: 模型候选按中转站兼容性+套餐覆盖面排序
|
||
# sonnet-4-6 放首位 - 中转站基础套餐都有, opus-4-7 可能被低档套餐 403
|
||
# 去掉 haiku (多数中转站已移除), 新增 sonnet-4-5 兼容老套餐
|
||
$modelCandidates = @(
|
||
"claude-sonnet-4-6",
|
||
"claude-opus-4-7",
|
||
"claude-opus-4-6",
|
||
"claude-opus-4-6-thinking",
|
||
"claude-sonnet-4-6-thinking"
|
||
)
|
||
# 错误分类:
|
||
# $true = 认证通过 (200/400) — $script:LastValidatedModel 记录通过的 model
|
||
# $false = 认证失败 (全部候选都返 401/403) 明确 Key 无效或权限不足
|
||
# $null = 网络/中转站故障 (5xx/404/timeout), 非 Key 问题, 外层应放行
|
||
$authFailModels = @()
|
||
$hadNetworkError = $false
|
||
foreach ($model in $modelCandidates) {
|
||
try {
|
||
$body = '{"model":"' + $model + '","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}'
|
||
$req = [System.Net.WebRequest]::Create("$baseUrl/v1/messages")
|
||
$req.Method = "POST"
|
||
$req.ContentType = "application/json"
|
||
$req.Headers["x-api-key"] = $apiKey
|
||
$req.Headers["anthropic-version"] = "2023-06-01"
|
||
# 中转站 bww.letcareme.com 对境外 IP 返 503, 强制直连绕代理
|
||
$req.Proxy = [System.Net.WebProxy]::new()
|
||
$req.Timeout = 10000
|
||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
|
||
$req.ContentLength = $bytes.Length
|
||
$stream = $req.GetRequestStream(); $stream.Write($bytes, 0, $bytes.Length); $stream.Close()
|
||
$resp = $req.GetResponse(); $resp.Close()
|
||
$script:LastValidatedModel = $model
|
||
return $true # 200 = Key 有效
|
||
} catch [System.Net.WebException] {
|
||
$lastStatus = 0
|
||
try { $lastStatus = [int]$_.Exception.Response.StatusCode } catch {}
|
||
# 401/403 = 这个 model 维度的认证/权限失败, 继续试其他 model (v3.0.4 核心改动)
|
||
# 只有全部候选都 401/403 才认 Key 无效; 任一 200/400 就算 Key 有效
|
||
if ($lastStatus -eq 401 -or $lastStatus -eq 403) {
|
||
$authFailModels += "$model=$lastStatus"
|
||
continue
|
||
}
|
||
# 400 = 请求体问题 (模型名等), Key 本身通过
|
||
if ($lastStatus -eq 400) { $script:LastValidatedModel = $model; return $true }
|
||
# 5xx/404/0 = 网络或中转站故障, 不能归咎 Key
|
||
$hadNetworkError = $true
|
||
continue
|
||
} catch { $hadNetworkError = $true; continue }
|
||
}
|
||
$script:LastAuthFailCodes = $authFailModels
|
||
# 全部候选都遇网络故障 → 返回 $null (外层应: 接受 Key, 首次真实请求时再判)
|
||
if ($hadNetworkError -and $authFailModels.Count -eq 0) { return $null }
|
||
return $false
|
||
}
|
||
|
||
function Show-GiteaCredentialDialog {
|
||
$form = New-Object System.Windows.Forms.Form
|
||
$form.Text = "Bookworm - Gitea 登录"
|
||
$form.Size = New-Object System.Drawing.Size(420, 300)
|
||
$form.StartPosition = "CenterScreen"
|
||
$form.FormBorderStyle = "FixedDialog"
|
||
$form.MaximizeBox = $false
|
||
$form.TopMost = $true
|
||
$form.BackColor = [System.Drawing.Color]::White
|
||
|
||
$lblInfo = New-Object System.Windows.Forms.Label
|
||
$lblInfo.Location = New-Object System.Drawing.Point(20, 15)
|
||
$lblInfo.Size = New-Object System.Drawing.Size(360, 40)
|
||
$lblInfo.Text = "输入 Gitea 账号 (code.letcareme.com)`n用于下载 Bookworm 配置文件,由管理员提供。"
|
||
$lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||
$form.Controls.Add($lblInfo)
|
||
|
||
$lblUser = New-Object System.Windows.Forms.Label
|
||
$lblUser.Location = New-Object System.Drawing.Point(20, 65)
|
||
$lblUser.Size = New-Object System.Drawing.Size(80, 25)
|
||
$lblUser.Text = "用户名:"
|
||
$form.Controls.Add($lblUser)
|
||
|
||
$txtUser = New-Object System.Windows.Forms.TextBox
|
||
$txtUser.Location = New-Object System.Drawing.Point(100, 63)
|
||
$txtUser.Size = New-Object System.Drawing.Size(280, 25)
|
||
$txtUser.Font = New-Object System.Drawing.Font("Consolas", 11)
|
||
$form.Controls.Add($txtUser)
|
||
|
||
$lblPass = New-Object System.Windows.Forms.Label
|
||
$lblPass.Location = New-Object System.Drawing.Point(20, 105)
|
||
$lblPass.Size = New-Object System.Drawing.Size(80, 25)
|
||
$lblPass.Text = "密码:"
|
||
$form.Controls.Add($lblPass)
|
||
|
||
$txtPass = New-Object System.Windows.Forms.TextBox
|
||
$txtPass.Location = New-Object System.Drawing.Point(100, 103)
|
||
$txtPass.Size = New-Object System.Drawing.Size(280, 25)
|
||
$txtPass.PasswordChar = '*'
|
||
$txtPass.Font = New-Object System.Drawing.Font("Consolas", 11)
|
||
$form.Controls.Add($txtPass)
|
||
|
||
# P1 F10: 空值验证提示
|
||
$lblError = New-Object System.Windows.Forms.Label
|
||
$lblError.Location = New-Object System.Drawing.Point(100, 135)
|
||
$lblError.Size = New-Object System.Drawing.Size(280, 20)
|
||
$lblError.Text = ""
|
||
$lblError.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
||
$lblError.ForeColor = [System.Drawing.Color]::Red
|
||
$form.Controls.Add($lblError)
|
||
|
||
$btnOK = New-Object System.Windows.Forms.Button
|
||
$btnOK.Location = New-Object System.Drawing.Point(200, 165)
|
||
$btnOK.Size = New-Object System.Drawing.Size(90, 35)
|
||
$btnOK.Text = "登录"
|
||
$form.Controls.Add($btnOK)
|
||
|
||
$btnCancel = New-Object System.Windows.Forms.Button
|
||
$btnCancel.Location = New-Object System.Drawing.Point(300, 165)
|
||
$btnCancel.Size = New-Object System.Drawing.Size(80, 35)
|
||
$btnCancel.Text = "取消"
|
||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||
$form.CancelButton = $btnCancel
|
||
$form.Controls.Add($btnCancel)
|
||
|
||
# OK 按钮手动验证 (不用 DialogResult, 防止空值直接关闭)
|
||
$btnOK.Add_Click({
|
||
if (-not $txtUser.Text.Trim() -or -not $txtPass.Text) {
|
||
$lblError.Text = "用户名和密码不能为空"
|
||
return
|
||
}
|
||
$form.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||
$form.Close()
|
||
})
|
||
$form.AcceptButton = $btnOK
|
||
|
||
$form.Add_Shown({ $txtUser.Focus() })
|
||
$result = $form.ShowDialog()
|
||
|
||
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
||
return @{ User = $txtUser.Text.Trim(); Pass = $txtPass.Text }
|
||
}
|
||
return $null
|
||
}
|
||
|
||
# ─── openssl 检测 ────────────────────────────────────
|
||
function Find-OpenSSL {
|
||
$cmd = Get-Command openssl -ErrorAction SilentlyContinue
|
||
if ($cmd) { return $cmd.Source }
|
||
$paths = @(
|
||
"C:\Program Files\Git\usr\bin\openssl.exe",
|
||
"D:\Git\usr\bin\openssl.exe",
|
||
"C:\Program Files\Git\mingw64\bin\openssl.exe",
|
||
"D:\Git\mingw64\bin\openssl.exe"
|
||
)
|
||
return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||
}
|
||
|
||
# ─── 凭证缓存 (DPAPI 加密, 绑定当前 Windows 用户) ──
|
||
# B2: 不再明文存注册表, 使用 ProtectedData 加密
|
||
# B9: 读取时使用白名单, 不加载任意 KEY
|
||
Add-Type -AssemblyName System.Security
|
||
|
||
$CacheAllowedKeys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN",
|
||
"SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY","GEMINI_API_KEY")
|
||
|
||
function Protect-String([string]$plain) {
|
||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($plain)
|
||
$enc = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, "CurrentUser")
|
||
return [Convert]::ToBase64String($enc)
|
||
}
|
||
|
||
function Unprotect-String([string]$b64) {
|
||
$enc = [Convert]::FromBase64String($b64)
|
||
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, "CurrentUser")
|
||
return [System.Text.Encoding]::UTF8.GetString($bytes)
|
||
}
|
||
|
||
function Get-CachedSecrets {
|
||
try {
|
||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
||
if (-not (Test-Path $regPath)) { return $false }
|
||
$expiry = (Get-ItemProperty $regPath -Name "_expiry" -ErrorAction SilentlyContinue)._expiry
|
||
if (-not $expiry -or [datetime]$expiry -le (Get-Date)) {
|
||
Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue
|
||
return $false
|
||
}
|
||
$props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
|
||
$loaded = 0
|
||
$needMigrate = $false
|
||
foreach ($p in $props.PSObject.Properties) {
|
||
if ($CacheAllowedKeys -contains $p.Name) {
|
||
$val = $null
|
||
# 先尝试 DPAPI 解密 (新格式)
|
||
try { $val = Unprotect-String $p.Value } catch {}
|
||
# 回退: 旧版明文格式 (非 Base64 / DPAPI 失败)
|
||
if (-not $val -and $p.Value -and $p.Value.Length -lt 200) {
|
||
$val = $p.Value
|
||
$needMigrate = $true
|
||
}
|
||
if ($val) {
|
||
[System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process")
|
||
[System.Environment]::SetEnvironmentVariable($p.Name, $val, "User")
|
||
$loaded++
|
||
}
|
||
}
|
||
}
|
||
# 旧缓存自动迁移为 DPAPI 格式
|
||
if ($needMigrate -and $loaded -gt 0) {
|
||
Save-SecretsToCache
|
||
Bw-Log "INFO" "旧版明文缓存已迁移为 DPAPI 加密"
|
||
}
|
||
return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY)
|
||
} catch { return $false }
|
||
}
|
||
|
||
function Save-SecretsToCache {
|
||
try {
|
||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
||
if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null }
|
||
foreach ($k in $CacheAllowedKeys) {
|
||
$v = [System.Environment]::GetEnvironmentVariable($k, "Process")
|
||
if ($v) {
|
||
$encrypted = Protect-String $v
|
||
Set-ItemProperty $regPath -Name $k -Value $encrypted -Force
|
||
}
|
||
}
|
||
Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToUniversalTime().ToString("o") -Force
|
||
# v3.0.11/v3.1.0: 同步注入 profile.ps1 sentinel 块
|
||
# v3.1.0 双 profile 注入 (PS7 + PS5.1), 失败显式弹窗 (闭合 L2/L3)
|
||
try { Inject-CredentialLoaderProfile }
|
||
catch {
|
||
$errMsg = $_.Exception.Message
|
||
Bw-Log "WARN" "profile 凭证块注入失败: $errMsg"
|
||
try {
|
||
$hint = "PowerShell profile 凭证块注入失败 (不阻断 v3.1.0 安装).`n`n"
|
||
$hint += "影响: 桌面快捷方式启动 Claude 时, 可能读不到 ANTHROPIC_API_KEY 凭证.`n`n"
|
||
$hint += "异常: $errMsg`n`n"
|
||
$hint += "修复方案 (任选一):`n"
|
||
$hint += " 方案 A (推荐): 重跑 Bookworm-Setup.exe, 凭证缓存有效会自动重试`n"
|
||
$hint += " 方案 B: 手动跑命令在 PowerShell 7 里:`n"
|
||
$hint += " pwsh -NoProfile -Command `"Inject-CredentialLoaderProfile`"`n"
|
||
$hint += " 方案 C: 启动 Claude 前手动 setx ANTHROPIC_API_KEY <你的Key>"
|
||
Show-MsgBox $hint "v3.1.0 profile 凭证注入失败 (可继续安装)" "OK" "Warning"
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
# v3.0.11/v3.1.0 ADR-002: DPAPI 凭证加载迁移到 PowerShell profile
|
||
# 桌面 .lnk 直调 pwsh/wrapper 后, 凭证由 profile 启动 hook 自动加载, 完全脱离 bat/Base64.
|
||
# v3.1.0 双 profile 注入 (PS7 + PS5.1), 闭合 L2 局限.
|
||
# profile 编辑用 String.Replace 字面替换 (不走 -replace 的 $ backreference 语义,
|
||
# 避免 v3.0.5 BW_CLIP 块踩过的同类 bug)
|
||
function Inject-CredentialLoaderProfile {
|
||
$sentinelStart = "# BW_CRED_START v3.1.0 — 不要手动修改 (Bookworm 启动凭证 DPAPI 自动加载)"
|
||
$sentinelEnd = "# BW_CRED_END"
|
||
|
||
# 注: here-string 内 PS 变量用反引号 ` 转义保留为字面量, 由 profile 加载时再求值
|
||
$block = @"
|
||
$sentinelStart
|
||
# pwsh / PS5.1 启动时自动从 HKCU:\Software\Bookworm\CachedEnv 解密凭证
|
||
# 写入 Process 作用域 env, claude.ps1 启动后能直接读到 ANTHROPIC_API_KEY 等
|
||
try {
|
||
`$bwReg = 'HKCU:\Software\Bookworm\CachedEnv'
|
||
if (Test-Path `$bwReg) {
|
||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||
(Get-ItemProperty `$bwReg -ErrorAction Stop).PSObject.Properties |
|
||
Where-Object { `$_.Name -match '^[A-Z_]+`$' } |
|
||
ForEach-Object {
|
||
`$v = `$_.Value
|
||
try {
|
||
`$b = [Security.Cryptography.ProtectedData]::Unprotect(
|
||
[Convert]::FromBase64String(`$v), `$null,
|
||
[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
||
`$v = [Text.Encoding]::UTF8.GetString(`$b)
|
||
} catch {}
|
||
[Environment]::SetEnvironmentVariable(`$_.Name, `$v, 'Process')
|
||
}
|
||
}
|
||
} catch {}
|
||
$sentinelEnd
|
||
"@
|
||
|
||
# v3.1.0: 双 profile 路径 (PS7 主用, PS5.1 兜底)
|
||
$profileTargets = @(
|
||
@{ Name = "PS7"; Dir = (Join-Path $env:USERPROFILE "Documents\PowerShell"); File = "profile.ps1" }
|
||
@{ Name = "PS5.1"; Dir = (Join-Path $env:USERPROFILE "Documents\WindowsPowerShell"); File = "profile.ps1" }
|
||
)
|
||
|
||
$injected = @()
|
||
$failed = @()
|
||
foreach ($t in $profileTargets) {
|
||
try {
|
||
if (-not (Test-Path $t.Dir)) { New-Item -ItemType Directory -Path $t.Dir -Force -EA Stop | Out-Null }
|
||
$psProfilePath = Join-Path $t.Dir $t.File
|
||
$existing = if (Test-Path $psProfilePath) { (Get-Content $psProfilePath -Raw -Encoding UTF8 -EA SilentlyContinue) } else { $null }
|
||
if (-not $existing) { $existing = "" }
|
||
# 同时清理 v3.0.11 旧 sentinel (BW_CRED_START v3.0.11 ...)
|
||
$oldPattern = "# BW_CRED_START v3\.0\.\d+[\s\S]*?# BW_CRED_END"
|
||
$existing = [regex]::Replace($existing, $oldPattern, "")
|
||
# 替换/追加 v3.1.0 块
|
||
$pattern = [regex]::Escape($sentinelStart) + "[\s\S]*?" + [regex]::Escape($sentinelEnd)
|
||
$regex = [regex]::new($pattern)
|
||
$match = $regex.Match($existing)
|
||
if ($match.Success) {
|
||
$updated = $existing.Replace($match.Value, $block)
|
||
} else {
|
||
$updated = if ($existing.Trim()) { $existing.TrimEnd() + "`n`n" + $block + "`n" } else { $block + "`n" }
|
||
}
|
||
# v3.1.1 (闭合 L7): FileShare.None 排他锁 + 5 次重试防并发损坏
|
||
$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($updated)
|
||
$written = $false
|
||
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||
try {
|
||
$fs = [System.IO.File]::Open($psProfilePath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
|
||
try {
|
||
$fs.Write($bytes, 0, $bytes.Length)
|
||
$fs.Flush()
|
||
$written = $true
|
||
break
|
||
} finally {
|
||
$fs.Close()
|
||
$fs.Dispose()
|
||
}
|
||
} catch [System.IO.IOException] {
|
||
if ($attempt -lt 5) { Start-Sleep -Milliseconds 50 }
|
||
}
|
||
}
|
||
if (-not $written) { throw "profile 文件锁占用 (5 次重试均失败): $psProfilePath" }
|
||
$injected += "$($t.Name) ($psProfilePath)"
|
||
} catch {
|
||
$failed += "$($t.Name): $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
if ($injected.Count -gt 0) {
|
||
Bw-Log "OK" "BW_CRED v3.1.0 块已注入 $($injected.Count) 个 profile: $($injected -join '; ')"
|
||
}
|
||
if ($failed.Count -gt 0) {
|
||
# 双 profile 至少一个成功不算失败 (PS7 默认场景写到一个就够)
|
||
Bw-Log "WARN" "部分 profile 注入失败: $($failed -join '; ')"
|
||
if ($injected.Count -eq 0) {
|
||
# 全失败才 throw → 由调用方 catch 弹窗
|
||
throw "所有 profile 注入失败: $($failed -join '; ')"
|
||
}
|
||
}
|
||
}
|
||
|
||
# ─── 桌面快捷方式 ──────────────────────────────────
|
||
function New-DesktopShortcuts {
|
||
# v3.0.11 架构重构: 桌面 .lnk 直调 pwsh + claude.ps1 绝对路径
|
||
# 不再走 bat → wt → Base64 → DPAPI 7 跳链路, 消除 F1-F8 大部分失败模式
|
||
# 启动 lnk: Target=pwsh.exe, Args=-NoLogo -NoExit -File "<claude.ps1 绝对路径>" --dangerously-skip-permissions
|
||
# 更新 lnk: Target=cmd.exe, Args=/c "git pull..." (失败也不影响启动 lnk)
|
||
try {
|
||
$shell = New-Object -ComObject WScript.Shell
|
||
$desktop = $shell.SpecialFolders("Desktop")
|
||
|
||
# ── 1. 定位 pwsh.exe 绝对路径 ─────────────────────────
|
||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
|
||
if (-not $pwshExe -or -not (Test-Path $pwshExe)) {
|
||
# 兜底候选 (winget 默认 / MSI 默认)
|
||
foreach ($p in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", "$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe")) {
|
||
if (Test-Path $p) { $pwshExe = $p; break }
|
||
}
|
||
}
|
||
if (-not $pwshExe -or -not (Test-Path $pwshExe)) {
|
||
Log-Fail "找不到 pwsh.exe 绝对路径, 拒绝创建桌面快捷方式 (v3.0.11 架构强依赖 pwsh)"
|
||
Show-MsgBox "无法定位 pwsh.exe 绝对路径.`n`nPowerShell 7 可能未正确安装. 请先解决 PS7 安装问题再重跑安装器." "v3.0.11 桌面快捷方式创建失败" "OK" "Error"
|
||
return
|
||
}
|
||
|
||
# ── 2. 定位 bw-launch.ps1 wrapper (v3.1.0 ADR) ───────
|
||
# v3.1.0 改为 wrapper 模式: .lnk 调 bw-launch.ps1, wrapper 启动时动态查 claude.ps1
|
||
# 闭合 L4 (claude.ps1 路径迁移破坏 .lnk).
|
||
# bw-launch.ps1 由本 EXE 自带 (Phase 7 复制到 $BootDir), 路径稳定.
|
||
$bwLaunchPs1 = Join-Path $BootDir "bw-launch.ps1"
|
||
if (-not (Test-Path $bwLaunchPs1)) {
|
||
# 兜底 1: 从本进程同目录拿 (开发场景)
|
||
$devCandidate = Join-Path (Split-Path -Parent $PSCommandPath) "bw-launch.ps1"
|
||
if ($devCandidate -and (Test-Path $devCandidate)) {
|
||
Copy-Item $devCandidate $bwLaunchPs1 -Force -EA SilentlyContinue
|
||
}
|
||
}
|
||
# 兜底 2: 从 bookworm-boot git 仓库 (clone 时已自带)
|
||
if (-not (Test-Path $bwLaunchPs1)) {
|
||
Log-Fail "bw-launch.ps1 wrapper 缺失, 拒绝创建桌面快捷方式"
|
||
Show-MsgBox "Bookworm 启动 wrapper (bw-launch.ps1) 未找到.`n`n这表示 bookworm-boot git 仓库内容不完整, 请重跑 Bookworm-Setup.exe 让 Phase 3 重新克隆仓库." "v3.1.0 wrapper 缺失" "OK" "Error"
|
||
return
|
||
}
|
||
|
||
# 兼容性自验证: 也确认 claude.ps1 当前可达 (装机时校验, 运行时由 wrapper 兜底)
|
||
$claudePs1Check = $null
|
||
try {
|
||
$cc = Get-Command claude -ErrorAction SilentlyContinue
|
||
if ($cc -and $cc.Source -and $cc.Source.EndsWith('.ps1') -and (Test-Path $cc.Source)) { $claudePs1Check = $cc.Source }
|
||
if (-not $claudePs1Check) {
|
||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
||
$cand = Join-Path $npmPrefix "claude.ps1"
|
||
if (Test-Path $cand) { $claudePs1Check = $cand }
|
||
}
|
||
} catch {}
|
||
if (-not $claudePs1Check) {
|
||
Log-Fail "claude.ps1 装机时不可达, 拒绝创建桌面快捷方式 (即便 wrapper 也无法启动)"
|
||
Show-MsgBox "Claude Code 安装可能失败 (claude.ps1 不可达).`n`n请先 npm i -g @anthropic-ai/claude-code 验证安装, 再重跑 Bookworm-Setup.exe" "v3.1.0 Claude 安装异常" "OK" "Error"
|
||
return
|
||
}
|
||
Log-OK "v3.1.0 启动路径已锁定: pwsh=$pwshExe; wrapper=$bwLaunchPs1; claude.ps1=$claudePs1Check"
|
||
|
||
# ── 3. 桌面图标 ───────────────────────────────────────
|
||
$iconPath = Join-Path $BootDir "bookworm-desktop.ico"
|
||
if (-not (Test-Path $iconPath)) { $iconPath = Join-Path $BootDir "bookworm.ico" }
|
||
|
||
# ── 4. 启动 .lnk: 直调 pwsh + claude.ps1 (1 跳链路) ──
|
||
$newLnk = "$desktop\启动Bookworm.lnk"
|
||
$shortcut = $shell.CreateShortcut($newLnk)
|
||
$shortcut.TargetPath = $pwshExe
|
||
# v3.1.0: .lnk 调 wrapper, wrapper 内动态查 claude.ps1, 闭合 L4 stale 路径问题
|
||
# -ExecutionPolicy Bypass 防 LTSC/组策略 AllSigned 拒绝未签名脚本
|
||
$shortcut.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$bwLaunchPs1`" --dangerously-skip-permissions"
|
||
$shortcut.WorkingDirectory = $env:USERPROFILE
|
||
$shortcut.Description = "Bookworm Smart Assistant (v3.1.0 wrapper 架构)"
|
||
if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" }
|
||
$shortcut.Save()
|
||
|
||
# ── 5. 自验证: 读回 .lnk 确认 4 项均写入完整 ──
|
||
$verify = $shell.CreateShortcut($newLnk)
|
||
$checks = @{
|
||
"TargetPath = pwshExe" = ($verify.TargetPath -eq $pwshExe)
|
||
"Arguments 含 bw-launch.ps1 字面路径" = ($verify.Arguments -match [regex]::Escape("`"$bwLaunchPs1`""))
|
||
"Arguments 含 --dangerously-skip-perms" = ($verify.Arguments -match "--dangerously-skip-permissions")
|
||
"Arguments 含 -ExecutionPolicy Bypass" = ($verify.Arguments -match "-ExecutionPolicy Bypass")
|
||
}
|
||
$failed = $checks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
|
||
if ($failed) {
|
||
Log-Fail "启动.lnk 自验证失败 (项: $($failed -join ', '))"
|
||
Log-Fail " 实际 Target: $($verify.TargetPath)"
|
||
Log-Fail " 实际 Args : $($verify.Arguments)"
|
||
Remove-Item $newLnk -Force -EA SilentlyContinue
|
||
Show-MsgBox "桌面快捷方式自验证失败:`n$(($failed | ForEach-Object { ' - ' + $_ }) -join "`n")`n`n已删除坏 lnk. 请重跑安装器." "v3.0.11 自验证失败" "OK" "Error"
|
||
return
|
||
}
|
||
Log-OK "启动Bookworm.lnk 创建并通过 4 项自验证"
|
||
|
||
# ── 6. 迁移清理老 lnk (v3.0.3 及以前的 Bookworm.lnk) ──
|
||
$oldLnk = "$desktop\Bookworm.lnk"
|
||
if (Test-Path $oldLnk) {
|
||
try { Remove-Item -LiteralPath $oldLnk -Force -ErrorAction Stop; Log-Info "已清理旧 Bookworm.lnk (迁移到 v3.0.11 架构)" } catch {}
|
||
}
|
||
|
||
# ── 7. 更新 .lnk: 仅 git pull, 失败不影响启动 lnk ────
|
||
$updateLnk = "$desktop\更新Bookworm.lnk"
|
||
$updateBat = Join-Path $BootDir "更新Bookworm.bat"
|
||
# 兼容旧名 (gen-launcher-bats 之前生成的)
|
||
if (-not (Test-Path $updateBat)) {
|
||
$oldUpdateBat = Join-Path $BootDir "更新并启动Bookworm.bat"
|
||
if (Test-Path $oldUpdateBat) { $updateBat = $oldUpdateBat }
|
||
}
|
||
if (Test-Path $updateBat) {
|
||
$u = $shell.CreateShortcut($updateLnk)
|
||
$u.TargetPath = $updateBat
|
||
$u.WorkingDirectory = $BootDir
|
||
$u.Description = "更新 Bookworm 配置 (git pull)"
|
||
if (Test-Path $iconPath) { $u.IconLocation = "$iconPath,0" }
|
||
$u.Save()
|
||
Log-OK "更新Bookworm.lnk 创建"
|
||
} else {
|
||
Log-Info "更新.bat 未找到, 跳过更新 lnk (启动 lnk 已就绪, 不影响使用)"
|
||
}
|
||
|
||
# ── 8. 体检 .lnk: 调 pwsh + bw-doctor.ps1 (v3.1.2 新增) ──
|
||
$doctorPs1 = Join-Path $BootDir "bw-doctor.ps1"
|
||
if (Test-Path $doctorPs1) {
|
||
$doctorLnk = "$desktop\体检Bookworm.lnk"
|
||
$d = $shell.CreateShortcut($doctorLnk)
|
||
$d.TargetPath = $pwshExe
|
||
$d.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$doctorPs1`""
|
||
$d.WorkingDirectory = $BootDir
|
||
$d.Description = "Bookworm 13 维度健康体检"
|
||
if (Test-Path $iconPath) { $d.IconLocation = "$iconPath,0" }
|
||
$d.Save()
|
||
Log-OK "体检Bookworm.lnk 创建"
|
||
} else {
|
||
Log-Info "bw-doctor.ps1 未找到, 跳过体检 lnk"
|
||
}
|
||
|
||
# ── 9. 卸载 .lnk: 调 cmd + 卸载Bookworm.bat (v3.1.2 新增) ──
|
||
$uninstBat = Join-Path $BootDir "卸载Bookworm.bat"
|
||
if (Test-Path $uninstBat) {
|
||
$uninstLnk = "$desktop\卸载Bookworm.lnk"
|
||
$ui = $shell.CreateShortcut($uninstLnk)
|
||
$ui.TargetPath = $uninstBat
|
||
$ui.WorkingDirectory = $BootDir
|
||
$ui.Description = "卸载 Bookworm (二次确认后执行)"
|
||
if (Test-Path $iconPath) { $ui.IconLocation = "$iconPath,0" }
|
||
$ui.Save()
|
||
Log-OK "卸载Bookworm.lnk 创建"
|
||
} else {
|
||
Log-Info "卸载Bookworm.bat 未找到, 跳过卸载 lnk"
|
||
}
|
||
|
||
Log-OK "v3.1.2 桌面快捷方式 (启动/更新/体检/卸载) 创建完成"
|
||
} catch { Log-Warn "快捷方式创建失败: $_" }
|
||
}
|
||
|
||
# ========================================================================
|
||
# 启动: 显示 GUI 进度窗口 (替代 console banner, PS2EXE -NoOutput 兼容)
|
||
# ========================================================================
|
||
Bw-Log "INIT" "Bookworm Portable Setup 启动 - 日志: $BWLogFile"
|
||
Show-ProgressForm
|
||
|
||
# ========================================================================
|
||
# Phase 1: 环境检测 + 依赖自动安装
|
||
# ========================================================================
|
||
Log-Phase 1 "环境检测 + 依赖自动安装"
|
||
|
||
# 刷新 PATH
|
||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||
|
||
# v3.0.2 (三台机踩坑修复): 放开 PS ExecutionPolicy, 否则管理员 PS 下 claude.ps1 会被拦
|
||
try {
|
||
$curPolicy = Get-ExecutionPolicy -Scope CurrentUser
|
||
if ($curPolicy -eq "Restricted" -or $curPolicy -eq "Undefined") {
|
||
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -EA Stop
|
||
Log-OK "PS ExecutionPolicy 已放开: CurrentUser = RemoteSigned"
|
||
}
|
||
} catch { Log-Warn "ExecutionPolicy 设置失败 (可能组策略锁死): $_" }
|
||
|
||
# DryRun 模式: 只做 Phase 1 检测, 不动任何东西
|
||
if (Is-DryRun "env") {
|
||
Log-Info "[DryRun=env] Phase 1 只检测 PATH + 依赖可达性, 跳过后续步骤"
|
||
}
|
||
|
||
$deps = @(
|
||
# 核心依赖 (三层 fallback: winget → MSI/EXE 直链 → 手动指引)
|
||
# v3.0.5: 所有核心依赖都补齐直链兜底,防 winget 不可用时 Phase 1 直接挂
|
||
@{ Name = "Node.js"; Cmd = "node"; WingetId = "OpenJS.NodeJS.LTS"; NpmPkg = $null; PipPkg = $null; Core = $true
|
||
MsiUrl = "https://nodejs.org/dist/v22.11.0/node-v22.11.0-x64.msi"
|
||
ManualUrl = "https://nodejs.org/zh-cn/download" }
|
||
@{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null; Core = $true
|
||
# Git for Windows 是 Inno Setup (非 MSI), 用 /VERYSILENT /NORESTART 静默
|
||
ExeUrl = "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe"
|
||
ExeArgs = @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/SUPPRESSMSGBOXES")
|
||
ManualUrl = "https://git-scm.com/download/win" }
|
||
@{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null; Core = $true
|
||
MsiUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi"
|
||
ManualUrl = "https://github.com/PowerShell/PowerShell/releases/latest" }
|
||
@{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null; Core = $true
|
||
ManualUrl = "https://docs.anthropic.com/en/docs/claude-code/overview" }
|
||
# Python 移到可选依赖 (不在此列表, 由 line 753 单独处理)
|
||
)
|
||
|
||
$hasWinget = Test-Cmd "winget"
|
||
$installed = @()
|
||
|
||
# v3.0.5: winget 不可用时醒目提示 (但不阻断, MSI/EXE 直链兜底可以继续)
|
||
if (-not $hasWinget) {
|
||
$wingetHint = @"
|
||
检测到 winget (Windows 包管理器) 未就绪。
|
||
|
||
Bookworm 将改用 MSI/EXE 直链自动下载 Node.js 和 Git,无需 winget 也能完成安装,请耐心等待。
|
||
|
||
【若想获取 winget】推荐两条路径:
|
||
1. 应用商店 — 搜索"应用安装程序" (App Installer)
|
||
2. GitHub — https://aka.ms/getwinget
|
||
|
||
【系统要求】Windows 10 1809+ / Windows 11 任意版本
|
||
|
||
【仍无法安装 winget?】很可能是企业镜像锁定或 Win10 1803 及更早版本 —
|
||
无需纠结,本安装器下一步的 MSI/EXE 直链兜底会自动处理。
|
||
若直链也失败,会弹窗给出手动安装清单。
|
||
"@
|
||
Show-MsgBox $wingetHint "winget 未就绪 (不阻断)" "OK" "Information"
|
||
Log-Warn "winget 不可用, 将走 MSI/EXE 直链兜底"
|
||
}
|
||
|
||
foreach ($dep in $deps) {
|
||
if (Test-Cmd $dep.Cmd) {
|
||
$ver = try { & $dep.Cmd --version 2>$null | Select-Object -First 1 } catch { "installed" }
|
||
Log-OK "$($dep.Name) $ver"
|
||
} else {
|
||
Log-Warn "$($dep.Name) 未安装, 正在自动安装..."
|
||
|
||
if ($dep.WingetId -and $hasWinget) {
|
||
try {
|
||
$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) 安装成功"
|
||
$installed += $dep.Name
|
||
} else {
|
||
Log-Fail "$($dep.Name) 安装后仍无法找到, 可能需要重启终端"
|
||
}
|
||
} catch {
|
||
Log-Fail "$($dep.Name) 安装失败: $_"
|
||
}
|
||
}
|
||
# v3.0.3/v3.0.5: winget 缺席/失败时, 用 MSI/EXE 直链兜底
|
||
# v3.0.5 扩展: Git 是 Inno Setup (非 MSI), 分 MsiUrl 和 ExeUrl 两条分派
|
||
if (-not (Test-Cmd $dep.Cmd) -and ($dep.MsiUrl -or $dep.ExeUrl)) {
|
||
$isExe = [bool]$dep.ExeUrl
|
||
$installerUrl = if ($isExe) { $dep.ExeUrl } else { $dep.MsiUrl }
|
||
$ext = if ($isExe) { "exe" } else { "msi" }
|
||
$installerPath = Join-Path $env:TEMP "bw-$($dep.Cmd)-installer.$ext"
|
||
Log-Warn "$($dep.Name) winget 路径失败, 改走 $($ext.ToUpper()) 直链..."
|
||
try {
|
||
# TLS 1.2 强制 (Win10 默认 TLS 1.0/1.1 访问 GitHub 会断)
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||
Log-Info " 下载 $($installerUrl.Substring($installerUrl.LastIndexOf('/')+1))"
|
||
Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing -TimeoutSec 300 -EA Stop
|
||
if ((Get-Item $installerPath).Length -lt 1MB) { throw "$($ext.ToUpper()) 下载不完整: $((Get-Item $installerPath).Length) bytes" }
|
||
Log-Info " 安装 $($ext.ToUpper()) (静默, 需 1-3 分钟)..."
|
||
if ($isExe) {
|
||
# Inno Setup (Git for Windows): /VERYSILENT /NORESTART /SP- /SUPPRESSMSGBOXES
|
||
$exeArgs = if ($dep.ExeArgs) { $dep.ExeArgs } else { @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/SUPPRESSMSGBOXES") }
|
||
$p = Start-Process $installerPath -ArgumentList $exeArgs -Wait -PassThru -NoNewWindow
|
||
} else {
|
||
# MSI (Node.js / PS7): msiexec /i ... /quiet /qn /norestart ADD_PATH=1
|
||
$p = Start-Process "msiexec.exe" -ArgumentList "/i", "`"$installerPath`"", "/quiet", "/qn", "/norestart", "ADD_PATH=1" -Wait -PassThru -NoNewWindow
|
||
}
|
||
Remove-Item $installerPath -Force -EA SilentlyContinue
|
||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||
# 常见安装位置强制补 PATH (避免 User env 延迟刷新)
|
||
$commonDirs = @(
|
||
"$env:ProgramFiles\PowerShell\7",
|
||
"$env:ProgramFiles\Git\cmd",
|
||
"$env:ProgramFiles\Git\bin",
|
||
"$env:ProgramFiles\nodejs",
|
||
"$env:APPDATA\npm"
|
||
)
|
||
foreach ($d in $commonDirs) {
|
||
if ((Test-Path $d) -and ($env:Path -notlike "*$d*")) { $env:Path = "$d;$env:Path" }
|
||
}
|
||
if (Test-Cmd $dep.Cmd) {
|
||
Log-OK "$($dep.Name) $($ext.ToUpper()) 安装成功 (exit $($p.ExitCode))"
|
||
$installed += $dep.Name
|
||
} else {
|
||
Log-Fail "$($dep.Name) $($ext.ToUpper()) 安装后仍找不到 $($dep.Cmd) (exit $($p.ExitCode))"
|
||
}
|
||
} catch { Log-Fail "$($dep.Name) $($ext.ToUpper()) 直链失败: $_" }
|
||
}
|
||
elseif ($dep.NpmPkg -and (Test-Cmd "npm")) {
|
||
try {
|
||
$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) 安装成功"
|
||
$installed += $dep.Name
|
||
}
|
||
} catch { Log-Fail "$($dep.Name) npm 安装失败: $_" }
|
||
}
|
||
elseif (-not $hasWinget) {
|
||
if ($dep.Core) {
|
||
Log-Fail "$($dep.Name) 需要手动安装 (winget 不可用)"
|
||
# 失败专属弹窗由下面统一的 per-dep 失败检查处理
|
||
} else {
|
||
Log-Info "$($dep.Name) 未安装 (可选, 不影响核心功能)"
|
||
}
|
||
}
|
||
|
||
# v3.0.8: per-dep 失败立即弹专属 Warning (不阻断继续下一个 dep, Phase 1 末尾统一判断 exit)
|
||
# 场景: 3 种安装路径全挂 + Core=true → 给用户**具体**该 dep 的手动方案, 而非等到末尾总弹窗
|
||
if ($dep.Core -and -not (Test-Cmd $dep.Cmd)) {
|
||
$depFailMsg = "[$($dep.Name)] 自动安装失败`n`n"
|
||
$depFailMsg += "请手动安装 (任选一种):`n"
|
||
if ($dep.WingetId) {
|
||
$depFailMsg += "`n方式 A (winget): `n winget install --id $($dep.WingetId) --accept-source-agreements --accept-package-agreements`n"
|
||
}
|
||
if ($dep.MsiUrl) {
|
||
$depFailMsg += "`n方式 B (MSI 直链):`n 浏览器打开: $($dep.MsiUrl)`n 下载后双击安装, 全部选默认`n"
|
||
}
|
||
if ($dep.ExeUrl) {
|
||
$depFailMsg += "`n方式 B (EXE 直链):`n 浏览器打开: $($dep.ExeUrl)`n 下载后双击安装, 全部选默认`n"
|
||
}
|
||
if ($dep.NpmPkg) {
|
||
$depFailMsg += "`n方式 A (需 Node.js 就绪):`n 命令行: npm i -g $($dep.NpmPkg)`n"
|
||
}
|
||
if ($dep.ManualUrl) {
|
||
$depFailMsg += "`n参考下载页: $($dep.ManualUrl)`n"
|
||
}
|
||
$depFailMsg += "`n【装完后】重新双击 Bookworm-Setup.exe 即可继续 (已装的依赖会自动跳过)"
|
||
Show-MsgBox $depFailMsg "[$($dep.Name)] 需手动安装" "OK" "Warning"
|
||
}
|
||
}
|
||
}
|
||
|
||
# ── bash PATH 自动修复 (Claude Code 的核心工具依赖 bash) ──
|
||
# Git 默认只把 cmd\ 加 PATH (有 git.exe), 但 bash.exe 在 bin\ 目录
|
||
if ((Test-Cmd "git") -and -not (Test-Cmd "bash")) {
|
||
$gitBinPaths = @(
|
||
"$env:ProgramFiles\Git\bin",
|
||
"${env:ProgramFiles(x86)}\Git\bin",
|
||
"D:\Git\bin",
|
||
"$env:LOCALAPPDATA\Programs\Git\bin"
|
||
)
|
||
$gitBin = $gitBinPaths | Where-Object { Test-Path (Join-Path $_ "bash.exe") } | Select-Object -First 1
|
||
if ($gitBin) {
|
||
# 加入用户 PATH (永久)
|
||
$userPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||
if ($userPath -notmatch [regex]::Escape($gitBin)) {
|
||
[System.Environment]::SetEnvironmentVariable("Path", "$userPath;$gitBin", "User")
|
||
$env:Path += ";$gitBin"
|
||
Log-OK "bash 已加入 PATH: $gitBin"
|
||
}
|
||
} else {
|
||
Log-Warn "Git 已安装但找不到 bash.exe (Claude Code Bash 工具可能不可用)"
|
||
}
|
||
} elseif (Test-Cmd "bash") {
|
||
Log-OK "bash 已就绪"
|
||
}
|
||
|
||
# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查
|
||
# v3.0.8: 失败时写诊断到 bw-crash.log (node -v / npm -v / npm prefix / PATH 片段)
|
||
# v3.0.9: 装完立即 `npm config get prefix` 拿真实路径 → 强制写入 User PATH (永久生效)
|
||
# 根治: 用户机器 Node.js MSI 安装时 User PATH 写入异常 → 快捷方式永远找不到 claude
|
||
if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) {
|
||
Log-Info "安装 Claude Code..."
|
||
$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")
|
||
|
||
# v3.0.9: 动态查询 npm 真实 prefix (兼容 nvm/fnm/Program Files/APPDATA 任意位置)
|
||
$npmPrefix = $null
|
||
try {
|
||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
||
} catch {}
|
||
# 如果 npm prefix 查询失败, 回退常见候选位置
|
||
if (-not $npmPrefix -or -not (Test-Path $npmPrefix)) {
|
||
foreach ($p in @("$env:APPDATA\npm", "$env:ProgramFiles\nodejs", "$env:LOCALAPPDATA\npm")) {
|
||
if (Test-Path $p) { $npmPrefix = $p; break }
|
||
}
|
||
}
|
||
# 进程级立即生效
|
||
if ($npmPrefix -and ($env:Path -notlike "*$npmPrefix*")) { $env:Path = "$npmPrefix;$env:Path" }
|
||
# User 级永久写入 (根治未来任何新 shell 找不到 claude)
|
||
if ($npmPrefix) {
|
||
try {
|
||
$userPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||
if ($userPath -and $userPath -notlike "*$npmPrefix*") {
|
||
[System.Environment]::SetEnvironmentVariable("Path", "$userPath;$npmPrefix", "User")
|
||
Log-OK "npm 全局路径已永久写入 User PATH: $npmPrefix"
|
||
} elseif (-not $userPath) {
|
||
[System.Environment]::SetEnvironmentVariable("Path", $npmPrefix, "User")
|
||
Log-OK "创建 User PATH 含 npm 全局路径: $npmPrefix"
|
||
} else {
|
||
Log-Info "npm 全局路径已在 User PATH 中"
|
||
}
|
||
} catch { Log-Warn "写入 User PATH 失败 (不阻断, 进程级 PATH 已更新): $_" }
|
||
}
|
||
|
||
if (Test-Cmd "claude") {
|
||
Log-OK "Claude Code 安装成功"
|
||
} else {
|
||
Log-Fail "Claude Code 安装失败"
|
||
# v3.0.8: 写具体诊断到 crash log, 便于用户报障 (不走 Show-MsgBox 避免双重打扰, 由 Phase 1 末尾统一弹)
|
||
try {
|
||
$nodeV = try { (& node --version 2>$null) } catch { "(node 不可用)" }
|
||
$npmV = try { (& npm --version 2>$null) } catch { "(npm 不可用)" }
|
||
$npmPrefix = try { (& npm config get prefix 2>$null) } catch { "(获取失败)" }
|
||
$pathNpm = ($env:Path -split ';') | Where-Object { $_ -match 'npm|nodejs' } | Out-String
|
||
$diag = "[$([DateTime]::Now.ToString('s'))] CLAUDE_INSTALL_FAIL`n"
|
||
$diag += " node -v : $nodeV`n"
|
||
$diag += " npm -v : $npmV`n"
|
||
$diag += " npm prefix : $npmPrefix`n"
|
||
$diag += " PATH (npm 相关):`n$pathNpm`n"
|
||
$diag | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue
|
||
Bw-Log "INFO" "Claude Code 诊断已写入: $global:BWCrashLog"
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
# uv (Python 包管理器, 可选依赖) - 完全静默, 失败不阻断不弹窗
|
||
# 安装策略: 1) winget astral-sh.uv 2) Astral 官方脚本 3) pip fallback
|
||
$uvLogFile = Join-Path $env:TEMP "bookworm-uv-install.log"
|
||
$uvInstalled = $false
|
||
|
||
if (Test-Cmd "uv") {
|
||
$uvVer = try { (& uv --version 2>$null | Select-Object -First 1) } catch { "installed" }
|
||
Log-OK "uv $uvVer (已存在)"
|
||
$uvInstalled = $true
|
||
} else {
|
||
Log-Info "安装 uv (Python 包管理器, 可选)..."
|
||
|
||
# B8: try/finally 确保 ErrorActionPreference 恢复 (防止后续 Phase 静默吞错)
|
||
$prevErrPref = $ErrorActionPreference
|
||
try {
|
||
$ErrorActionPreference = "SilentlyContinue"
|
||
|
||
# 方案 A: winget (最可靠)
|
||
if (Test-Cmd "winget") {
|
||
try {
|
||
$null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 |
|
||
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
|
||
} catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
|
||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||
$uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links"
|
||
if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" }
|
||
if (Test-Cmd "uv") { $uvInstalled = $true }
|
||
}
|
||
|
||
# 方案 B: Astral 官方一行脚本
|
||
if (-not $uvInstalled) {
|
||
try {
|
||
$null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 |
|
||
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
|
||
} catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
|
||
$localBin = Join-Path $env:USERPROFILE ".local\bin"
|
||
if (Test-Path $localBin) { $env:Path += ";$localBin" }
|
||
if (Test-Cmd "uv") { $uvInstalled = $true }
|
||
}
|
||
|
||
# 方案 C: pip fallback
|
||
if (-not $uvInstalled -and (Test-Cmd "python")) {
|
||
try {
|
||
$null = & python -m pip install --quiet uv 2>&1 |
|
||
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
|
||
} catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
|
||
try {
|
||
$pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts"
|
||
if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" }
|
||
} catch {}
|
||
if (Test-Cmd "uv") { $uvInstalled = $true }
|
||
}
|
||
} finally {
|
||
$ErrorActionPreference = $prevErrPref
|
||
}
|
||
|
||
if ($uvInstalled) {
|
||
Log-OK "uv 安装成功"
|
||
$installed += "uv"
|
||
} else {
|
||
# 静默 fallback: 仅写日志文件, 不调 Log-Warn 避免 PS2EXE 弹窗
|
||
"[fail] uv 三种安装方式均失败, Python MCP 将不可用. 详见上方日志." | Out-File -FilePath $uvLogFile -Encoding utf8 -Append
|
||
Log-Info "uv 未就绪 (可选, 不影响核心功能, 详情: $uvLogFile)"
|
||
}
|
||
}
|
||
|
||
# OpenSSL (随 Git 安装)
|
||
$opensslCmd = Find-OpenSSL
|
||
if ($opensslCmd) { Log-OK "OpenSSL: $opensslCmd" } else { Log-Warn "OpenSSL 未找到 (凭证解密可能失败)" }
|
||
|
||
# v3.0.8: Phase 1 总结弹窗 — 让用户一眼看懂全局, 避免 GUI 进度条滚动中遗漏关键信息
|
||
$coreList = @("Node.js", "Git", "PowerShell 7", "Claude Code")
|
||
$coreCmds = @{ "Node.js" = "node"; "Git" = "git"; "PowerShell 7" = "pwsh"; "Claude Code" = "claude" }
|
||
$summaryReady = @() # 已就绪 (本次未重装)
|
||
$summaryNew = @() # 本次自动安装成功
|
||
$summaryFail = @() # 本次自动安装失败
|
||
foreach ($name in $coreList) {
|
||
$cmd = $coreCmds[$name]
|
||
if (Test-Cmd $cmd) {
|
||
if ($installed -contains $name) { $summaryNew += $name }
|
||
else { $summaryReady += $name }
|
||
} else {
|
||
$summaryFail += $name
|
||
}
|
||
}
|
||
# v3.1.1 (闭合 L5): 始终弹总结弹窗 (无论是否有动作), 让用户明确确认 Phase 1 通过
|
||
$sum = "[Phase 1] 依赖环境检查完成`n`n"
|
||
if ($summaryReady.Count -gt 0) { $sum += "[OK] 已就绪: $($summaryReady -join ', ')`n" }
|
||
if ($summaryNew.Count -gt 0) { $sum += "[INSTALLED] 本次自动安装: $($summaryNew -join ', ')`n" }
|
||
if ($summaryFail.Count -gt 0) {
|
||
$sum += "[FAIL] 需手动处理: $($summaryFail -join ', ')`n"
|
||
$sum += "`n失败项已在前面弹窗给出手动方案; 装完重跑 EXE 即可继续."
|
||
} elseif ($summaryNew.Count -eq 0) {
|
||
$sum += "`n所有核心依赖之前已装好 (本次零新装).`n即将进入 Phase 2 网络诊断."
|
||
} else {
|
||
$sum += "`n所有核心依赖就绪, 即将进入 Phase 2 网络诊断."
|
||
}
|
||
$icon = if ($summaryFail.Count -gt 0) { "Warning" } else { "Information" }
|
||
Show-MsgBox $sum "Phase 1 总结 — v$BWVersion" "OK" $icon
|
||
|
||
# v3.0.8: PS7 升为硬核依赖 (启动器 bat 强依赖 pwsh 的 -EncodedCommand + STA runspace 正确性,
|
||
# PS5.1 降级路径体验差: 粘贴多行命令被拆分 / Base64 可能被截断 / WinForms 兼容不一致)
|
||
# 最终检查 (Node.js + Git + Claude Code + PowerShell 7 全部为硬性依赖)
|
||
if (-not (Test-Cmd "node") -or -not (Test-Cmd "git") -or -not (Test-Cmd "claude") -or -not (Test-Cmd "pwsh")) {
|
||
$missing = @()
|
||
if (-not (Test-Cmd "node")) { $missing += "Node.js" }
|
||
if (-not (Test-Cmd "git")) { $missing += "Git" }
|
||
if (-not (Test-Cmd "claude")) { $missing += "Claude Code" }
|
||
if (-not (Test-Cmd "pwsh")) { $missing += "PowerShell 7" }
|
||
|
||
# v3.0.5: 清除桌面僵尸快捷方式, 防用户点击空快捷方式触发 'claude.exe not found'
|
||
try {
|
||
$desktop = [System.Environment]::GetFolderPath("Desktop")
|
||
foreach ($lnk in @("启动Bookworm.lnk", "更新Bookworm.lnk", "Bookworm.lnk")) {
|
||
$lnkPath = Join-Path $desktop $lnk
|
||
if (Test-Path $lnkPath) {
|
||
Remove-Item -LiteralPath $lnkPath -Force -EA SilentlyContinue
|
||
Log-Warn "已移除僵尸快捷方式: $lnk (Phase 1 未完成)"
|
||
}
|
||
}
|
||
} catch {}
|
||
|
||
# v3.0.5: 手动安装清单 (winget 失败 + 直链失败时最后一根稻草)
|
||
$manualGuide = "以下核心依赖自动安装失败:`n " + ($missing -join ", ") + "`n`n"
|
||
$manualGuide += "【手动安装清单】请按顺序操作:`n`n"
|
||
if ($missing -contains "Node.js") {
|
||
$manualGuide += "1. Node.js`n"
|
||
$manualGuide += " 下载: https://nodejs.org/zh-cn/download`n"
|
||
$manualGuide += " 选 LTS → Windows Installer (.msi) → 双击安装`n`n"
|
||
}
|
||
if ($missing -contains "Git") {
|
||
$manualGuide += "2. Git for Windows`n"
|
||
$manualGuide += " 下载: https://git-scm.com/download/win`n"
|
||
$manualGuide += " 选 64-bit Git → 双击安装 (默认选项即可)`n`n"
|
||
}
|
||
if ($missing -contains "Claude Code") {
|
||
$manualGuide += "3. Claude Code (需先装好 Node.js)`n"
|
||
$manualGuide += " 命令行执行: npm i -g @anthropic-ai/claude-code`n`n"
|
||
}
|
||
if ($missing -contains "PowerShell 7") {
|
||
$manualGuide += "4. PowerShell 7 (启动器 bat + Base64 启动链路必需)`n"
|
||
$manualGuide += " 方式 A: winget install --id Microsoft.PowerShell`n"
|
||
$manualGuide += " 方式 B: 下载 MSI https://github.com/PowerShell/PowerShell/releases/latest`n"
|
||
$manualGuide += " 选 PowerShell-7.x.x-win-x64.msi → 双击安装`n`n"
|
||
}
|
||
$manualGuide += "【完成后】`n"
|
||
$manualGuide += " 重新双击 Bookworm-Setup.exe 即可继续安装`n"
|
||
$manualGuide += " (已装成功的依赖会被自动跳过)`n`n"
|
||
$manualGuide += "【仍有问题?】`n"
|
||
$manualGuide += " 1. 检查是否以管理员身份运行`n"
|
||
$manualGuide += " 2. 检查网络能否访问上述下载地址`n"
|
||
$manualGuide += " 3. 联系 Bookworm 管理员`n"
|
||
|
||
Show-MsgBox $manualGuide "安装中断 — 手动安装指引" "OK" "Error"
|
||
exit 1
|
||
}
|
||
|
||
# 定位 pwsh.exe 完整路径 (供后续 settings.json 配置使用)
|
||
$PwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
|
||
if (-not $PwshPath) {
|
||
# winget 默认安装路径
|
||
$defaultPaths = @(
|
||
"$env:ProgramFiles\PowerShell\7\pwsh.exe",
|
||
"${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe",
|
||
"$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe"
|
||
)
|
||
$PwshPath = $defaultPaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||
}
|
||
if ($PwshPath) {
|
||
Log-OK "PowerShell 7 路径: $PwshPath"
|
||
} else {
|
||
Log-Warn "pwsh 可执行但无法定位完整路径, 使用 'pwsh'"
|
||
$PwshPath = "pwsh"
|
||
}
|
||
|
||
# 可选依赖检查 (不阻断, 用 Log-Info 避免 PS2EXE 弹窗化)
|
||
$optionalMissing = @()
|
||
if (-not (Test-Cmd "python")) { $optionalMissing += "Python 3.12" }
|
||
if (-not (Test-Cmd "uv")) { $optionalMissing += "uv" }
|
||
if ($optionalMissing.Count -gt 0) {
|
||
Log-Info "可选依赖未就绪: $($optionalMissing -join ', ') — 仅影响 Python 类 MCP, 核心功能正常"
|
||
}
|
||
|
||
if ($installed.Count -gt 0) {
|
||
Log-OK "本次新安装: $($installed -join ', ')"
|
||
}
|
||
|
||
# DryRun "env" 退出点: Phase 1 完成即退
|
||
if (Is-DryRun "env") { Log-OK "[DryRun=env] 完成, 退出"; exit 0 }
|
||
|
||
# ========================================================================
|
||
# Phase 2: 网络诊断
|
||
# ========================================================================
|
||
Log-Phase 2 "网络诊断"
|
||
if (Is-DryRun "net") { Log-Info "[DryRun=net] 只跑网络诊断, 跳过 Phase 3+" }
|
||
|
||
# 代理检测
|
||
$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1"
|
||
$env:no_proxy = $env:NO_PROXY
|
||
|
||
$proxyFound = $false
|
||
|
||
# .NET 系统代理
|
||
if (-not $env:HTTPS_PROXY) {
|
||
try {
|
||
$proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com")
|
||
if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") {
|
||
$env:HTTPS_PROXY = "http://$($proxyUri.Authority)"
|
||
$env:HTTP_PROXY = $env:HTTPS_PROXY
|
||
Log-OK "系统代理: $($env:HTTPS_PROXY)"
|
||
$proxyFound = $true
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
# 注册表 IE 代理
|
||
if (-not $proxyFound -and -not $env:HTTPS_PROXY) {
|
||
try {
|
||
$reg = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction SilentlyContinue
|
||
if ($reg.ProxyEnable -eq 1 -and $reg.ProxyServer) {
|
||
$proxy = $reg.ProxyServer
|
||
if ($proxy -notmatch '^http') { $proxy = "http://$proxy" }
|
||
$env:HTTPS_PROXY = $proxy
|
||
$env:HTTP_PROXY = $proxy
|
||
Log-OK "IE 代理: $proxy"
|
||
$proxyFound = $true
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
# 端口扫描
|
||
if (-not $proxyFound -and -not $env:HTTPS_PROXY) {
|
||
$ports = @(7890,7891,7893,10792,10793,10808,10809,1080,1087,8080,8118)
|
||
foreach ($port in $ports) {
|
||
try {
|
||
$tcp = New-Object System.Net.Sockets.TcpClient
|
||
$ar = $tcp.BeginConnect("127.0.0.1", $port, $null, $null)
|
||
$ok = $ar.AsyncWaitHandle.WaitOne(500)
|
||
if ($ok) { $tcp.EndConnect($ar); $tcp.Close()
|
||
$env:HTTPS_PROXY = "http://127.0.0.1:$port"
|
||
$env:HTTP_PROXY = $env:HTTPS_PROXY
|
||
Log-OK "本地代理端口: $port"
|
||
$proxyFound = $true
|
||
break
|
||
}
|
||
$tcp.Close()
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
if ($env:HTTPS_PROXY) { $proxyFound = $true }
|
||
|
||
if (-not $proxyFound) {
|
||
Log-Warn "未检测到代理/VPN"
|
||
$r = Show-MsgBox "未检测到代理/VPN 软件。`n国内 Claude Code 需要代理才能启动。`n`n请先启动代理软件 (Clash / V2Ray / 快柠檬),`n然后点击 '重试'。`n`n或点击 '忽略' 继续 (可能失败)。" "网络警告" "AbortRetryIgnore" "Warning"
|
||
if ($r -eq "Retry") {
|
||
# 重试代理检测
|
||
try {
|
||
$proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com")
|
||
if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") {
|
||
$env:HTTPS_PROXY = "http://$($proxyUri.Authority)"
|
||
$env:HTTP_PROXY = $env:HTTPS_PROXY
|
||
Log-OK "系统代理: $($env:HTTPS_PROXY)"
|
||
}
|
||
} catch {}
|
||
} elseif ($r -eq "Abort") { exit 1 }
|
||
}
|
||
|
||
Log-OK "NO_PROXY: bww.letcareme.com, code.letcareme.com"
|
||
|
||
# 连通性测试
|
||
Log-Info "测试网络连通性..."
|
||
|
||
$netTests = @(
|
||
@{ Name = "Gitea 代码仓库"; Url = "https://code.letcareme.com"; Direct = $true }
|
||
@{ Name = "API 中转站"; Url = "https://bww.letcareme.com"; Direct = $true }
|
||
@{ Name = "Claude API"; Url = "https://api.anthropic.com"; Direct = $false }
|
||
)
|
||
|
||
foreach ($t in $netTests) {
|
||
try {
|
||
$req = [System.Net.HttpWebRequest]::Create($t.Url)
|
||
$req.Timeout = 8000
|
||
$req.Method = "HEAD"
|
||
if ($t.Direct) { $req.Proxy = [System.Net.GlobalProxySelection]::GetEmptyWebProxy() }
|
||
$resp = $req.GetResponse()
|
||
$code = [int]$resp.StatusCode
|
||
$resp.Close()
|
||
Log-OK "$($t.Name) ($($t.Url)) - HTTP $code"
|
||
} catch {
|
||
$errMsg = $_.Exception.InnerException.Message
|
||
if (-not $errMsg) { $errMsg = $_.Exception.Message }
|
||
# 非 200 但能连上也算成功 (如 401, 403)
|
||
if ($errMsg -match '40[0-9]|30[0-9]') {
|
||
Log-OK "$($t.Name) - 可达 (需认证)"
|
||
} else {
|
||
Log-Warn "$($t.Name) - 不可达: $($errMsg.Substring(0, [Math]::Min(60, $errMsg.Length)))"
|
||
}
|
||
}
|
||
}
|
||
|
||
# DryRun "net" 退出点: Phase 2 完成即退
|
||
if (Is-DryRun "net") { Log-OK "[DryRun=net] 网络诊断完成, 退出"; exit 0 }
|
||
|
||
# ========================================================================
|
||
# Phase 3: 仓库克隆
|
||
# ========================================================================
|
||
Log-Phase 3 "同步 Bookworm 配置"
|
||
if (Is-DryRun "repo") { Log-Info "[DryRun=repo] 测试 git clone 完成后退出, 不做凭证解密/配置渲染" }
|
||
function Test-CloneComplete { (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) -and (Test-Path (Join-Path $ClaudeDir "skills")) -and (Test-Path (Join-Path $ClaudeDir "hooks")) }
|
||
|
||
# B3: 使用 Windows Credential Manager (DPAPI 加密) 替代明文 store
|
||
git config --global credential.helper manager 2>$null
|
||
# v3.2.0: 强制 git 对国内仓库直连 (VPN 开启时 git 可能走代理导致 clone 失败)
|
||
git config --global "http.https://code.letcareme.com/.proxy" "" 2>$null
|
||
|
||
# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结
|
||
# 辅助函数: clone 后缓存凭证到 Windows Credential Manager
|
||
function Cache-GitCredentials($credObj) {
|
||
if (-not $credObj) { return }
|
||
try {
|
||
$approveInput = "protocol=https`nhost=code.letcareme.com`nusername=$($credObj.User)`npassword=$($credObj.Pass)`n`n"
|
||
$approveInput | & git credential approve 2>$null
|
||
Bw-Log "OK" "Gitea 凭证已缓存到 Windows Credential Manager"
|
||
} catch { Bw-Log "WARN" "凭证缓存失败: $_" }
|
||
}
|
||
|
||
if (Test-Path (Join-Path $ClaudeDir ".git")) {
|
||
Log-Info "配置仓库已存在, 更新中..."
|
||
# 设置 git 身份 (auto-resolve commit 需要)
|
||
& git -C $ClaudeDir config user.email "bookworm@auto.local" 2>$null
|
||
& git -C $ClaudeDir config user.name "Bookworm" 2>$null
|
||
# v3.2.0: 校验 remote URL, 旧仓库自动切换到新仓库
|
||
try {
|
||
$currentRemote = (& git -C $ClaudeDir remote get-url origin 2>$null) -replace '\.git$', ''
|
||
$expectedBase = ($GitUrl -replace '\.git$', '')
|
||
if ($currentRemote -and $currentRemote -ne $expectedBase) {
|
||
Log-Warn "配置仓库 remote 指向旧源: $currentRemote, 切换到 $GitUrl"
|
||
& git -C $ClaudeDir remote set-url origin $GitUrl 2>$null
|
||
Log-OK "remote 已切换到 bookworm-smart-assistant"
|
||
}
|
||
} catch {}
|
||
try {
|
||
# 强制清除冲突状态 (运行时文件不重要, Phase 5 会重新渲染)
|
||
& git -C $ClaudeDir reset --hard HEAD 2>&1 | Out-Null
|
||
# 如果 remote 已切换, 需要 fetch 新仓库再 reset
|
||
& git -C $ClaudeDir fetch origin 2>&1 | Out-Null
|
||
& git -C $ClaudeDir reset --hard origin/main 2>&1 | Out-Null
|
||
$r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "同步配置仓库" 120000
|
||
if ($r.OK) {
|
||
Log-OK "配置仓库已更新"
|
||
} else {
|
||
# pull 失败可能是认证问题, 尝试重新输入凭证
|
||
Log-Warn "git pull 失败, 尝试重新认证..."
|
||
$cred = Show-GiteaCredentialDialog
|
||
if ($cred) {
|
||
Cache-GitCredentials $cred
|
||
$r2 = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "重试同步" 120000
|
||
if ($r2.OK) { Log-OK "配置仓库已更新 (重新认证成功)" }
|
||
else { Log-Warn "git pull 仍失败, 使用本地版本" }
|
||
} else { Log-Warn "用户取消认证, 使用本地版本" }
|
||
}
|
||
} catch { Log-Warn "git pull 异常: $_, 使用本地版本" }
|
||
}
|
||
elseif (Test-Path $ClaudeDir) {
|
||
Log-Info "备份现有 .claude/ 并克隆..."
|
||
if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force }
|
||
Rename-Item $ClaudeDir $BackupDir
|
||
|
||
# v3.0.1 先匿名克隆 (repos 已 public + REQUIRE_SIGNIN_VIEW=false), 失败才弹凭证
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $GitUrl, $ClaudeDir) "克隆配置仓库 (匿名)" 180000
|
||
if (-not (Test-CloneComplete)) {
|
||
Log-Warn "匿名克隆失败, 弹凭证对话框重试..."
|
||
$cred = Show-GiteaCredentialDialog
|
||
if ($cred) {
|
||
$cloneUrl = $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@"
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库 (认证)" 180000
|
||
if (Test-CloneComplete) { Cache-GitCredentials $cred }
|
||
}
|
||
}
|
||
if (Test-CloneComplete) {
|
||
Log-OK "配置仓库克隆成功 (旧目录已备份)"
|
||
} else {
|
||
Log-Fail "克隆失败"
|
||
if (Test-Path $BackupDir) { Rename-Item $BackupDir $ClaudeDir }
|
||
Show-MsgBox "配置仓库克隆失败。`n请检查网络连接。" "克隆失败" "OK" "Error"
|
||
exit 1
|
||
}
|
||
}
|
||
else {
|
||
Log-Info "首次安装, 匿名克隆配置仓库..."
|
||
# v3.0.1 先匿名, 失败才弹凭证
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $GitUrl, $ClaudeDir) "克隆配置仓库 (匿名)" 180000
|
||
if (-not (Test-CloneComplete)) {
|
||
Log-Warn "匿名克隆失败, 弹凭证对话框重试..."
|
||
$cred = Show-GiteaCredentialDialog
|
||
if ($cred) {
|
||
$cloneUrl = $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@"
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库 (认证)" 180000
|
||
if (Test-CloneComplete) { Cache-GitCredentials $cred }
|
||
}
|
||
}
|
||
if (Test-CloneComplete) {
|
||
Log-OK "配置仓库克隆成功"
|
||
} else {
|
||
Log-Fail "克隆失败"
|
||
Show-MsgBox "配置仓库克隆失败。`n请检查网络连接。" "克隆失败" "OK" "Error"
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
# 创建本地运行时目录
|
||
$dirs = @("debug","sessions","cache","backups","telemetry","shell-snapshots","projects","memory")
|
||
foreach ($d in $dirs) {
|
||
$p = Join-Path $ClaudeDir $d
|
||
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
|
||
}
|
||
|
||
# ─── 克隆/更新 bookworm-boot (含 crypto-helper.js + secrets-*.enc + install.ps1) ───
|
||
if (Test-Path (Join-Path $BootDir ".git")) {
|
||
Log-Info "boot 仓库已存在, 更新中..."
|
||
try {
|
||
$r = Run-CmdWithUI "git" @("-C", $BootDir, "pull", "--rebase") "同步 boot 仓库" 120000
|
||
if ($r.OK) { Log-OK "boot 仓库已更新" } else { Log-Warn "boot 仓库更新失败, 使用本地版本" }
|
||
} catch { Log-Warn "boot 仓库更新失败, 使用本地版本" }
|
||
} else {
|
||
Log-Info "匿名克隆 boot 仓库 (含解密工具)..."
|
||
# v3.0.1: 先匿名 (repo 已公开), 失败才弹凭证
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $BootUrl, $BootDir) "克隆 boot 仓库 (匿名)" 180000
|
||
if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) {
|
||
Log-Warn "匿名克隆失败, 弹凭证对话框重试..."
|
||
$cred = Show-GiteaCredentialDialog
|
||
if ($cred) {
|
||
$bootCloneUrl = $BootUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@"
|
||
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库 (认证)" 180000
|
||
if (Test-Path (Join-Path $BootDir "crypto-helper.js")) { Cache-GitCredentials $cred }
|
||
}
|
||
}
|
||
if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) {
|
||
Log-Fail "启动工具包下载失败"
|
||
Show-MsgBox "Bookworm 启动工具包下载失败。`n`n请检查:`n1. 网络和代理是否正常`n2. Gitea (code.letcareme.com) 是否可达`n`n重新运行安装器即可。" "下载失败" "OK" "Error"
|
||
exit 1
|
||
}
|
||
Log-OK "boot 仓库克隆成功 → $BootDir"
|
||
}
|
||
|
||
# DryRun "repo" 退出点: Phase 3 git clone 完成即退
|
||
if (Is-DryRun "repo") { Log-OK "[DryRun=repo] git clone 完成, 退出"; exit 0 }
|
||
|
||
# ========================================================================
|
||
# Phase 4: 凭证解密 (GUI 弹窗)
|
||
# ========================================================================
|
||
Log-Phase 4 "凭证解密"
|
||
if (Is-DryRun "creds") { Log-Info "[DryRun=creds] 只测 sk- Key 验证, 不持久化, 不进入 Phase 5" }
|
||
|
||
$secretsDecrypted = $false
|
||
|
||
# 优先级 1: User 级环境变量已有 (上次安装已永久写入)
|
||
# v3.0.1: 静默使用旧 Key 改为明确询问 (防误用吊销的老 Key)
|
||
$existingKey = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_API_KEY", "User")
|
||
$existingUrl = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_BASE_URL", "User")
|
||
if ($existingKey) {
|
||
$keyPreview = if ($existingKey.Length -ge 14) { $existingKey.Substring(0,10) + "..." + $existingKey.Substring($existingKey.Length - 4) } else { $existingKey.Substring(0, [Math]::Min(6, $existingKey.Length)) + "..." }
|
||
$urlPreview = if ($existingUrl) { $existingUrl } else { "(默认中转站)" }
|
||
$useExistingChoice = Show-MsgBox "检测到系统已保存的 API Key:`n`n Key : $keyPreview`n URL : $urlPreview`n`n是 = 继续使用这把 Key`n否 = 更换为新 Key (弹输入框)" "已有 API Key" "YesNo" "Question"
|
||
if ($useExistingChoice -eq "Yes") {
|
||
# 复用旧 Key
|
||
$env:ANTHROPIC_API_KEY = $existingKey
|
||
if ($existingUrl) { $env:ANTHROPIC_BASE_URL = $existingUrl }
|
||
foreach ($k in $CacheAllowedKeys) {
|
||
$v = [System.Environment]::GetEnvironmentVariable($k, "User")
|
||
if ($v) { [System.Environment]::SetEnvironmentVariable($k, $v, "Process") }
|
||
}
|
||
Log-OK "继续使用已保存 Key ($keyPreview)"
|
||
$secretsDecrypted = $true
|
||
} else {
|
||
# 用户选择换 Key: 彻底清除所有缓存层 (否则 Priority 2 DPAPI 会复活旧 Key)
|
||
Log-Info "用户选择更换 Key, 清除所有层缓存, 转到手动输入"
|
||
# 层 1: User env var
|
||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User")
|
||
$env:ANTHROPIC_API_KEY = $null
|
||
# 层 2: DPAPI Registry 缓存 (HKCU:\Software\Bookworm\CachedEnv)
|
||
try { Remove-Item "HKCU:\Software\Bookworm\CachedEnv" -Recurse -Force -ErrorAction SilentlyContinue } catch {}
|
||
# URL 保留不清, 新 Key 默认还指向同一中转站
|
||
Log-Info "已清 User env + DPAPI cache, 下面将弹输入框让你粘新 Key"
|
||
}
|
||
}
|
||
|
||
# 优先级 2: Registry DPAPI 缓存
|
||
if (-not $secretsDecrypted -and (Get-CachedSecrets)) {
|
||
Log-OK "从 Registry 缓存加载凭证"
|
||
$secretsDecrypted = $true
|
||
}
|
||
|
||
# v3.0.4: 默认模型优先级重排(修复: opus-4-7 硬编码导致 sonnet-only 套餐用户启动后全 403)
|
||
# 顺序: 已显式设置 > Test-ApiKey 实际通过的 model (延后设) > Worker /config > 兜底 sonnet-4-6
|
||
# 注: 实际验证通过后会在 Phase 4 末尾用 $script:LastValidatedModel 覆盖为"真·可用模型"
|
||
if (-not $env:ANTHROPIC_MODEL) {
|
||
$defaultModel = "claude-sonnet-4-6" # v3.0.4: 兜底改 sonnet-4-6 (基础套餐都有, opus 常被低档 403)
|
||
try {
|
||
$cfg = Invoke-RestMethod "https://bookworm-router.bookworm-api.workers.dev/config" -TimeoutSec 8 -ErrorAction Stop
|
||
if ($cfg.default_model) { $defaultModel = $cfg.default_model }
|
||
} catch {}
|
||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_MODEL", $defaultModel, "User")
|
||
$env:ANTHROPIC_MODEL = $defaultModel
|
||
Log-OK "ANTHROPIC_MODEL=$defaultModel (暂设, Phase 4 验证通过后会根据实际可用 model 覆盖)"
|
||
}
|
||
# 优先级 3: 直接输入中转站 API Key (v2.3 新增)
|
||
if (-not $secretsDecrypted) {
|
||
Show-MsgBox "欢迎使用 Bookworm Portable!`n`n接下来需要配置你的中转站 API Key。`n`n如果还没有, 请先去 https://bww.letcareme.com 注册并充值获取。" "配置 API Key" "OK" "Information"
|
||
|
||
$keyAttempts = 0
|
||
$maxKeyAttempts = 3
|
||
while ($keyAttempts -lt $maxKeyAttempts) {
|
||
$keyAttempts++
|
||
$apiKey = Show-ApiKeyDialog $keyAttempts $maxKeyAttempts
|
||
if (-not $apiKey) {
|
||
Log-Warn "用户取消 API Key 输入"
|
||
break
|
||
}
|
||
# 基础格式校验
|
||
if ($apiKey.Length -lt 20) {
|
||
Show-MsgBox "API Key 格式错误 (长度过短)。`n请检查后重试。" "格式错误" "OK" "Warning"
|
||
continue
|
||
}
|
||
Log-Info "正在验证..."
|
||
# v3.0.4: 统一走 Test-ApiKey (移除 change-key.js 优先分支)
|
||
# 删除原因: change-key.js 硬编码 claude-3-haiku-20240307 做验证,
|
||
# sonnet-only 套餐用户的 Key 在 haiku 必返 403 → 误判 Key 无效,
|
||
# 而 Test-ApiKey 本来就支持多模型 fallback + 三值错误分类, 覆盖更广.
|
||
# 同步修复: stderr 不再 2>&1 | Out-Null, 改为记录到 phase4-validate.log 便于用户报障
|
||
# 残留清理: 如 .claude/change-key.js 存在, 保留但不再调用 (未来版本会彻底移除)
|
||
$ckOk = $false
|
||
$checkResult = Test-ApiKey $apiKey
|
||
$phase4Log = Join-Path $env:TEMP "bw-phase4-validate.log"
|
||
if ($checkResult -eq $true) {
|
||
$ckOk = $true
|
||
$validatedModel = if ($script:LastValidatedModel) { $script:LastValidatedModel } else { "(unknown)" }
|
||
Log-OK "Key 在线验证通过 (model=$validatedModel)"
|
||
try { "[$([DateTime]::Now.ToString('s'))] PASS model=$validatedModel" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
|
||
} elseif ($null -eq $checkResult) {
|
||
# 全部候选网络故障, 不能归咎 Key, 先接受 (首次真实请求时再判)
|
||
$ckOk = $true
|
||
Log-Warn "中转站暂不可达, 暂接受此 Key (首次使用时若失败请重装换 Key)"
|
||
try { "[$([DateTime]::Now.ToString('s'))] NETWORK_ACCEPT baseUrl=https://bww.letcareme.com" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
|
||
} else {
|
||
# 全部候选都 401/403 = 明确认证失败
|
||
$ckOk = $false
|
||
$failDetail = if ($script:LastAuthFailCodes) { $script:LastAuthFailCodes -join ',' } else { 'all_models_auth_fail' }
|
||
Log-Warn "Key 被拒绝 ($failDetail)"
|
||
try { "[$([DateTime]::Now.ToString('s'))] AUTH_FAIL $failDetail" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
|
||
}
|
||
if ($ckOk) {
|
||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $apiKey, "User")
|
||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "https://bww.letcareme.com", "User")
|
||
# v3.0.4: 如果 Test-ApiKey 返回了真实通过的模型, 用它覆盖先前设的兜底默认
|
||
if ($script:LastValidatedModel) {
|
||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_MODEL", $script:LastValidatedModel, "User")
|
||
$env:ANTHROPIC_MODEL = $script:LastValidatedModel
|
||
Log-OK "ANTHROPIC_MODEL 锁定为实际可用: $($script:LastValidatedModel)"
|
||
}
|
||
}
|
||
if ($ckOk) {
|
||
$env:ANTHROPIC_API_KEY = $apiKey
|
||
$env:ANTHROPIC_BASE_URL = "https://bww.letcareme.com"
|
||
# v3.0.2: 广播 WM_SETTINGCHANGE, 让 explorer.exe + 已打开窗口刷新 env
|
||
try {
|
||
$sig = '[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'
|
||
$api = Add-Type -MemberDefinition $sig -Name Win32SendMsg -Namespace BWBroadcast -PassThru -EA Stop
|
||
$HWND_BROADCAST = [IntPtr]0xffff; $WM_SETTINGCHANGE = 0x001A; $result = [UIntPtr]::Zero
|
||
[void]$api::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result)
|
||
} catch { Log-Warn "env 广播失败 (不影响功能): $_" }
|
||
# v3.0.2: 预填 .claude.json 跳过 Claude Code 2.0.1 的登录选择页 (两选项都走 anthropic.com OAuth, 国内不通)
|
||
try {
|
||
$claudeJsonPath = Join-Path $env:USERPROFILE ".claude.json"
|
||
$keyPrefix = $apiKey.Substring(0, [Math]::Min(20, $apiKey.Length))
|
||
$bypassConfig = '{"hasCompletedOnboarding":true,"hasSeenWelcome":true,"bypassPermissionsModeAccepted":true,"customApiKeyResponses":{"approved":["' + $keyPrefix + '"],"rejected":[]},"numStartups":5,"projects":{}}'
|
||
# 无 BOM UTF-8 写入, Set-Content 会加 BOM 导致 Claude Code JSON parser 拒收
|
||
[IO.File]::WriteAllText($claudeJsonPath, $bypassConfig, [System.Text.UTF8Encoding]::new($false))
|
||
Log-OK "Claude Code onboarding 已预填 (跳过 v2.0.1 登录选择页)"
|
||
} catch { Log-Warn ".claude.json 预填失败 (首次启动需手工过登录画面): $_" }
|
||
Log-OK "凭证已验证并持久化"
|
||
$secretsDecrypted = $true
|
||
Save-SecretsToCache
|
||
Show-MsgBox "API Key 验证成功!`n`n已写入系统环境变量, 任何终端输入 claude 即可启动。`n`n以后想换 Key, 可以:`n1. 双击桌面 '更换Key.bat'`n2. 或 Claude Code 里输入 /change-key`n3. 或重跑安装器" "验证成功" "OK" "Information"
|
||
break
|
||
} else {
|
||
$remaining = $maxKeyAttempts - $keyAttempts
|
||
if ($remaining -gt 0) {
|
||
Show-MsgBox "API Key 验证失败 (无法连接或认证错误)。`n剩余重试: $remaining 次`n`n请检查:`n1. Key 是否正确 (sk- 开头)`n2. 中转站是否有余额`n3. 网络和代理是否正常" "验证失败" "OK" "Warning"
|
||
} else {
|
||
Show-MsgBox "3 次验证均失败。`n`n请检查 Key 和网络, 或联系管理员。" "验证失败" "OK" "Error"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# 优先级 4: 解密授权码 (向后兼容, 旧用户保留)
|
||
if (-not $secretsDecrypted) {
|
||
$cryptoHelper = Join-Path $BootDir "crypto-helper.js"
|
||
if (-not (Test-Cmd "node") -or -not (Test-Path $cryptoHelper)) {
|
||
Log-Info "跳过授权码解密"
|
||
}
|
||
elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) {
|
||
# 强制要求授权码 — 不允许跳过 (跳过 = 无法使用)
|
||
Show-MsgBox "检测到加密凭证文件,需要输入授权码才能使用 Bookworm。`n`n授权码由管理员提供,格式: BW-YYYYMMDD-XXXX...`n如果没有授权码,请联系管理员获取。" "需要授权码" "OK" "Information"
|
||
|
||
$validAttempts = 0
|
||
while ($validAttempts -lt 3) {
|
||
$rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3
|
||
if (-not $rawCode) {
|
||
# 不再静默跳过,明确警告
|
||
$skip = Show-MsgBox "未输入授权码。`n`n没有授权码将无法使用 Bookworm(无 API 凭证)。`n`n确定要跳过吗?" "警告" "YesNo" "Warning"
|
||
if ($skip -eq "No") { continue }
|
||
Log-Warn "用户确认跳过授权码"
|
||
break
|
||
}
|
||
|
||
$token = Parse-AuthCode-GUI $rawCode
|
||
if ($token -eq 'EXPIRED') {
|
||
Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning"
|
||
continue
|
||
}
|
||
if (-not $token) {
|
||
Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning"
|
||
continue
|
||
}
|
||
|
||
# B7: 先检查文件存在, 再递增 validAttempts (文件缺失不消耗尝试次数)
|
||
$fileId = $token.Substring(0, 8)
|
||
$encFile = Join-Path $BootDir "secrets-$fileId.enc"
|
||
if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc }
|
||
if (-not (Test-Path $encFile)) {
|
||
Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。`n`n(此次不计为失败尝试)" "文件未找到" "OK" "Warning"
|
||
$token = $null
|
||
continue
|
||
}
|
||
|
||
$validAttempts++ # B7: 只有真正尝试解密才计数
|
||
|
||
try {
|
||
$decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1
|
||
$decExit = $LASTEXITCODE
|
||
$token = $null
|
||
|
||
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') {
|
||
$count = 0
|
||
foreach ($line in $decrypted -split "`n") {
|
||
$line = $line.Trim()
|
||
if (-not $line -or $line -notmatch '=') { continue }
|
||
$key = ($line -split '=', 2)[0].Trim()
|
||
$value = ($line -split '=', 2)[1].Trim()
|
||
# F-12 fix: 白名单 + 长度校验, 防止 secrets.enc 被污染注入恶意 env
|
||
if ($key -and $value -and ($key -in $CacheAllowedKeys) -and ($value.Length -lt 512)) {
|
||
[System.Environment]::SetEnvironmentVariable($key, $value, "Process")
|
||
[System.Environment]::SetEnvironmentVariable($key, $value, "User")
|
||
Log-OK "已注入: $key (永久)"
|
||
$count++
|
||
} elseif ($key -and ($key -notin $CacheAllowedKeys)) {
|
||
Bw-Log "WARN" "跳过未知 key: $key (不在白名单)"
|
||
}
|
||
}
|
||
$decrypted = $null
|
||
$secretsDecrypted = $true
|
||
Save-SecretsToCache # F-22 fix: 写入 DPAPI 缓存, 下次免授权码
|
||
|
||
Show-MsgBox "授权码验证成功!`n`n$count 个凭证已写入系统环境变量 (永久生效)。`n任何终端输入 claude 即可启动,无需再次输入授权码。" "验证成功" "OK" "Information"
|
||
break
|
||
} else {
|
||
$token = $null
|
||
$remaining = 3 - $validAttempts
|
||
if ($remaining -gt 0) {
|
||
Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次" "验证失败" "OK" "Warning"
|
||
} else {
|
||
Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error"
|
||
}
|
||
}
|
||
} catch {
|
||
$token = $null
|
||
Log-Warn "解密异常: $_"
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
Log-Warn "未找到任何 secrets*.enc,跳过凭证解密"
|
||
}
|
||
} # end if (-not $secretsDecrypted)
|
||
|
||
# ========================================================================
|
||
# Phase 4.5: License 激活 (v3.0 新增, 可选)
|
||
# ========================================================================
|
||
# 检查 ~/.claude/.bw-token 是否存在且未过期; 不存在则引导激活
|
||
$bwTokenFile = Join-Path $ClaudeDir ".bw-token"
|
||
$bwActivateJs = Join-Path $ClaudeDir "lib\activate.js"
|
||
$needLicenseActivate = $true
|
||
if (Test-Path $bwTokenFile) {
|
||
try {
|
||
$tok = Get-Content $bwTokenFile -Raw | ConvertFrom-Json
|
||
if ($tok.expires_at -gt ((New-TimeSpan -Start (Get-Date "1970-01-01") -End (Get-Date).ToUniversalTime()).TotalMilliseconds + 86400000)) {
|
||
Log-OK "License token 有效 (UUID: $($tok.license_uuid))"
|
||
$needLicenseActivate = $false
|
||
}
|
||
} catch {}
|
||
}
|
||
if ($needLicenseActivate -and (Test-Path $bwActivateJs) -and (Test-Cmd "node")) {
|
||
# 零输入快路径: $env:BW_LICENSE_KEY 预设时直接激活, 不弹 GUI
|
||
# (install.ps1 通过环境变量传递, 格式: BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
|
||
$envKey = $env:BW_LICENSE_KEY
|
||
if ($envKey -and $envKey -match '^BW-[A-F0-9]{4}(-[A-F0-9]{4}){5}$') {
|
||
Log-Info "检测到 `$env:BW_LICENSE_KEY, 静默激活..."
|
||
try {
|
||
$envKey | & node $bwActivateJs 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0 -and (Test-Path $bwTokenFile)) {
|
||
Log-OK "License 静默激活成功"
|
||
} else {
|
||
Log-Warn "静默激活失败 (退出码 $LASTEXITCODE), 回退到 GUI"
|
||
$envKey = $null # 失败后走 GUI 补救
|
||
}
|
||
} catch {
|
||
Log-Warn "静默激活异常: $_, 回退到 GUI"
|
||
$envKey = $null
|
||
}
|
||
# 清掉 env 防止后续进程泄露
|
||
Remove-Item Env:\BW_LICENSE_KEY -ErrorAction SilentlyContinue
|
||
}
|
||
# 仍未激活 → 走 GUI 交互流 (带三次重试)
|
||
if (-not (Test-Path $bwTokenFile)) {
|
||
$activateChoice = Show-MsgBox "是否激活 Bookworm License (用于加载 Skill 专家)?`n`n如果有 License Key (BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX), 点是`n如果没有, 点否 (可后续用 change-key.bat 激活)" "License 激活" "YesNo" "Information"
|
||
if ($activateChoice -eq "Yes") {
|
||
$attempt = 0
|
||
while ($attempt -lt 3 -and -not (Test-Path $bwTokenFile)) {
|
||
$attempt++
|
||
$licKey = Show-LicenseKeyDialog $attempt 3
|
||
if (-not $licKey) { break } # 用户取消
|
||
if ($licKey -notmatch '^BW-[A-F0-9]{4}(-[A-F0-9]{4}){5}$') {
|
||
Show-MsgBox "格式错误! License Key 必须是 6 段 4 位十六进制`n例: BW-1A2B-3C4D-5E6F-7890-ABCD-EF12" "格式错误 ($attempt/3)" "OK" "Warning"
|
||
continue
|
||
}
|
||
try {
|
||
$licKey | & node $bwActivateJs 2>&1 | Out-Null
|
||
if ($LASTEXITCODE -eq 0 -and (Test-Path $bwTokenFile)) {
|
||
Log-OK "License 激活成功"
|
||
break
|
||
} else {
|
||
Show-MsgBox "激活失败 (可能 Key 已吊销/达设备上限/过期, 或代理不稳)`n重试或点取消跳过" "激活失败 ($attempt/3)" "OK" "Warning"
|
||
}
|
||
} catch {
|
||
Show-MsgBox "激活异常: $_" "激活失败 ($attempt/3)" "OK" "Error"
|
||
}
|
||
}
|
||
} else {
|
||
Log-Info "已跳过 License 激活"
|
||
}
|
||
}
|
||
}
|
||
|
||
# DryRun "creds" / "license" 退出点: Phase 4 / 4.5 完成即退
|
||
if (Is-DryRun "creds") { Log-OK "[DryRun=creds] 凭证验证完成, 退出"; exit 0 }
|
||
if (Is-DryRun "license") { Log-OK "[DryRun=license] License 激活完成, 退出"; exit 0 }
|
||
|
||
# ========================================================================
|
||
# Phase 5: 配置渲染
|
||
# ========================================================================
|
||
Log-Phase 5 "配置渲染"
|
||
|
||
$templateFile = Join-Path $ClaudeDir "settings.template.json"
|
||
$settingsFile = Join-Path $ClaudeDir "settings.json"
|
||
|
||
if (Test-Path $templateFile) {
|
||
$claudeRoot = $ClaudeDir.Replace('\', '/')
|
||
$homeDir = $env:USERPROFILE
|
||
# pwsh 路径转正斜杠供 JSON 使用 (C:/Program Files/PowerShell/7/pwsh.exe)
|
||
$pwshJsonPath = if ($PwshPath) { $PwshPath.Replace('\', '/') } else { "pwsh" }
|
||
|
||
$content = Get-Content $templateFile -Raw
|
||
$content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
||
# HOME 必须用正斜杠: C:\Users\x 中 \U 是非法 JSON 转义序列
|
||
$homeForward = $homeDir.Replace('\', '/')
|
||
$content = $content -replace '\{\{HOME\}\}', $homeForward
|
||
$content = $content -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
|
||
|
||
Set-Content $settingsFile -Value $content -Encoding UTF8
|
||
Log-OK "settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)"
|
||
|
||
# settings.local.template.json (向后兼容)
|
||
$localTpl = Join-Path $ClaudeDir "settings.local.template.json"
|
||
$localSet = Join-Path $ClaudeDir "settings.local.json"
|
||
if (Test-Path $localTpl) {
|
||
$lc = Get-Content $localTpl -Raw
|
||
$lc = $lc -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
||
$lc = $lc -replace '\{\{HOME\}\}', $homeForward
|
||
$lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME
|
||
$lc = $lc -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
|
||
Set-Content $localSet -Value $lc -Encoding UTF8
|
||
Log-OK "settings.local.json 已渲染"
|
||
}
|
||
|
||
# ── ~/.claude.json (Claude Code v2.1+ MCP 服务器配置的正确位置) ──
|
||
# 优先用 config 仓库的 inject-mcp.js, 如果 git pull 失败则内嵌 fallback
|
||
$injectScript = Join-Path $ClaudeDir "inject-mcp.js"
|
||
$mcpInjected = $false
|
||
# 方案 A: 调用 config 仓库里的 inject-mcp.js
|
||
if (Test-Path $injectScript) {
|
||
try {
|
||
$nodeOut = & node $injectScript 2>&1
|
||
$firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim()
|
||
Log-OK $firstLine
|
||
$mcpInjected = $true
|
||
} catch { Bw-Log "WARN" "inject-mcp.js 执行失败: $_" }
|
||
}
|
||
# 方案 B: 内嵌 fallback (git pull 失败时 inject-mcp.js 不存在)
|
||
if (-not $mcpInjected) {
|
||
Log-Info "inject-mcp.js 不可用, 使用内嵌 MCP 注入..."
|
||
$fallbackJs = Join-Path $env:TEMP "bw-mcp-fallback.js"
|
||
try {
|
||
$fbLines = @(
|
||
'var fs=require("fs"),p=require("path");'
|
||
'var H=process.env.USERPROFILE;'
|
||
'var f=p.join(H,".claude.json");'
|
||
'var d={};'
|
||
'try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch(e){}'
|
||
'var N="npx.cmd",Y="--yes";'
|
||
'var S={};'
|
||
'S.context7={command:N,args:[Y,"@upstash/context7-mcp@2.1.1"],type:"stdio"};'
|
||
'S.playwright={command:N,args:[Y,"@playwright/mcp@0.0.68","--headless"],type:"stdio"};'
|
||
'S["session-continuity"]={command:N,args:[Y,"claude-session-continuity-mcp@1.13.0"],type:"stdio"};'
|
||
'S["browser-mcp"]={command:N,args:[Y,"@browsermcp/mcp@latest"],type:"stdio"};'
|
||
'S["desktop-commander"]={command:N,args:[Y,"@wonderwhy-er/desktop-commander@latest"],type:"stdio"};'
|
||
'S["chrome-devtools"]={command:N,args:[Y,"chrome-devtools-mcp@0.18.1"],type:"stdio"};'
|
||
'S.github={command:N,args:[Y,"@modelcontextprotocol/server-github"],type:"stdio"};'
|
||
'S.slack={command:N,args:[Y,"@modelcontextprotocol/server-slack"],type:"stdio"};'
|
||
'S.firecrawl={command:N,args:[Y,"firecrawl-mcp"],type:"stdio"};'
|
||
'S["mcp-image"]={command:N,args:[Y,"mcp-image"],type:"stdio"};'
|
||
'S["google-drive"]={command:N,args:[Y,"@piotr-agier/google-drive-mcp"],type:"stdio"};'
|
||
'S.browserbase={command:N,args:[Y,"@anthropic-ai/browserbase-mcp"],type:"stdio"};'
|
||
'S.notebooklm={command:N,args:[Y,"notebooklm-mcp@latest"],type:"stdio"};'
|
||
'S.cloudflare={command:N,args:[Y,"mcp-remote","https://docs.mcp.cloudflare.com/sse"],type:"stdio"};'
|
||
'S.mobile={command:N,args:[Y,"@mobilenext/mobile-mcp@0.0.35"],type:"stdio"};'
|
||
'var K="@modelcontextprotocol/server-sequential-thinking";'
|
||
'S["sequential-thinking"]={command:N,args:[Y,K+"@2025.12.18"],type:"stdio"};'
|
||
'S.linear={type:"http",url:"https://mcp.linear.app/mcp"};'
|
||
'S.supabase={type:"http",url:"https://mcp.supabase.com/mcp?project_ref=oepmihbtoylosbsxlmfo"};'
|
||
'S.figma={type:"http",url:"https://mcp.figma.com/mcp"};'
|
||
'S["windows-mcp"]={command:"uvx",args:["--python","3.13","windows-mcp"],type:"stdio"};'
|
||
'S.atlassian={command:"uvx",args:["mcp-atlassian"],type:"stdio"};'
|
||
'S["computer-control-mcp"]={command:"uvx",args:["computer-control-mcp@latest"],type:"stdio"};'
|
||
'd.mcpServers=S;'
|
||
'fs.writeFileSync(f,JSON.stringify(d,null,2));'
|
||
'console.log("OK: "+Object.keys(S).length+" MCP servers (fallback)");'
|
||
)
|
||
$fbLines -join "`n" | Set-Content $fallbackJs -Encoding UTF8
|
||
$nodeOut = & node $fallbackJs 2>&1
|
||
$firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim()
|
||
Remove-Item $fallbackJs -Force -ErrorAction SilentlyContinue
|
||
Log-OK $firstLine
|
||
} catch { Bw-Log "WARN" "MCP fallback 注入失败: $_" }
|
||
}
|
||
} else {
|
||
Log-Warn "settings.template.json 不存在, 跳过渲染"
|
||
}
|
||
|
||
$env:CLAUDE_HOME = $ClaudeDir
|
||
|
||
# ========================================================================
|
||
# Phase 5.5: 截图粘贴助手部署 (v3.0.5 新增)
|
||
# ========================================================================
|
||
# 目标: Shift+Win+S 截图后, 在 Claude Code CLI Ctrl+V 直接粘贴为 [Image #N] 附件
|
||
# 分发文件: $ClaudeDir\scripts\ClipImageWatcher.ps1 (由 bookworm-portable-config 仓库提供)
|
||
# 注入点:
|
||
# 1. $HOME\Documents\PowerShell\profile.ps1 (sentinel 追加式, Q1=A)
|
||
# 2. HKCU:\...Microsoft.ScreenSketch\...\Enabled=0 (备份原值到 HKCU:\Software\Bookworm\ToastBackup, 卸载时还原, Q2=B)
|
||
# ========================================================================
|
||
Log-Info "[5.5/$TOTAL_PHASES] 截图粘贴助手部署"
|
||
Update-Progress-SubStatus "配置截图粘贴助手..."
|
||
|
||
# v3.0.7: 整段 Phase 5.5 包 try-catch, 任何失败都降级到 Log-Warn 不阻断主流程
|
||
# 原因: Phase 5.5 是可选体验增强, 失败不应导致主安装流程闪退 (Phase 6/7 还没跑)
|
||
try {
|
||
$clipWatcherSrc = Join-Path $ClaudeDir "scripts\ClipImageWatcher.ps1"
|
||
if (Test-Path $clipWatcherSrc) {
|
||
# ── 5.5a: profile sentinel 注入 (幂等, 精准替换 BW_CLIP_START..END 中间块) ──
|
||
$psProfileDir = Join-Path $env:USERPROFILE "Documents\PowerShell"
|
||
$psProfilePath = Join-Path $psProfileDir "profile.ps1"
|
||
if (-not (Test-Path $psProfileDir)) { New-Item -ItemType Directory -Path $psProfileDir -Force | Out-Null }
|
||
|
||
$sentinelStart = "# BW_CLIP_START v3.0.5 — 不要手动修改此块 (Bookworm 自动生成)"
|
||
$sentinelEnd = "# BW_CLIP_END"
|
||
$bwClipBlock = @"
|
||
$sentinelStart
|
||
`$script:ClipWatcherPath = "`$HOME\.claude\scripts\ClipImageWatcher.ps1"
|
||
|
||
function Start-ClipWatch {
|
||
[CmdletBinding()]
|
||
param([int]`$IntervalMs = 400, [switch]`$Foreground)
|
||
if (-not (Test-Path `$script:ClipWatcherPath)) {
|
||
Write-Error "找不到监听脚本: `$script:ClipWatcherPath"
|
||
return
|
||
}
|
||
if (`$Foreground) {
|
||
& pwsh -NoProfile -ExecutionPolicy Bypass -File `$script:ClipWatcherPath -IntervalMs `$IntervalMs
|
||
return
|
||
}
|
||
`$logDir = Join-Path `$env:TEMP 'claude-clip'
|
||
if (-not (Test-Path `$logDir)) { New-Item -ItemType Directory -Path `$logDir -Force | Out-Null }
|
||
`$logPath = Join-Path `$logDir 'watcher.log'
|
||
`$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||
`$psi.FileName = 'pwsh'
|
||
`$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File ```"`$(`$script:ClipWatcherPath)```" -IntervalMs `$IntervalMs"
|
||
`$psi.UseShellExecute = `$false
|
||
`$psi.CreateNoWindow = `$true
|
||
`$psi.WindowStyle = 'Hidden'
|
||
`$psi.RedirectStandardOutput = `$true
|
||
`$psi.RedirectStandardError = `$true
|
||
`$p = [System.Diagnostics.Process]::Start(`$psi)
|
||
`$global:ClipWatchPid = `$p.Id
|
||
Register-ObjectEvent -InputObject `$p -EventName OutputDataReceived -Action {
|
||
if (`$EventArgs.Data) { Add-Content -Path `$using:logPath -Value `$EventArgs.Data -Encoding UTF8 }
|
||
} | Out-Null
|
||
Register-ObjectEvent -InputObject `$p -EventName ErrorDataReceived -Action {
|
||
if (`$EventArgs.Data) { Add-Content -Path `$using:logPath -Value "[ERR] `$(`$EventArgs.Data)" -Encoding UTF8 }
|
||
} | Out-Null
|
||
`$p.BeginOutputReadLine()
|
||
`$p.BeginErrorReadLine()
|
||
Write-Host "[ClipWatch] 已静默启动 (PID=`$(`$p.Id))。日志: `$logPath" -ForegroundColor Cyan
|
||
}
|
||
|
||
function Stop-ClipWatch {
|
||
if (`$global:ClipWatchPid) {
|
||
try { Stop-Process -Id `$global:ClipWatchPid -Force -ErrorAction Stop; Write-Host "[ClipWatch] 已停止 PID=`$global:ClipWatchPid" -ForegroundColor Yellow }
|
||
catch { Write-Warning "停止失败: `$(`$_.Exception.Message)" }
|
||
`$global:ClipWatchPid = `$null
|
||
} else { Write-Host "[ClipWatch] 未在运行。" -ForegroundColor DarkGray }
|
||
}
|
||
|
||
function Get-ClipWatchDir {
|
||
`$d = Join-Path `$env:TEMP 'claude-clip'
|
||
if (Test-Path `$d) { Get-ChildItem `$d | Sort-Object LastWriteTime -Descending | Select-Object -First 10 }
|
||
else { Write-Host "尚未产生截图。" }
|
||
}
|
||
|
||
# 自动启动监听器 (全局只跑一份, 用 CommandLine 查重)
|
||
try {
|
||
`$watcherName = Split-Path `$script:ClipWatcherPath -Leaf
|
||
`$running = Get-CimInstance Win32_Process -Filter "Name='pwsh.exe'" -ErrorAction SilentlyContinue |
|
||
Where-Object { `$_.CommandLine -like "*`$watcherName*" }
|
||
if (-not `$running -and (Test-Path `$script:ClipWatcherPath)) { Start-ClipWatch }
|
||
} catch {}
|
||
$sentinelEnd
|
||
"@
|
||
|
||
# 读现有 profile, 精准替换 sentinel 中间块 (不动其他内容)
|
||
# v3.0.7 修复: 原 -replace 运算符把 replacement 里的 $script:/`$HOME/$1 等当作 backreference,
|
||
# 导致生成的 profile.ps1 损坏. 改用 [regex]::Match + String.Replace 字面替换绕过 $ 语义.
|
||
$existing = if (Test-Path $psProfilePath) { Get-Content $psProfilePath -Raw -Encoding UTF8 } else { "" }
|
||
$pattern = [regex]::Escape($sentinelStart) + "[\s\S]*?" + [regex]::Escape($sentinelEnd)
|
||
$regex = [regex]::new($pattern)
|
||
$match = $regex.Match($existing)
|
||
if ($match.Success) {
|
||
# 字面替换: 把匹配到的旧块整段 String.Replace 成新块, 不走正则 replacement 语义
|
||
$updated = $existing.Replace($match.Value, $bwClipBlock)
|
||
} else {
|
||
$updated = if ($existing) { $existing.TrimEnd() + "`n`n" + $bwClipBlock + "`n" } else { $bwClipBlock + "`n" }
|
||
}
|
||
# 无 BOM UTF-8 写入 (PS7 能正确识别, PS5.1 也 OK)
|
||
[System.IO.File]::WriteAllText($psProfilePath, $updated, [System.Text.UTF8Encoding]::new($false))
|
||
Log-OK "PowerShell profile 已注入 BW_CLIP 块: $psProfilePath"
|
||
|
||
# ── 5.5b: 截图 Toast 关闭 + 备份原值用于卸载还原 ──
|
||
$toastReg = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\Microsoft.ScreenSketch_8wekyb3d8bbwe!App"
|
||
$bwBackup = "HKCU:\Software\Bookworm\ToastBackup"
|
||
try {
|
||
$origVal = $null
|
||
if (Test-Path $toastReg) {
|
||
$origVal = (Get-ItemProperty -Path $toastReg -Name "Enabled" -EA SilentlyContinue).Enabled
|
||
}
|
||
# 备份原值到 HKCU:\Software\Bookworm\ToastBackup (卸载时用于还原)
|
||
if (-not (Test-Path $bwBackup)) { New-Item -Path $bwBackup -Force | Out-Null }
|
||
$bwBackupProps = Get-ItemProperty -Path $bwBackup -EA SilentlyContinue
|
||
if (-not $bwBackupProps -or -not $bwBackupProps.ScreenSketchToast_Backed) {
|
||
# 只在首次备份 (防重装覆盖用户手动改过的值)
|
||
$bakValue = if ($null -eq $origVal) { "__ABSENT__" } else { $origVal.ToString() }
|
||
New-ItemProperty -Path $bwBackup -Name "ScreenSketchToast_Original" -Value $bakValue -PropertyType String -Force | Out-Null
|
||
New-ItemProperty -Path $bwBackup -Name "ScreenSketchToast_Backed" -Value 1 -PropertyType DWord -Force | Out-Null
|
||
Log-Info "已备份 Toast 原值到 $bwBackup (卸载时自动还原)"
|
||
}
|
||
# 关 Toast
|
||
if (-not (Test-Path $toastReg)) { New-Item -Path $toastReg -Force | Out-Null }
|
||
New-ItemProperty -Path $toastReg -Name "Enabled" -Value 0 -PropertyType DWord -Force | Out-Null
|
||
Log-OK "截图工具 Toast 已关闭 (原值已备份)"
|
||
} catch {
|
||
Log-Warn "截图 Toast 关闭失败 (不影响截图本身): $_"
|
||
}
|
||
} else {
|
||
Log-Warn "ClipImageWatcher.ps1 未分发到 $clipWatcherSrc, 跳过截图助手部署"
|
||
}
|
||
} catch {
|
||
# v3.0.7: 截图助手是可选功能, 失败降级不阻断主流程
|
||
Log-Warn "Phase 5.5 截图助手部署异常 (不影响主功能): $($_.Exception.Message)"
|
||
try { "[$([DateTime]::Now.ToString('s'))] PHASE_5_5_FAIL $($_.Exception.Message) @ $($_.InvocationInfo.ScriptLineNumber)" | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {}
|
||
}
|
||
|
||
# ========================================================================
|
||
# Phase 6: MCP 验证 + 自动安装
|
||
# ========================================================================
|
||
Log-Phase 6 "MCP 服务验证 + 预安装"
|
||
|
||
# ── 6a: Bookworm 完整性检查 ──
|
||
$skillCount = 0; $hookCount = 0
|
||
$skillsDir = Join-Path $ClaudeDir "skills"
|
||
$hooksDir = Join-Path $ClaudeDir "hooks"
|
||
if (Test-Path $skillsDir) { $skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count }
|
||
if (Test-Path $hooksDir) { $hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count }
|
||
|
||
$claudeMdOK = $false
|
||
$claudeMdPath = Join-Path $ClaudeDir "CLAUDE.md"
|
||
if (Test-Path $claudeMdPath) {
|
||
$cm = Get-Content $claudeMdPath -Raw -ErrorAction SilentlyContinue
|
||
$claudeMdOK = $cm -match "Bookworm"
|
||
}
|
||
|
||
$settingsOK = $false
|
||
if (Test-Path $settingsFile) {
|
||
$sc = Get-Content $settingsFile -Raw -ErrorAction SilentlyContinue
|
||
$settingsOK = $sc -match '"hooks"'
|
||
}
|
||
|
||
# v3.0.1 修复: Skills 门槛从 >50 改为 >=10
|
||
# 原因: 架构是 14 本地 + 79 云端懒加载, 本地只缓存常用 skill
|
||
# >50 的旧门槛和架构不匹配, 每次装完都误报 "配置不完整"
|
||
$checks = @(
|
||
@{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $claudeMdOK }
|
||
@{ Name = "Skills ($skillCount 本地 + 79 云端懒加载)"; OK = ($skillCount -ge 10) }
|
||
# v3.0.1: hook 阈值从 >10 改为 >=3 (config repo 分发精简版只有 4 个核心 hook)
|
||
@{ Name = "Hooks ($hookCount 核心)"; OK = ($hookCount -ge 3) }
|
||
@{ Name = "Settings hooks 配置"; OK = $settingsOK }
|
||
)
|
||
|
||
$allOK = $true
|
||
foreach ($c in $checks) {
|
||
if ($c.OK) { Log-OK $c.Name } else { Log-Fail $c.Name; $allOK = $false }
|
||
}
|
||
|
||
# ── 6b: API 凭证检查 ──
|
||
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 包预缓存 (非阻塞 UI) ──
|
||
Log-Info "MCP 预安装 (npx 包预缓存)..."
|
||
|
||
$npxPackages = @(
|
||
@{ Name = "context7"; Pkg = "@upstash/context7-mcp@2.1.1" }
|
||
@{ Name = "sequential-thinking"; Pkg = "@modelcontextprotocol/server-sequential-thinking@2025.12.18" }
|
||
@{ Name = "playwright"; Pkg = "@playwright/mcp@0.0.68" }
|
||
@{ Name = "session-continuity"; Pkg = "claude-session-continuity-mcp@1.13.0" }
|
||
@{ Name = "notebooklm"; Pkg = "notebooklm-mcp@latest" }
|
||
@{ Name = "cloudflare-docs"; Pkg = "mcp-remote" }
|
||
@{ Name = "chrome-devtools"; Pkg = "chrome-devtools-mcp@0.18.1" }
|
||
@{ Name = "github"; Pkg = "@modelcontextprotocol/server-github" }
|
||
@{ Name = "slack"; Pkg = "@modelcontextprotocol/server-slack" }
|
||
@{ Name = "firecrawl"; Pkg = "firecrawl-mcp" }
|
||
@{ Name = "mcp-image"; Pkg = "mcp-image" }
|
||
@{ Name = "google-drive"; Pkg = "@piotr-agier/google-drive-mcp" }
|
||
)
|
||
|
||
# v3.1.2 L11: 国内镜像 fallback (npmjs 失败 → npmmirror 重试)
|
||
$npmMirrorRegistry = "https://registry.npmmirror.com"
|
||
|
||
$mcpOK = 0; $mcpFail = 0
|
||
foreach ($mcp in $npxPackages) {
|
||
$idx = $mcpOK + $mcpFail + 1
|
||
$label = "[$idx/$($npxPackages.Count)] $($mcp.Name)"
|
||
Update-Progress-SubStatus "$label ..."
|
||
|
||
$cached = $false
|
||
foreach ($registry in @($null, $npmMirrorRegistry)) {
|
||
try {
|
||
$outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp"
|
||
$errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp"
|
||
$npmArgs = @("cache", "add", $mcp.Pkg)
|
||
if ($registry) {
|
||
$npmArgs += "--registry=$registry"
|
||
Update-Progress-SubStatus "$label (镜像重试)..."
|
||
}
|
||
$proc = Start-Process npm.cmd -ArgumentList $npmArgs `
|
||
-NoNewWindow -PassThru `
|
||
-RedirectStandardOutput $outTmp `
|
||
-RedirectStandardError $errTmp
|
||
$ok = Wait-ProcessWithUI $proc 60000 $label
|
||
if ($ok -and $proc.ExitCode -eq 0) {
|
||
$src = if ($registry) { "mirror" } else { "npmjs" }
|
||
Bw-Log "OK" "$label cached ($src)"
|
||
$mcpOK++; $cached = $true
|
||
} else { throw "exit=$($proc.ExitCode)" }
|
||
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
|
||
break
|
||
} catch {
|
||
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
|
||
if ($registry) {
|
||
Bw-Log "WARN" "$label failed (mirror): $_"
|
||
} else {
|
||
Bw-Log "INFO" "$label npmjs failed, trying mirror..."
|
||
}
|
||
}
|
||
}
|
||
if (-not $cached) { $mcpFail++ }
|
||
}
|
||
Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功$(if ($mcpFail -gt 0) { " ($mcpFail 失败)" })"
|
||
|
||
# ── 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 {
|
||
$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 {
|
||
Log-Warn "Playwright Chromium 安装可能未完成"
|
||
}
|
||
}
|
||
} catch { Log-Warn "Playwright 浏览器安装失败: $_ (不影响核心功能)" }
|
||
|
||
# ── 6e: Python MCP (uvx) 验证 (非阻塞 UI) ──
|
||
# v3.1.2 L11: PyPI 失败 → tuna 镜像重试
|
||
$pypiMirrorIndex = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||
|
||
if (Test-Cmd "uvx") {
|
||
Log-Info "Python MCP 验证 (uvx)..."
|
||
$uvxPackages = @(
|
||
# F-17 fix: uv tool install 参数顺序 = 包名在前, --python 在后
|
||
@{ Name = "windows-mcp"; Args = @("tool", "install", "windows-mcp", "--python", "3.13") }
|
||
@{ Name = "atlassian"; Args = @("tool", "install", "mcp-atlassian") }
|
||
)
|
||
foreach ($pkg in $uvxPackages) {
|
||
$installed = $false
|
||
foreach ($indexUrl in @($null, $pypiMirrorIndex)) {
|
||
try {
|
||
$outTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name).tmp"
|
||
$errTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name)-err.tmp"
|
||
$installArgs = $pkg.Args
|
||
if ($indexUrl) {
|
||
$installArgs += @("--index-url", $indexUrl)
|
||
Update-Progress-SubStatus "uvx $($pkg.Name) (镜像重试)..."
|
||
}
|
||
$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 -and $proc.ExitCode -eq 0) {
|
||
$src = if ($indexUrl) { "tuna" } else { "pypi" }
|
||
Bw-Log "OK" "uvx $($pkg.Name) ready ($src)"
|
||
$installed = $true; break
|
||
} else { throw "exit=$($proc.ExitCode)" }
|
||
} catch {
|
||
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
|
||
if ($indexUrl) {
|
||
Bw-Log "WARN" "uvx $($pkg.Name) failed (mirror): $_"
|
||
} else {
|
||
Bw-Log "INFO" "uvx $($pkg.Name) pypi failed, trying tuna..."
|
||
}
|
||
}
|
||
}
|
||
if (-not $installed) { Bw-Log "WARN" "uvx $($pkg.Name): all sources failed" }
|
||
}
|
||
} else {
|
||
Bw-Log "INFO" "uvx 不可用, 跳过 Python MCP"
|
||
}
|
||
|
||
# ── 6f: 可选 API Key 提示 ──
|
||
$optional = @(
|
||
@{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP" }
|
||
@{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP" }
|
||
@{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP" }
|
||
@{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP" }
|
||
@{ Key = "GEMINI_API_KEY"; Name = "MCP Image / Browserbase" }
|
||
@{ Key = "ATLASSIAN_API_TOKEN"; Name = "Atlassian MCP" }
|
||
)
|
||
$missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") }
|
||
if ($missingOpt.Count -gt 0) {
|
||
foreach ($m in $missingOpt) { Bw-Log "INFO" "可选 Key 未配置: $($m.Name) ($($m.Key))" }
|
||
}
|
||
|
||
# ========================================================================
|
||
# Phase 7: 环境加固 + 完成 + 启动
|
||
# ========================================================================
|
||
Log-Phase 7 "环境加固 + 启动"
|
||
|
||
# ── 7a: claude 命令默认带 --dangerously-skip-permissions ──
|
||
# PS2EXE 下 $PROFILE 可能为 $null, 需先检查
|
||
try {
|
||
# 构造 pwsh profile 路径 (不依赖 $PROFILE 自动变量, PS2EXE 下可能为空)
|
||
$pwshProfile = if ($PROFILE) { $PROFILE }
|
||
elseif ($PwshPath) { Join-Path (Split-Path $PwshPath -Parent) "profile.ps1" }
|
||
else { Join-Path "$env:USERPROFILE\Documents\PowerShell" "Microsoft.PowerShell_profile.ps1" }
|
||
|
||
if ($pwshProfile) {
|
||
$profileDir = Split-Path $pwshProfile -Parent
|
||
if ($profileDir -and -not (Test-Path $profileDir)) {
|
||
New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
|
||
}
|
||
$aliasLine = 'function claude { $exe = (Get-Command claude.exe -EA SilentlyContinue).Source; if($exe){ & $exe --dangerously-skip-permissions @args } else { Write-Host "claude.exe not found" } }'
|
||
$hasAlias = (Test-Path $pwshProfile) -and (Select-String -Path $pwshProfile -Pattern 'dangerously-skip-permissions' -Quiet -ErrorAction SilentlyContinue)
|
||
if (-not $hasAlias) {
|
||
Add-Content -Path $pwshProfile -Value "`n# Bookworm: claude 默认免权限确认`n$aliasLine" -Encoding utf8
|
||
Bw-Log "OK" "PowerShell profile 已添加 claude alias"
|
||
}
|
||
}
|
||
} catch { Bw-Log "WARN" "7a claude alias 设置失败: $_" }
|
||
|
||
# ── 7b: 清理 OAuth 登录 (防止与 relay key 冲突) ──
|
||
try {
|
||
$credFile = Join-Path $ClaudeDir ".credentials.json"
|
||
if ($env:ANTHROPIC_BASE_URL -and (Test-Path $credFile)) {
|
||
$credContent = Get-Content $credFile -Raw -ErrorAction SilentlyContinue
|
||
if ($credContent -match '"claudeAiOauth"') {
|
||
Remove-Item $credFile -Force -ErrorAction SilentlyContinue
|
||
Bw-Log "OK" "已清理 OAuth 登录凭证 (改用中转站 relay key)"
|
||
}
|
||
}
|
||
} catch { Bw-Log "WARN" "7b OAuth 清理失败: $_" }
|
||
|
||
# ── 7c: 自动修复 .claude 仓库冲突 ──
|
||
try {
|
||
$claudeGit = Join-Path $ClaudeDir ".git"
|
||
if (Test-Path $claudeGit) {
|
||
# F-19 fix: 逐行匹配冲突状态 (Out-String 合并后 ^ 不匹配行内)
|
||
$gitLines = & git -C $ClaudeDir status --porcelain 2>&1
|
||
$hasConflict = $gitLines | Where-Object { $_ -match '^U|^.U' }
|
||
if ($hasConflict) {
|
||
& git -C $ClaudeDir checkout --theirs . 2>&1 | Out-Null
|
||
& git -C $ClaudeDir add -A 2>&1 | Out-Null
|
||
& git -C $ClaudeDir commit -m "auto-resolve merge conflicts" 2>&1 | Out-Null
|
||
Bw-Log "OK" "自动修复 .claude 仓库合并冲突"
|
||
}
|
||
}
|
||
} catch { Bw-Log "WARN" "7c 冲突修复失败: $_" }
|
||
|
||
# 关闭进度窗口
|
||
Close-ProgressForm
|
||
|
||
# v3.0.9: 创建桌面快捷方式前强制自验证 claude 可达 (防止交付半成品快捷方式)
|
||
# 已 Phase 1 + 补装 Claude 到这里仍 Test-Cmd false → 真的有问题, 立即弹窗让用户修,
|
||
# 而不是创建"点了没反应"的坏快捷方式误导用户
|
||
if (-not (Test-Cmd "claude")) {
|
||
# 尝试最后一次 PATH 重载 (可能 Phase 1 写 User PATH 后本进程还没 pickup)
|
||
try {
|
||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
||
if ($npmPrefix -and (Test-Path $npmPrefix) -and ($env:Path -notlike "*$npmPrefix*")) {
|
||
$env:Path = "$npmPrefix;$env:Path"
|
||
}
|
||
} catch {}
|
||
}
|
||
if (-not (Test-Cmd "claude")) {
|
||
$msg = "[CRITICAL] Claude Code 安装成功但命令不可达`n`n"
|
||
$msg += "这通常是 npm 全局路径未进 PATH 导致. 尝试修复:`n`n"
|
||
$msg += "1. 关闭本安装器 + 所有终端窗口`n"
|
||
$msg += "2. 打开新 PowerShell 跑:`n"
|
||
$msg += " `$p=[Environment]::GetEnvironmentVariable('Path','User')`n"
|
||
$msg += " [Environment]::SetEnvironmentVariable('Path',`$p+';'+(npm config get prefix),'User')`n"
|
||
$msg += "3. 再次关闭所有终端, 重新双击 Bookworm-Setup.exe`n`n"
|
||
$msg += "为避免交付无法启动的桌面快捷方式, 本次不创建快捷方式."
|
||
Show-MsgBox $msg "快捷方式创建已拒绝 — Claude 不可达" "OK" "Error"
|
||
Bw-Log "FAIL" "Claude 命令不可达, 跳过桌面快捷方式创建"
|
||
} else {
|
||
New-DesktopShortcuts
|
||
}
|
||
|
||
# ========================================================================
|
||
# Phase 8: OTA 自动更新基础设施
|
||
# ========================================================================
|
||
Log-Phase 8 "OTA 自动更新基础设施"
|
||
|
||
try {
|
||
$otaDir = Join-Path $ClaudeDir '.bw-ota'
|
||
if (-not (Test-Path $otaDir)) { New-Item -ItemType Directory -Path $otaDir -Force | Out-Null }
|
||
|
||
# 8a: 写入 Ed25519 签名公钥 (从主机 admin-private 导出, base64 硬编码)
|
||
$pubKeyB64 = 'MCowBQYDK2VwAyEAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXo='
|
||
$pubKeyPemPath = Join-Path $otaDir 'signing-pubkey.pem'
|
||
$pubKeyContent = "-----BEGIN PUBLIC KEY-----`n$pubKeyB64`n-----END PUBLIC KEY-----`n"
|
||
# 检查是否需要从 admin-private 读取真实公钥
|
||
$adminPub = Join-Path $env:USERPROFILE 'bookworm-admin-private\ed25519-sync.pub.pem'
|
||
if (Test-Path $adminPub) {
|
||
$pubKeyContent = Get-Content -Raw $adminPub
|
||
Log-OK "8a 签名公钥已从 admin-private 读取"
|
||
} else {
|
||
# 从 .claude/tools/ 导出包中的公钥读取 (Portable config 仓库已含)
|
||
$repoPub = Join-Path $ClaudeDir 'bw-signing-pubkey.pem'
|
||
if (Test-Path $repoPub) {
|
||
$pubKeyContent = Get-Content -Raw $repoPub
|
||
Log-OK "8a 签名公钥已从配置仓库读取"
|
||
} else {
|
||
Log-Warn "8a 签名公钥未找到, OTA 验签将不可用"
|
||
}
|
||
}
|
||
[IO.File]::WriteAllText($pubKeyPemPath, $pubKeyContent, [Text.UTF8Encoding]::new($false))
|
||
|
||
# 8b: DPAPI 加密 Gitea 凭证 (复用 Phase 3 已缓存的 credential manager)
|
||
$credFilePath = Join-Path $otaDir 'pull-cred.dpapi'
|
||
$giteaUser = $null; $giteaPass = $null
|
||
try {
|
||
$fillInput = "protocol=https`nhost=code.letcareme.com`n`n"
|
||
$fillOutput = $fillInput | git credential fill 2>$null
|
||
if ($fillOutput) {
|
||
foreach ($line in ($fillOutput -split "`n")) {
|
||
if ($line -match '^username=(.+)') { $giteaUser = $Matches[1].Trim() }
|
||
if ($line -match '^password=(.+)') { $giteaPass = $Matches[1].Trim() }
|
||
}
|
||
}
|
||
} catch {}
|
||
if ($giteaUser -and $giteaPass) {
|
||
Add-Type -AssemblyName System.Security
|
||
$credJson = @{ user = $giteaUser; pass = $giteaPass } | ConvertTo-Json -Compress
|
||
$plainBytes = [Text.Encoding]::UTF8.GetBytes($credJson)
|
||
$encrypted = [Security.Cryptography.ProtectedData]::Protect(
|
||
$plainBytes,
|
||
[Text.Encoding]::UTF8.GetBytes('bookworm-ota-salt'),
|
||
[Security.Cryptography.DataProtectionScope]::CurrentUser
|
||
)
|
||
[IO.File]::WriteAllBytes($credFilePath, $encrypted)
|
||
Log-OK "8b Gitea 凭证已 DPAPI 加密存储"
|
||
} else {
|
||
Log-Warn "8b Gitea 凭证未找到, OTA 更新将不可用"
|
||
}
|
||
|
||
# 8c: OTA 配置文件
|
||
$otaConfig = @{
|
||
repoUrl = 'https://code.letcareme.com/bookworm/bookworm-smart-assistant'
|
||
checkInterval = 86400
|
||
lastCheck = 0
|
||
autoUpdate = $false
|
||
channel = 'stable'
|
||
} | ConvertTo-Json -Depth 4
|
||
$otaConfigPath = Join-Path $otaDir 'config.json'
|
||
if (-not (Test-Path $otaConfigPath)) {
|
||
[IO.File]::WriteAllText($otaConfigPath, $otaConfig, [Text.UTF8Encoding]::new($false))
|
||
Log-OK "8c OTA 配置已写入"
|
||
} else {
|
||
Log-OK "8c OTA 配置已存在, 保留 lastCheck"
|
||
}
|
||
|
||
# 8d: 复制 bw-ota.ps1 (从安装器同目录或 BootDir)
|
||
$otaScript = Join-Path $otaDir 'bw-ota.ps1'
|
||
$otaSrc = $null
|
||
foreach ($candidate in @(
|
||
(Join-Path $ScriptDir 'bw-ota.ps1'),
|
||
(Join-Path $BootDir 'bw-ota.ps1')
|
||
)) {
|
||
if (Test-Path $candidate) { $otaSrc = $candidate; break }
|
||
}
|
||
if ($otaSrc) {
|
||
Copy-Item -Path $otaSrc -Destination $otaScript -Force
|
||
Log-OK "8d bw-ota.ps1 已部署到 $otaDir"
|
||
} else {
|
||
Log-Warn "8d bw-ota.ps1 未找到, OTA 功能不可用"
|
||
}
|
||
|
||
# 8e: 写入 VERSION 文件 (首次安装基准)
|
||
$versionFile = Join-Path $ClaudeDir 'VERSION'
|
||
if (-not (Test-Path $versionFile)) {
|
||
$statsFile = Join-Path $ClaudeDir 'stats-compiled.json'
|
||
$ver = 'unknown'
|
||
if (Test-Path $statsFile) {
|
||
try { $ver = (Get-Content -Raw $statsFile | ConvertFrom-Json).version -replace '^v', '' } catch {}
|
||
}
|
||
[IO.File]::WriteAllText($versionFile, "$ver`n", [Text.UTF8Encoding]::new($false))
|
||
Log-OK "8e VERSION 文件已写入: $ver"
|
||
}
|
||
|
||
Log-OK "Phase 8 OTA 基础设施就绪"
|
||
} catch {
|
||
Bw-Log "WARN" "Phase 8 OTA 设置失败 (不影响主安装): $_"
|
||
}
|
||
|
||
if ($allOK -and $env:ANTHROPIC_API_KEY) {
|
||
Bw-Log "DONE" "v$BWVersion 安装成功 ($skillCount Skills / $hookCount Hooks)"
|
||
|
||
# ═══ 祝贺闪屏 (2.5 秒自动消失) ═══
|
||
$splash = New-Object System.Windows.Forms.Form
|
||
$splash.FormBorderStyle = "None"
|
||
$splash.StartPosition = "CenterScreen"
|
||
$splash.Size = New-Object System.Drawing.Size(480, 300)
|
||
$splash.BackColor = [System.Drawing.Color]::FromArgb(24, 25, 38)
|
||
$splash.TopMost = $true
|
||
$splash.ShowInTaskbar = $false
|
||
$splash.Opacity = 0.0
|
||
|
||
# 品牌蓝紫装饰条
|
||
$topBar = New-Object System.Windows.Forms.Panel
|
||
$topBar.Location = New-Object System.Drawing.Point(0, 0)
|
||
$topBar.Size = New-Object System.Drawing.Size(480, 4)
|
||
$topBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242)
|
||
$splash.Controls.Add($topBar)
|
||
|
||
# 大勾图标
|
||
$checkLabel = New-Object System.Windows.Forms.Label
|
||
$checkLabel.Location = New-Object System.Drawing.Point(0, 35)
|
||
$checkLabel.Size = New-Object System.Drawing.Size(480, 55)
|
||
$checkLabel.Text = [char]0x2714
|
||
$checkLabel.Font = New-Object System.Drawing.Font("Segoe UI", 36)
|
||
$checkLabel.ForeColor = [System.Drawing.Color]::FromArgb(46, 160, 67)
|
||
$checkLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||
$splash.Controls.Add($checkLabel)
|
||
|
||
# 主标题
|
||
$mainTitle = New-Object System.Windows.Forms.Label
|
||
$mainTitle.Location = New-Object System.Drawing.Point(0, 95)
|
||
$mainTitle.Size = New-Object System.Drawing.Size(480, 38)
|
||
$mainTitle.Text = "Bookworm v$BWVersion 安装成功"
|
||
$mainTitle.Font = New-Object System.Drawing.Font("Segoe UI", 18, [System.Drawing.FontStyle]::Bold)
|
||
$mainTitle.ForeColor = [System.Drawing.Color]::White
|
||
$mainTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||
$splash.Controls.Add($mainTitle)
|
||
|
||
# 副标题
|
||
$subTitle = New-Object System.Windows.Forms.Label
|
||
$subTitle.Location = New-Object System.Drawing.Point(0, 140)
|
||
$subTitle.Size = New-Object System.Drawing.Size(480, 28)
|
||
$subTitle.Text = "$skillCount Skills / $hookCount Hooks / 全部就绪"
|
||
$subTitle.Font = New-Object System.Drawing.Font("Segoe UI", 11)
|
||
$subTitle.ForeColor = [System.Drawing.Color]::FromArgb(160, 170, 200)
|
||
$subTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||
$splash.Controls.Add($subTitle)
|
||
|
||
# 祝福语
|
||
$wish = New-Object System.Windows.Forms.Label
|
||
$wish.Location = New-Object System.Drawing.Point(0, 190)
|
||
$wish.Size = New-Object System.Drawing.Size(480, 30)
|
||
$wish.Text = "善读者,必善造。使用愉快!"
|
||
$wish.Font = New-Object System.Drawing.Font("Segoe UI", 12)
|
||
$wish.ForeColor = [System.Drawing.Color]::FromArgb(88, 101, 242)
|
||
$wish.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||
$splash.Controls.Add($wish)
|
||
|
||
# 底部提示
|
||
$hint = New-Object System.Windows.Forms.Label
|
||
$hint.Location = New-Object System.Drawing.Point(0, 250)
|
||
$hint.Size = New-Object System.Drawing.Size(480, 22)
|
||
$hint.Text = "双击桌面 Bookworm 图标即可随时启动"
|
||
$hint.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||
$hint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130)
|
||
$hint.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||
$splash.Controls.Add($hint)
|
||
|
||
# 底部装饰条
|
||
$bottomBar = New-Object System.Windows.Forms.Panel
|
||
$bottomBar.Location = New-Object System.Drawing.Point(0, 296)
|
||
$bottomBar.Size = New-Object System.Drawing.Size(480, 4)
|
||
$bottomBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242)
|
||
$splash.Controls.Add($bottomBar)
|
||
|
||
# 淡入动画 + 定时关闭
|
||
$splash.Show()
|
||
for ($i = 0; $i -le 10; $i++) {
|
||
$splash.Opacity = $i / 10.0
|
||
$splash.Refresh()
|
||
Start-Sleep -Milliseconds 30
|
||
}
|
||
# 停留 2.5 秒
|
||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||
while ($sw.ElapsedMilliseconds -lt 2500) {
|
||
[System.Windows.Forms.Application]::DoEvents()
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
# 淡出
|
||
for ($i = 10; $i -ge 0; $i--) {
|
||
$splash.Opacity = $i / 10.0
|
||
$splash.Refresh()
|
||
Start-Sleep -Milliseconds 25
|
||
}
|
||
$splash.Close()
|
||
$splash.Dispose()
|
||
|
||
# v3.2.0: Bun crash 降级逻辑已移除 (Claude Code 2.1.x+ 不再使用 Bun, 无需降级到 2.0.1)
|
||
|
||
# v3.0.1: 启动 Bookworm — 彻底绕开 .bat + claude.cmd shim
|
||
# 直接调 claude.ps1 (npm 全局装时生成, PS 原生, 不会经 cmd.exe)
|
||
# 若没 claude.ps1 (极旧 npm), 回退 node 直接跑 claude.js
|
||
if (-not $SkipLaunch) {
|
||
$claudeShim = "$env:APPDATA\npm\claude.ps1"
|
||
$claudeJsRel = "node_modules\@anthropic-ai\claude-code\cli.js"
|
||
$claudeJs = "$env:APPDATA\npm\$claudeJsRel"
|
||
# 构造 pwsh 里跑 claude 的命令 (不用 cmd shim)
|
||
$launchCmd = if (Test-Path $claudeShim) {
|
||
"& '$claudeShim' --dangerously-skip-permissions"
|
||
} elseif (Test-Path $claudeJs) {
|
||
"& node '$claudeJs' --dangerously-skip-permissions"
|
||
} else {
|
||
# 最后回退: 让 pwsh 的 Get-Command 找 claude (可能仍走 .cmd)
|
||
"claude --dangerously-skip-permissions"
|
||
}
|
||
# v3.2.0: 安装完毕不自动启动终端 (用户通过桌面快捷方式启动)
|
||
Bw-Log "INFO" "安装完毕, 用户可通过桌面快捷方式启动 Bookworm"
|
||
}
|
||
|
||
} else {
|
||
Bw-Log "DONE" "安装完成但部分受限 allOK=$allOK hasKey=$($env:ANTHROPIC_API_KEY -ne $null)"
|
||
|
||
$issues = @()
|
||
if (-not $allOK) { $issues += "- Bookworm 配置不完整" }
|
||
if (-not $env:ANTHROPIC_API_KEY) { $issues += "- API 凭证未解密" }
|
||
$issueText = $issues -join "`n"
|
||
|
||
Show-MsgBox "安装完成, 但存在以下问题:`n$issueText`n`n请通过桌面快捷方式启动 Bookworm。`n`n日志: $BWLogFile" "安装警告" "OK" "Warning"
|
||
}
|