deploy/windows/tray.ps1
Rama master · réplica de lectura del repositorio original.
# WindsurfAPI 系统托盘监督器(零依赖:纯 .NET WinForms NotifyIcon)。
# 托盘图标 + 右键菜单【打开面板 / 状态 / 重启 / 退出】,双击图标开网页。
# 监督 node:消息泵(Application.Run)+ 定时器轮询子进程退出,按退出码路由
# 75/0 重启、连续崩溃 5 次停。用 `cmd /c node ... >> log` 让 cmd 自己抽干 stdout
# (消息泵下 Register-ObjectEvent 不可靠且可能死锁),$proc.ExitCode 是 node 的
# 退出码(cmd /c 透传),taskkill /T 杀 cmd 会连 node 一起杀。
$ErrorActionPreference = 'Stop'
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
# 早期启动追踪 + 顶层错误捕获:hidden/wscript 下无控制台,任何未捕获错误会静默
# 杀掉进程且不留痕。把致命错误写到 exe 旁的 tray-error.log,便于诊断闪退。
$__traceLog = Join-Path $PSScriptRoot 'tray-error.log'
function __trace($m) { try { Add-Content -Path $__traceLog -Value ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 } catch {} }
__trace 'tray.ps1 start'
try {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
__trace 'assemblies loaded'
# 双模式:同目录有 windsurfapi.exe → EXE 模式(零依赖,启单 exe,不需 Node/源码,
# 用于解压即用的 Windows 分发包);否则 → 源码模式(启 node src\index.js,需本机
# Node,用于 clone 仓库开发)。EXE 模式下 .env/logs 都在 exe 旁(与 config.js 的
# dirname(process.execPath) 一致,密码/数据同目录),$Root 即脚本目录。
$ExePath = Join-Path $PSScriptRoot 'windsurfapi.exe'
$script:ExeMode = Test-Path $ExePath
if ($script:ExeMode) {
$Root = $PSScriptRoot
} else {
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
}
Set-Location $Root
__trace "root=$Root mode=$(if($script:ExeMode){'exe'}else{'source'})"
$LogDir = Join-Path $Root 'logs'
if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$PidFile = Join-Path $LogDir 'windsurfapi.pid'
# 预检:源码模式需要 Node;EXE 模式自带运行时,跳过。
if (-not $script:ExeMode -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
[System.Windows.Forms.MessageBox]::Show('未检测到 Node.js。请从 https://nodejs.org 安装 Node 22.13+ 后重试。(或改用解压即用的 Windows 单 exe 分发包,无需安装 Node)', 'WindsurfAPI', 'OK', 'Error') | Out-Null
exit 1
}
# language server 仅 Linux/macOS;托盘默认走纯 HTTP 主路 + 本机绑定。
if (-not $env:DEVIN_CONNECT -and -not $env:DEVIN_ONLY) { $env:DEVIN_CONNECT = '1' }
if (-not $env:HOST -and -not $env:BIND_HOST) { $env:HOST = '127.0.0.1' }
if (-not $env:LOG_LEVEL) { $env:LOG_LEVEL = 'info' }
# 强随机密钥(与 start.ps1 同格式)。
function New-Secret([int]$bytes = 24) {
$buf = New-Object 'System.Byte[]' $bytes
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($buf); $rng.Dispose()
return ([Convert]::ToBase64String($buf)) -replace '\+', '-' -replace '/', '_' -replace '=', ''
}
# 首次引导:无 .env 则生成 API_KEY + DASHBOARD_PASSWORD 并写入(无 BOM)。
$envPath = Join-Path $Root '.env'
$genMsg = $null
if (-not (Test-Path $envPath)) {
$apiKey = 'sk-windsurf-' + (New-Secret 24)
$pw = New-Secret 18
$lines = @(
"# --- generated by tray.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ---",
"PORT=3003", "HOST=127.0.0.1", "DEVIN_CONNECT=1",
"API_KEY=$apiKey", "DASHBOARD_PASSWORD=$pw"
)
[System.IO.File]::WriteAllText($envPath, ($lines -join "`r`n") + "`r`n", (New-Object System.Text.UTF8Encoding($false)))
# .env 存 API_KEY + DASHBOARD_PASSWORD。Windows 默认继承目录 ACL,收紧为仅
# 当前用户;失败只警告(best-effort,不能阻止托盘启动)。
$aclUser = if ($env:USERDOMAIN) { "$env:USERDOMAIN\$env:USERNAME" } else { $env:USERNAME }
try {
& icacls $envPath /inheritance:r /grant:r "${aclUser}:(F)" *> $null
if ($LASTEXITCODE -ne 0) { Write-Warning "icacls could not restrict .env (exit $LASTEXITCODE)" }
} catch {
Write-Warning "icacls could not restrict .env: $($_.Exception.Message)"
}
$genMsg = "已生成并写入 .env:`n`n面板密码 (DASHBOARD_PASSWORD):`n $pw`n`nAPI Key (API_KEY):`n $apiKey`n`n请截图妥善保存。"
}
# 从 .env 读 PORT / 密码 / API Key(勿硬编码)。每次启动都读,使"复制密码"菜单
# 在非首次启动时也能用(不只首次生成那一刻)。
$Port = 3003
$script:DashPw = ''
$script:ApiKey = ''
foreach ($line in (Get-Content $envPath -EA SilentlyContinue)) {
if ($line -match '^\s*PORT\s*=\s*(\d+)') { $Port = [int]$Matches[1] }
elseif ($line -match '^\s*DASHBOARD_PASSWORD\s*=\s*(.+?)\s*$') { $script:DashPw = $Matches[1] }
elseif ($line -match '^\s*API_KEY\s*=\s*(.+?)\s*$') { $script:ApiKey = $Matches[1] }
}
$DashUrl = "http://127.0.0.1:$Port/dashboard"
# ─── 监督状态 ───
$script:proc = $null # 当前 cmd /c node 子进程
$script:crashStreak = 0
$script:MaxCrash = 5
$script:stopping = $false # 用户请求退出:退出循环不再重拉
$Date = Get-Date -Format 'yyyy-MM-dd'
$SupLog = Join-Path $LogDir "supervisor-$Date.log"
$NodeLog = Join-Path $LogDir "node-$Date.log"
function Write-Sup([string]$m) {
Add-Content -Path $SupLog -Value ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8
}
# 启动一个 node 子进程。直接 spawn node(不经 cmd /c —— 用 ProcessStartInfo 传
# `>>` 重定向串会被 cmd 误解析报 "syntax incorrect"),CreateNoWindow 无黑窗,
# 异步事件把 stdout/stderr 抽到 node 日志(消息泵下 Register-ObjectEvent 正常触发)。
# 直接 spawn node 时 $proc.ExitCode 可靠(与 run.ps1 同法),75/0 路由才成立。
$script:nodeEvents = @()
function Start-Node {
$psi = New-Object System.Diagnostics.ProcessStartInfo
if ($script:ExeMode) {
$psi.FileName = $ExePath # 单 exe 自带 Node 运行时
$psi.Arguments = ''
} else {
$psi.FileName = 'node'
$psi.Arguments = 'src\index.js'
}
$psi.WorkingDirectory = $Root
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
# 托盘自己负责首次开面板,禁掉子进程再开一次(避免双开浏览器)。
$psi.EnvironmentVariables['WINDSURFAPI_NO_OPEN'] = '1'
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
$sink = { if ($null -ne $EventArgs.Data) { Add-Content -Path $Event.MessageData -Value $EventArgs.Data -Encoding UTF8 } }
$so = Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $sink -MessageData $NodeLog
$se = Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $sink -MessageData $NodeLog
$script:nodeEvents = @($so, $se)
[void]$p.Start()
$p.BeginOutputReadLine()
$p.BeginErrorReadLine()
Set-Content -Path $PidFile -Value $p.Id -Encoding ASCII
Write-Sup "$(if($script:ExeMode){'exe'}else{'node'}) started (pid=$($p.Id))"
return $p
}
# 注销上一次的 stdout/stderr 事件订阅(防跨重启泄漏)。
function Clear-NodeEvents {
foreach ($e in $script:nodeEvents) {
if ($e) { Unregister-Event -SourceIdentifier $e.Name -EA SilentlyContinue }
}
$script:nodeEvents = @()
}
# 停当前 node 子进程:taskkill /T(含可能的 LS 孙进程),超时 /F 兜底。
# K7 后 accounts.json 原子写 + dirty-flush,/F 安全(不截断、最多丢一个 flush 周期)。
function Stop-Node {
if ($script:proc -and -not $script:proc.HasExited) {
try { & taskkill /PID $script:proc.Id /T 2>$null | Out-Null } catch {}
if (-not $script:proc.WaitForExit(3000)) {
try { & taskkill /F /PID $script:proc.Id /T 2>$null | Out-Null } catch {}
}
}
Clear-NodeEvents
if (Test-Path $PidFile) { Remove-Item $PidFile -Force -EA SilentlyContinue }
}
# ─── 托盘图标 ───
$notify = New-Object System.Windows.Forms.NotifyIcon
# 用系统图标(零资产依赖);有自带 .ico 则优先。
$icoPath = Join-Path $PSScriptRoot 'windsurfapi.ico'
if (Test-Path $icoPath) { $notify.Icon = New-Object System.Drawing.Icon($icoPath) }
else { $notify.Icon = [System.Drawing.SystemIcons]::Application }
$notify.Text = "WindsurfAPI ($DashUrl)" # 悬浮提示(≤63 字符)
$notify.Visible = $true
function Open-Dashboard { Start-Process $DashUrl | Out-Null }
function Notify-Balloon([string]$title, [string]$text) {
$notify.BalloonTipTitle = $title; $notify.BalloonTipText = $text
$notify.ShowBalloonTip(4000)
}
# 复制文本到剪贴板(WinForms Clipboard 需 STA;powershell -File 默认 STA)。
function Copy-ToClipboard([string]$text, [string]$label) {
if ([string]::IsNullOrEmpty($text)) {
Notify-Balloon 'WindsurfAPI' "$label 为空(检查 .env)。"
return
}
try {
[System.Windows.Forms.Clipboard]::SetText($text)
Notify-Balloon 'WindsurfAPI' "$label 已复制到剪贴板。"
} catch {
# 极少数 STA/剪贴板占用失败:退回用 clip.exe 兜底。
try { $text | clip.exe; Notify-Balloon 'WindsurfAPI' "$label 已复制(clip)。" }
catch { Notify-Balloon 'WindsurfAPI' "复制失败:$($_.Exception.Message)" }
}
}
# ─── 右键菜单 ───
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$miOpen = $menu.Items.Add('打开面板 (Open Dashboard)')
$miCopyPw = $menu.Items.Add('复制面板密码 (Copy Password)')
$miCopyKey = $menu.Items.Add('复制 API Key (Copy API Key)')
$miStatus = $menu.Items.Add('状态 (Status)')
$miRestart = $menu.Items.Add('重启 (Restart)')
$menu.Items.Add('-') | Out-Null # 分隔线
$miExit = $menu.Items.Add('退出 (Exit)')
$notify.ContextMenuStrip = $menu
$miOpen.Add_Click({ Open-Dashboard })
$notify.Add_DoubleClick({ Open-Dashboard })
$miCopyPw.Add_Click({ Copy-ToClipboard $script:DashPw '面板密码 (DASHBOARD_PASSWORD)' })
$miCopyKey.Add_Click({ Copy-ToClipboard $script:ApiKey 'API Key' })
$miStatus.Add_Click({
$running = $script:proc -and -not $script:proc.HasExited
$listening = $false
try {
$c = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
$listening = [bool]($c | Where-Object { $_.Port -eq $Port })
} catch {}
$state = if ($running -and $listening) { 'RUNNING' } elseif ($listening) { '监听中(pid 未知)' } else { 'STOPPED' }
Notify-Balloon 'WindsurfAPI 状态' "$state`n端口 $Port ($(if($listening){'监听中'}else{'未监听'}))`n$DashUrl"
})
$miRestart.Add_Click({
Notify-Balloon 'WindsurfAPI' '正在重启…'
Stop-Node
$script:crashStreak = 0
$script:proc = Start-Node
})
$miExit.Add_Click({
$script:stopping = $true
Stop-Node
$notify.Visible = $false
$notify.Dispose()
[System.Windows.Forms.Application]::Exit()
})
# ─── 监督定时器 ───
# 消息泵下不能阻塞 WaitForExit(会冻结 UI),改用 1s 定时器轮询子进程是否退出,
# 按退出码路由重启(75/0)或退避(崩溃),连续崩溃 MaxCrash 次弹窗并停。
$script:backoffUntil = [DateTime]::MinValue
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 1000
$timer.Add_Tick({
if ($script:stopping) { return }
if ([DateTime]::Now -lt $script:backoffUntil) { return }
if ($script:proc -and -not $script:proc.HasExited) { return } # 还在跑
# 子进程已退出(或首次尚未启动)。
if ($script:proc) {
$code = $script:proc.ExitCode
Clear-NodeEvents # 注销上个进程的 stdout/stderr 订阅
Write-Sup "node exited code=$code"
if ($code -eq 75 -or $code -eq 0) {
$script:crashStreak = 0 # 面板重启/OTA(75)或优雅(0)→ 重拉
} else {
$script:crashStreak++
if ($script:crashStreak -ge $script:MaxCrash) {
Write-Sup "CRASH LOOP x$($script:crashStreak) (last=$code). Stopping."
Notify-Balloon 'WindsurfAPI 已停止' "连续 $($script:crashStreak) 次异常退出(码 $code)。多为端口占用/配置错。见 logs\node-$Date.log"
$script:proc = $null
$script:stopping = $true # 停止重拉,但托盘留着让用户看/退出
return
}
$script:backoffUntil = [DateTime]::Now.AddSeconds([Math]::Min(2 * $script:crashStreak, 10))
Write-Sup "non-zero exit ($code), streak=$($script:crashStreak) -> backoff"
return
}
}
$script:proc = Start-Node
})
# ─── 启动 ───
$script:proc = Start-Node
$timer.Start()
Notify-Balloon 'WindsurfAPI 已启动' "面板:$DashUrl`n右键托盘图标可打开/重启/退出。"
if ($genMsg) {
[System.Windows.Forms.MessageBox]::Show($genMsg, 'WindsurfAPI 首次运行', 'OK', 'Information') | Out-Null
Start-Process $DashUrl | Out-Null # 首次自动开面板
}
__trace 'entering message pump'
# 消息泵:阻塞直到 Exit()。
[System.Windows.Forms.Application]::Run()
# 兜底清理(Application.Exit 后)。
$timer.Stop()
if (-not $script:proc -or $script:proc.HasExited) { } else { Stop-Node }
if (Test-Path $PidFile) { Remove-Item $PidFile -Force -EA SilentlyContinue }
__trace 'exited cleanly'
} catch {
__trace ("FATAL: " + $_.Exception.Message + " @ " + $_.InvocationInfo.ScriptLineNumber)
try { [System.Windows.Forms.MessageBox]::Show('WindsurfAPI 托盘启动失败:' + $_.Exception.Message, 'WindsurfAPI', 'OK', 'Error') | Out-Null } catch {}
}