339 lines
8.6 KiB
PowerShell
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
|
|
}
|