Files
2026-04-29 00:28:49 +08:00

339 lines
8.6 KiB
PowerShell

param(
[int[]]$PreferredPorts = @(8011, 8010, 8012, 8013, 8014),
[switch]$NoBrowser,
[switch]$ForceRestart
)
$ErrorActionPreference = "Stop"
function Write-Info {
param([string]$Message)
Write-Host "[launcher] $Message"
}
function Get-UvPath {
$command = Get-Command uv -ErrorAction SilentlyContinue
if ($command -and $command.Source) {
return $command.Source
}
$fallback = Join-Path $HOME ".local\bin\uv.exe"
if (Test-Path -LiteralPath $fallback) {
return $fallback
}
throw "uv.exe was not found. Install uv first or add it to PATH."
}
function Test-Health {
param([int]$Port)
try {
$null = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 2
return $true
} catch {
return $false
}
}
function Get-FreePort {
param([int[]]$Candidates)
foreach ($port in $Candidates) {
$listener = $null
try {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $port)
$listener.Start()
return $port
} catch {
continue
} finally {
if ($listener) {
$listener.Stop()
}
}
}
throw "No free port was found in: $($Candidates -join ', ')"
}
function Get-RunningProcess {
param([int]$Id)
if ($Id -le 0) {
return $null
}
try {
return Get-Process -Id $Id -ErrorAction Stop
} catch {
return $null
}
}
function Get-ListeningProcessId {
param([int]$Port)
try {
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
Select-Object -First 1
if ($connection) {
return [int]$connection.OwningProcess
}
} catch {
}
$lines = netstat -ano -p tcp | Select-String "LISTENING"
foreach ($line in $lines) {
$parts = ($line.Line.Trim() -split "\s+")
if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) {
return [int]$parts[4]
}
}
return 0
}
function Invoke-UiShutdown {
param(
[string]$Url,
[string]$ShutdownToken
)
if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) {
return $false
}
try {
$headers = @{
"X-S1DL-Shutdown-Token" = $ShutdownToken
}
$shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown"
$null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-ForUiShutdown {
param(
[int]$ProcessId,
[int]$Port,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$running = Get-RunningProcess -Id $ProcessId
$listenerProcessId = 0
if ($Port -gt 0) {
$listenerProcessId = Get-ListeningProcessId -Port $Port
}
if (-not $running -and $listenerProcessId -le 0) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
function Load-State {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return $null
}
try {
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
} catch {
return $null
}
}
function Save-State {
param(
[string]$Path,
[int]$ProcessId,
[int]$Port,
[string]$Url,
[string]$ShutdownToken
)
$payload = [ordered]@{
pid = $ProcessId
port = $Port
url = $Url
shutdown_token = $ShutdownToken
started_at = (Get-Date).ToString("s")
}
$payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8
}
function Remove-State {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Force
}
}
$projectRoot = Split-Path -Parent $PSCommandPath
$runtimeDir = Join-Path $projectRoot ".s1dl"
$statePath = Join-Path $runtimeDir "web-ui-state.json"
$stdoutPath = Join-Path $runtimeDir "web-ui.stdout.log"
$stderrPath = Join-Path $runtimeDir "web-ui.stderr.log"
New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null
$state = Load-State -Path $statePath
if ($state -and -not $ForceRestart) {
$existingProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($existingProcess -and (Test-Health -Port ([int]$state.port))) {
$existingUrl = "http://127.0.0.1:$($state.port)/"
Write-Info "UI is already running at $existingUrl"
if (-not $NoBrowser) {
Start-Process $existingUrl | Out-Null
}
exit 0
}
}
if ($ForceRestart -and $state) {
$existingStopped = $false
if ($state.url -and $state.shutdown_token) {
Write-Info "Requesting shutdown for previous tracked UI"
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
$existingStopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
}
}
if (-not $existingStopped) {
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1
}
}
Remove-State -Path $statePath
}
if ($ForceRestart) {
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$listenerProcessId = Get-ListeningProcessId -Port $port
if ($listenerProcessId -gt 0) {
Write-Info "Stopping existing UI listener $listenerProcessId on port $port"
Stop-Process -Id $listenerProcessId -Force
Start-Sleep -Seconds 1
}
}
}
}
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$url = "http://127.0.0.1:$port/"
Write-Info "Found an existing running UI at $url"
Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url -ShutdownToken ""
if (-not $NoBrowser) {
Start-Process $url | Out-Null
}
exit 0
}
}
$uvPath = Get-UvPath
Write-Info "Using uv at $uvPath"
Write-Info "Syncing project environment"
& $uvPath sync --directory $projectRoot
if ($LASTEXITCODE -ne 0) {
throw "uv sync failed with exit code $LASTEXITCODE"
}
$pythonPath = Join-Path $projectRoot ".venv\Scripts\python.exe"
if (-not (Test-Path -LiteralPath $pythonPath)) {
throw "Python executable was not found at $pythonPath"
}
$portToUse = Get-FreePort -Candidates $PreferredPorts
$url = "http://127.0.0.1:$portToUse/"
$shutdownToken = [guid]::NewGuid().ToString("N")
if (Test-Path -LiteralPath $stdoutPath) {
Remove-Item -LiteralPath $stdoutPath -Force
}
if (Test-Path -LiteralPath $stderrPath) {
Remove-Item -LiteralPath $stderrPath -Force
}
$arguments = @(
"-m",
"uvicorn",
"sentinel_orbit_downloader.api.app:app",
"--host",
"127.0.0.1",
"--port",
"$portToUse"
)
Write-Info "Starting UI at $url"
try {
$env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken
$process = Start-Process `
-FilePath $pythonPath `
-ArgumentList $arguments `
-WorkingDirectory $projectRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-PassThru
} finally {
Remove-Item Env:S1DL_UI_SHUTDOWN_TOKEN -ErrorAction SilentlyContinue
}
$started = $false
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 1
if (Test-Health -Port $portToUse) {
$started = $true
break
}
}
if (-not $started) {
$running = Get-RunningProcess -Id $process.Id
if ($running) {
Stop-Process -Id $running.Id -Force
}
$stderrTail = ""
if (Test-Path -LiteralPath $stderrPath) {
$stderrTail = (Get-Content -LiteralPath $stderrPath -Tail 20) -join [Environment]::NewLine
}
if ($stderrTail) {
throw "UI failed to start.`n$stderrTail"
}
throw "UI failed to start."
}
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken
$listenerProcessId = Get-ListeningProcessId -Port $portToUse
if ($listenerProcessId -gt 0) {
Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url -ShutdownToken $shutdownToken
} else {
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken
}
Write-Info "UI is ready: $url"
if (-not $NoBrowser) {
Start-Process $url | Out-Null
}