Ctrl K
ComunidadGuías…

deploy/windows/tray.ps1

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 {}
}