|
| 1 | +# WindsurfAPI 系统托盘监督器(零依赖:纯 .NET WinForms NotifyIcon)。 |
| 2 | +# 托盘图标 + 右键菜单【打开面板 / 状态 / 重启 / 退出】,双击图标开网页。 |
| 3 | +# 监督 node:消息泵(Application.Run)+ 定时器轮询子进程退出,按退出码路由 |
| 4 | +# 75/0 重启、连续崩溃 5 次停。用 `cmd /c node ... >> log` 让 cmd 自己抽干 stdout |
| 5 | +# (消息泵下 Register-ObjectEvent 不可靠且可能死锁),$proc.ExitCode 是 node 的 |
| 6 | +# 退出码(cmd /c 透传),taskkill /T 杀 cmd 会连 node 一起杀。 |
| 7 | +$ErrorActionPreference = 'Stop' |
| 8 | +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } |
| 9 | +# 早期启动追踪 + 顶层错误捕获:hidden/wscript 下无控制台,任何未捕获错误会静默 |
| 10 | +# 杀掉进程且不留痕。把致命错误写到 exe 旁的 tray-error.log,便于诊断闪退。 |
| 11 | +$__traceLog = Join-Path $PSScriptRoot 'tray-error.log' |
| 12 | +function __trace($m) { try { Add-Content -Path $__traceLog -Value ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 } catch {} } |
| 13 | +__trace 'tray.ps1 start' |
| 14 | +try { |
| 15 | +Add-Type -AssemblyName System.Windows.Forms |
| 16 | +Add-Type -AssemblyName System.Drawing |
| 17 | +__trace 'assemblies loaded' |
| 18 | + |
| 19 | +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path |
| 20 | +Set-Location $Root |
| 21 | +__trace "root=$Root" |
| 22 | +$LogDir = Join-Path $Root 'logs' |
| 23 | +if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } |
| 24 | +$PidFile = Join-Path $LogDir 'windsurfapi.pid' |
| 25 | + |
| 26 | +# 预检 Node |
| 27 | +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { |
| 28 | + [System.Windows.Forms.MessageBox]::Show('未检测到 Node.js。请从 https://nodejs.org 安装 Node 20+ 后重试。', 'WindsurfAPI', 'OK', 'Error') | Out-Null |
| 29 | + exit 1 |
| 30 | +} |
| 31 | + |
| 32 | +# language server 仅 Linux/macOS;托盘默认走纯 HTTP 主路 + 本机绑定。 |
| 33 | +if (-not $env:DEVIN_CONNECT -and -not $env:DEVIN_ONLY) { $env:DEVIN_CONNECT = '1' } |
| 34 | +if (-not $env:HOST -and -not $env:BIND_HOST) { $env:HOST = '127.0.0.1' } |
| 35 | +if (-not $env:LOG_LEVEL) { $env:LOG_LEVEL = 'info' } |
| 36 | + |
| 37 | +# 强随机密钥(与 start.ps1 同格式)。 |
| 38 | +function New-Secret([int]$bytes = 24) { |
| 39 | + $buf = New-Object 'System.Byte[]' $bytes |
| 40 | + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() |
| 41 | + $rng.GetBytes($buf); $rng.Dispose() |
| 42 | + return ([Convert]::ToBase64String($buf)) -replace '\+', '-' -replace '/', '_' -replace '=', '' |
| 43 | +} |
| 44 | + |
| 45 | +# 首次引导:无 .env 则生成 API_KEY + DASHBOARD_PASSWORD 并写入(无 BOM)。 |
| 46 | +$envPath = Join-Path $Root '.env' |
| 47 | +$genMsg = $null |
| 48 | +if (-not (Test-Path $envPath)) { |
| 49 | + $apiKey = 'sk-windsurf-' + (New-Secret 24) |
| 50 | + $pw = New-Secret 18 |
| 51 | + $lines = @( |
| 52 | + "# --- generated by tray.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ---", |
| 53 | + "PORT=3003", "HOST=127.0.0.1", "DEVIN_CONNECT=1", |
| 54 | + "API_KEY=$apiKey", "DASHBOARD_PASSWORD=$pw" |
| 55 | + ) |
| 56 | + [System.IO.File]::WriteAllText($envPath, ($lines -join "`r`n") + "`r`n", (New-Object System.Text.UTF8Encoding($false))) |
| 57 | + $genMsg = "已生成并写入 .env:`n`n面板密码 (DASHBOARD_PASSWORD):`n $pw`n`nAPI Key (API_KEY):`n $apiKey`n`n请截图妥善保存。" |
| 58 | +} |
| 59 | + |
| 60 | +# PLACEHOLDER_TRAY_BODY |
| 61 | + |
| 62 | +# 从 .env 读 PORT / 密码 / API Key(勿硬编码)。每次启动都读,使"复制密码"菜单 |
| 63 | +# 在非首次启动时也能用(不只首次生成那一刻)。 |
| 64 | +$Port = 3003 |
| 65 | +$script:DashPw = '' |
| 66 | +$script:ApiKey = '' |
| 67 | +foreach ($line in (Get-Content $envPath -EA SilentlyContinue)) { |
| 68 | + if ($line -match '^\s*PORT\s*=\s*(\d+)') { $Port = [int]$Matches[1] } |
| 69 | + elseif ($line -match '^\s*DASHBOARD_PASSWORD\s*=\s*(.+?)\s*$') { $script:DashPw = $Matches[1] } |
| 70 | + elseif ($line -match '^\s*API_KEY\s*=\s*(.+?)\s*$') { $script:ApiKey = $Matches[1] } |
| 71 | +} |
| 72 | +$DashUrl = "http://127.0.0.1:$Port/dashboard" |
| 73 | + |
| 74 | +# ─── 监督状态 ─── |
| 75 | +$script:proc = $null # 当前 cmd /c node 子进程 |
| 76 | +$script:crashStreak = 0 |
| 77 | +$script:MaxCrash = 5 |
| 78 | +$script:stopping = $false # 用户请求退出:退出循环不再重拉 |
| 79 | +$Date = Get-Date -Format 'yyyy-MM-dd' |
| 80 | +$SupLog = Join-Path $LogDir "supervisor-$Date.log" |
| 81 | +$NodeLog = Join-Path $LogDir "node-$Date.log" |
| 82 | + |
| 83 | +function Write-Sup([string]$m) { |
| 84 | + Add-Content -Path $SupLog -Value ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 |
| 85 | +} |
| 86 | + |
| 87 | +# 启动一个 node 子进程。直接 spawn node(不经 cmd /c —— 用 ProcessStartInfo 传 |
| 88 | +# `>>` 重定向串会被 cmd 误解析报 "syntax incorrect"),CreateNoWindow 无黑窗, |
| 89 | +# 异步事件把 stdout/stderr 抽到 node 日志(消息泵下 Register-ObjectEvent 正常触发)。 |
| 90 | +# 直接 spawn node 时 $proc.ExitCode 可靠(与 run.ps1 同法),75/0 路由才成立。 |
| 91 | +$script:nodeEvents = @() |
| 92 | +function Start-Node { |
| 93 | + $psi = New-Object System.Diagnostics.ProcessStartInfo |
| 94 | + $psi.FileName = 'node' |
| 95 | + $psi.Arguments = 'src\index.js' |
| 96 | + $psi.WorkingDirectory = $Root |
| 97 | + $psi.UseShellExecute = $false |
| 98 | + $psi.RedirectStandardOutput = $true |
| 99 | + $psi.RedirectStandardError = $true |
| 100 | + $psi.CreateNoWindow = $true |
| 101 | + $p = New-Object System.Diagnostics.Process |
| 102 | + $p.StartInfo = $psi |
| 103 | + $sink = { if ($null -ne $EventArgs.Data) { Add-Content -Path $Event.MessageData -Value $EventArgs.Data -Encoding UTF8 } } |
| 104 | + $so = Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $sink -MessageData $NodeLog |
| 105 | + $se = Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $sink -MessageData $NodeLog |
| 106 | + $script:nodeEvents = @($so, $se) |
| 107 | + [void]$p.Start() |
| 108 | + $p.BeginOutputReadLine() |
| 109 | + $p.BeginErrorReadLine() |
| 110 | + Set-Content -Path $PidFile -Value $p.Id -Encoding ASCII |
| 111 | + Write-Sup "node started (pid=$($p.Id))" |
| 112 | + return $p |
| 113 | +} |
| 114 | + |
| 115 | +# 注销上一次的 stdout/stderr 事件订阅(防跨重启泄漏)。 |
| 116 | +function Clear-NodeEvents { |
| 117 | + foreach ($e in $script:nodeEvents) { |
| 118 | + if ($e) { Unregister-Event -SourceIdentifier $e.Name -EA SilentlyContinue } |
| 119 | + } |
| 120 | + $script:nodeEvents = @() |
| 121 | +} |
| 122 | + |
| 123 | +# 停当前 node 子进程:taskkill /T(含可能的 LS 孙进程),超时 /F 兜底。 |
| 124 | +# K7 后 accounts.json 原子写 + dirty-flush,/F 安全(不截断、最多丢一个 flush 周期)。 |
| 125 | +function Stop-Node { |
| 126 | + if ($script:proc -and -not $script:proc.HasExited) { |
| 127 | + try { & taskkill /PID $script:proc.Id /T 2>$null | Out-Null } catch {} |
| 128 | + if (-not $script:proc.WaitForExit(3000)) { |
| 129 | + try { & taskkill /F /PID $script:proc.Id /T 2>$null | Out-Null } catch {} |
| 130 | + } |
| 131 | + } |
| 132 | + Clear-NodeEvents |
| 133 | + if (Test-Path $PidFile) { Remove-Item $PidFile -Force -EA SilentlyContinue } |
| 134 | +} |
| 135 | + |
| 136 | +# PLACEHOLDER_TRAY_UI |
| 137 | + |
| 138 | +# ─── 托盘图标 ─── |
| 139 | +$notify = New-Object System.Windows.Forms.NotifyIcon |
| 140 | +# 用系统图标(零资产依赖);有自带 .ico 则优先。 |
| 141 | +$icoPath = Join-Path $PSScriptRoot 'windsurfapi.ico' |
| 142 | +if (Test-Path $icoPath) { $notify.Icon = New-Object System.Drawing.Icon($icoPath) } |
| 143 | +else { $notify.Icon = [System.Drawing.SystemIcons]::Application } |
| 144 | +$notify.Text = "WindsurfAPI ($DashUrl)" # 悬浮提示(≤63 字符) |
| 145 | +$notify.Visible = $true |
| 146 | + |
| 147 | +function Open-Dashboard { Start-Process $DashUrl | Out-Null } |
| 148 | +function Notify-Balloon([string]$title, [string]$text) { |
| 149 | + $notify.BalloonTipTitle = $title; $notify.BalloonTipText = $text |
| 150 | + $notify.ShowBalloonTip(4000) |
| 151 | +} |
| 152 | + |
| 153 | +# 复制文本到剪贴板(WinForms Clipboard 需 STA;powershell -File 默认 STA)。 |
| 154 | +function Copy-ToClipboard([string]$text, [string]$label) { |
| 155 | + if ([string]::IsNullOrEmpty($text)) { |
| 156 | + Notify-Balloon 'WindsurfAPI' "$label 为空(检查 .env)。" |
| 157 | + return |
| 158 | + } |
| 159 | + try { |
| 160 | + [System.Windows.Forms.Clipboard]::SetText($text) |
| 161 | + Notify-Balloon 'WindsurfAPI' "$label 已复制到剪贴板。" |
| 162 | + } catch { |
| 163 | + # 极少数 STA/剪贴板占用失败:退回用 clip.exe 兜底。 |
| 164 | + try { $text | clip.exe; Notify-Balloon 'WindsurfAPI' "$label 已复制(clip)。" } |
| 165 | + catch { Notify-Balloon 'WindsurfAPI' "复制失败:$($_.Exception.Message)" } |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +# ─── 右键菜单 ─── |
| 170 | +$menu = New-Object System.Windows.Forms.ContextMenuStrip |
| 171 | +$miOpen = $menu.Items.Add('打开面板 (Open Dashboard)') |
| 172 | +$miCopyPw = $menu.Items.Add('复制面板密码 (Copy Password)') |
| 173 | +$miCopyKey = $menu.Items.Add('复制 API Key (Copy API Key)') |
| 174 | +$miStatus = $menu.Items.Add('状态 (Status)') |
| 175 | +$miRestart = $menu.Items.Add('重启 (Restart)') |
| 176 | +$menu.Items.Add('-') | Out-Null # 分隔线 |
| 177 | +$miExit = $menu.Items.Add('退出 (Exit)') |
| 178 | +$notify.ContextMenuStrip = $menu |
| 179 | + |
| 180 | +$miOpen.Add_Click({ Open-Dashboard }) |
| 181 | +$notify.Add_DoubleClick({ Open-Dashboard }) |
| 182 | +$miCopyPw.Add_Click({ Copy-ToClipboard $script:DashPw '面板密码 (DASHBOARD_PASSWORD)' }) |
| 183 | +$miCopyKey.Add_Click({ Copy-ToClipboard $script:ApiKey 'API Key' }) |
| 184 | + |
| 185 | +$miStatus.Add_Click({ |
| 186 | + $running = $script:proc -and -not $script:proc.HasExited |
| 187 | + $listening = $false |
| 188 | + try { |
| 189 | + $c = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() |
| 190 | + $listening = [bool]($c | Where-Object { $_.Port -eq $Port }) |
| 191 | + } catch {} |
| 192 | + $state = if ($running -and $listening) { 'RUNNING' } elseif ($listening) { '监听中(pid 未知)' } else { 'STOPPED' } |
| 193 | + Notify-Balloon 'WindsurfAPI 状态' "$state`n端口 $Port ($(if($listening){'监听中'}else{'未监听'}))`n$DashUrl" |
| 194 | +}) |
| 195 | + |
| 196 | +$miRestart.Add_Click({ |
| 197 | + Notify-Balloon 'WindsurfAPI' '正在重启…' |
| 198 | + Stop-Node |
| 199 | + $script:crashStreak = 0 |
| 200 | + $script:proc = Start-Node |
| 201 | +}) |
| 202 | + |
| 203 | +$miExit.Add_Click({ |
| 204 | + $script:stopping = $true |
| 205 | + Stop-Node |
| 206 | + $notify.Visible = $false |
| 207 | + $notify.Dispose() |
| 208 | + [System.Windows.Forms.Application]::Exit() |
| 209 | +}) |
| 210 | + |
| 211 | +# PLACEHOLDER_TRAY_LOOP |
| 212 | + |
| 213 | +# ─── 监督定时器 ─── |
| 214 | +# 消息泵下不能阻塞 WaitForExit(会冻结 UI),改用 1s 定时器轮询子进程是否退出, |
| 215 | +# 按退出码路由重启(75/0)或退避(崩溃),连续崩溃 MaxCrash 次弹窗并停。 |
| 216 | +$script:backoffUntil = [DateTime]::MinValue |
| 217 | +$timer = New-Object System.Windows.Forms.Timer |
| 218 | +$timer.Interval = 1000 |
| 219 | +$timer.Add_Tick({ |
| 220 | + if ($script:stopping) { return } |
| 221 | + if ([DateTime]::Now -lt $script:backoffUntil) { return } |
| 222 | + if ($script:proc -and -not $script:proc.HasExited) { return } # 还在跑 |
| 223 | + |
| 224 | + # 子进程已退出(或首次尚未启动)。 |
| 225 | + if ($script:proc) { |
| 226 | + $code = $script:proc.ExitCode |
| 227 | + Clear-NodeEvents # 注销上个进程的 stdout/stderr 订阅 |
| 228 | + Write-Sup "node exited code=$code" |
| 229 | + if ($code -eq 75 -or $code -eq 0) { |
| 230 | + $script:crashStreak = 0 # 面板重启/OTA(75)或优雅(0)→ 重拉 |
| 231 | + } else { |
| 232 | + $script:crashStreak++ |
| 233 | + if ($script:crashStreak -ge $script:MaxCrash) { |
| 234 | + Write-Sup "CRASH LOOP x$($script:crashStreak) (last=$code). Stopping." |
| 235 | + Notify-Balloon 'WindsurfAPI 已停止' "连续 $($script:crashStreak) 次异常退出(码 $code)。多为端口占用/配置错。见 logs\node-$Date.log" |
| 236 | + $script:proc = $null |
| 237 | + $script:stopping = $true # 停止重拉,但托盘留着让用户看/退出 |
| 238 | + return |
| 239 | + } |
| 240 | + $script:backoffUntil = [DateTime]::Now.AddSeconds([Math]::Min(2 * $script:crashStreak, 10)) |
| 241 | + Write-Sup "non-zero exit ($code), streak=$($script:crashStreak) -> backoff" |
| 242 | + return |
| 243 | + } |
| 244 | + } |
| 245 | + $script:proc = Start-Node |
| 246 | +}) |
| 247 | + |
| 248 | +# ─── 启动 ─── |
| 249 | +$script:proc = Start-Node |
| 250 | +$timer.Start() |
| 251 | +Notify-Balloon 'WindsurfAPI 已启动' "面板:$DashUrl`n右键托盘图标可打开/重启/退出。" |
| 252 | +if ($genMsg) { |
| 253 | + [System.Windows.Forms.MessageBox]::Show($genMsg, 'WindsurfAPI 首次运行', 'OK', 'Information') | Out-Null |
| 254 | + Start-Process $DashUrl | Out-Null # 首次自动开面板 |
| 255 | +} |
| 256 | + |
| 257 | +__trace 'entering message pump' |
| 258 | +# 消息泵:阻塞直到 Exit()。 |
| 259 | +[System.Windows.Forms.Application]::Run() |
| 260 | + |
| 261 | +# 兜底清理(Application.Exit 后)。 |
| 262 | +$timer.Stop() |
| 263 | +if (-not $script:proc -or $script:proc.HasExited) { } else { Stop-Node } |
| 264 | +if (Test-Path $PidFile) { Remove-Item $PidFile -Force -EA SilentlyContinue } |
| 265 | +__trace 'exited cleanly' |
| 266 | +} catch { |
| 267 | + __trace ("FATAL: " + $_.Exception.Message + " @ " + $_.InvocationInfo.ScriptLineNumber) |
| 268 | + try { [System.Windows.Forms.MessageBox]::Show('WindsurfAPI 托盘启动失败:' + $_.Exception.Message, 'WindsurfAPI', 'OK', 'Error') | Out-Null } catch {} |
| 269 | +} |
| 270 | + |
| 271 | + |
| 272 | + |
0 commit comments