diff --git a/install_backend_service.ps1 b/install_backend_service.ps1 index 29ed061..ab919bf 100644 --- a/install_backend_service.ps1 +++ b/install_backend_service.ps1 @@ -43,7 +43,38 @@ $publishDir = Join-Path $scriptDir "service_host\publish" $serviceExe = Join-Path $publishDir "ScreenJob.WindowsServiceHost.exe" $logDir = Join-Path $scriptDir "screenjob_runs\service" +$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($null -ne $existingService) { + if (-not $ForceReinstall) { + throw "Service '$ServiceName' already exists. Re-run with -ForceReinstall to replace it." + } + + if ($PSCmdlet.ShouldProcess($ServiceName, "Remove existing service")) { + if ($existingService.Status -ne "Stopped") { + Stop-Service -Name $ServiceName -Force -ErrorAction Stop + } + + & sc.exe delete $ServiceName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete existing service '$ServiceName' (sc.exe exit code $LASTEXITCODE)." + } + + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline) { + $stillThere = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($null -eq $stillThere) { + break + } + Start-Sleep -Milliseconds 300 + } + } +} + if ($PSCmdlet.ShouldProcess($projectFile, "Publish Windows service host")) { + if (Test-Path -LiteralPath $serviceExe) { + Remove-Item -LiteralPath $serviceExe -Force -ErrorAction SilentlyContinue + } + & $dotnetCmd.Source publish ` $projectFile ` -c Release ` @@ -63,24 +94,6 @@ if (-not (Test-Path -LiteralPath $serviceExe)) { $binaryPath = "`"$serviceExe`" --backend-script `"$backendScript`" --working-dir `"$scriptDir`" --log-dir `"$logDir`"" -$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue -if ($null -ne $existingService) { - if (-not $ForceReinstall) { - throw "Service '$ServiceName' already exists. Re-run with -ForceReinstall to replace it." - } - - if ($PSCmdlet.ShouldProcess($ServiceName, "Remove existing service")) { - if ($existingService.Status -ne "Stopped") { - Stop-Service -Name $ServiceName -Force -ErrorAction Stop - } - - & sc.exe delete $ServiceName | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "Failed to delete existing service '$ServiceName' (sc.exe exit code $LASTEXITCODE)." - } - } -} - if ($PSCmdlet.ShouldProcess($ServiceName, "Create service")) { New-Service ` -Name $ServiceName ` diff --git a/screenjob_tray.ps1 b/screenjob_tray.ps1 index 6b795e4..609c1ef 100644 --- a/screenjob_tray.ps1 +++ b/screenjob_tray.ps1 @@ -28,7 +28,12 @@ function Read-EnvConfig { } $parts = $trimmed.Split("=", 2) if ($parts.Count -eq 2) { - $result[$parts[0].Trim()] = $parts[1].Trim() + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $result[$key] = $value } } return $result @@ -79,17 +84,98 @@ function Get-DashboardUrl { $envFile = Join-Path $scriptDir ".env" $envVars = Read-EnvConfig -EnvFilePath $envFile - $host = $defaultHost - $port = $defaultPort + $dashboardHost = $defaultHost + $dashboardPort = $defaultPort if ($envVars.ContainsKey("SCREENJOB_HOST") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_HOST"])) { - $host = $envVars["SCREENJOB_HOST"] + $dashboardHost = $envVars["SCREENJOB_HOST"] } if ($envVars.ContainsKey("SCREENJOB_PORT") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_PORT"])) { - $port = $envVars["SCREENJOB_PORT"] + $dashboardPort = $envVars["SCREENJOB_PORT"] } - return "http://{0}:{1}/" -f $host, $port + $connectHost = Resolve-ConnectHost -ConfiguredHost $dashboardHost + return "http://{0}:{1}/" -f $connectHost, $dashboardPort +} + +function Resolve-ConnectHost { + param([string]$ConfiguredHost) + + if ([string]::IsNullOrWhiteSpace($ConfiguredHost)) { + return "127.0.0.1" + } + + switch ($ConfiguredHost.Trim().ToLowerInvariant()) { + "0.0.0.0" { return "127.0.0.1" } + "::" { return "127.0.0.1" } + "*" { return "127.0.0.1" } + default { return $ConfiguredHost } + } +} + +function Get-HealthCheckHosts { + param([string]$ConfiguredHost) + + if ([string]::IsNullOrWhiteSpace($ConfiguredHost)) { + return @("127.0.0.1", "localhost") + } + + $normalized = $ConfiguredHost.Trim().ToLowerInvariant() + switch ($normalized) { + "0.0.0.0" { return @("127.0.0.1", "localhost", "::1") } + "::" { return @("127.0.0.1", "localhost", "::1") } + "*" { return @("127.0.0.1", "localhost", "::1") } + default { return @($ConfiguredHost) } + } +} + +function Test-TcpEndpoint { + param( + [Parameter(Mandatory = $true)][string]$HostName, + [Parameter(Mandatory = $true)][int]$Port, + [int]$TimeoutMs = 1200 + ) + + $client = New-Object System.Net.Sockets.TcpClient + try { + $async = $client.BeginConnect($HostName, $Port, $null, $null) + $connected = $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false) + if (-not $connected) { + return $false + } + $client.EndConnect($async) | Out-Null + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Get-BackendReachability { + $envFile = Join-Path $scriptDir ".env" + $envVars = Read-EnvConfig -EnvFilePath $envFile + $configuredHost = $defaultHost + $configuredPort = $defaultPort + + if ($envVars.ContainsKey("SCREENJOB_HOST") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_HOST"])) { + $configuredHost = $envVars["SCREENJOB_HOST"] + } + if ($envVars.ContainsKey("SCREENJOB_PORT") -and -not [string]::IsNullOrWhiteSpace($envVars["SCREENJOB_PORT"])) { + $configuredPort = $envVars["SCREENJOB_PORT"] + } + + $portNumber = 8787 + [void][int]::TryParse([string]$configuredPort, [ref]$portNumber) + $hostsToTry = Get-HealthCheckHosts -ConfiguredHost $configuredHost + + foreach ($candidateHost in $hostsToTry) { + if (Test-TcpEndpoint -HostName $candidateHost -Port $portNumber) { + return $true + } + } + + return $false } function Update-TrayState { @@ -100,9 +186,20 @@ function Update-TrayState { ) $status = Get-ServiceStatusSafe -Name $Name - $StatusItem.Text = "Status: $status" + $isBackendReachable = Get-BackendReachability - switch ($status) { + $displayStatus = $status + if ($status -eq "Running" -and -not $isBackendReachable) { + $displayStatus = "Running (Backend Down)" + } elseif ($status -eq "Stopped" -and $isBackendReachable) { + $displayStatus = "Stopped (Backend Up)" + } elseif ($status -eq "NotInstalled" -and $isBackendReachable) { + $displayStatus = "NotInstalled (Backend Up)" + } + + $StatusItem.Text = "Status: $displayStatus" + + switch ($displayStatus) { "Running" { $NotifyIcon.Icon = [System.Drawing.SystemIcons]::Information } @@ -114,7 +211,7 @@ function Update-TrayState { } } - $tooltip = "ScreenJob Backend: $status" + $tooltip = "ScreenJob Backend: $displayStatus" if ($tooltip.Length -gt 63) { $tooltip = $tooltip.Substring(0, 63) } diff --git a/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs b/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs index 9cb24c0..01492f7 100644 --- a/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs +++ b/service_host/ScreenJob.WindowsServiceHost/BackendProcessService.cs @@ -63,8 +63,12 @@ internal sealed class BackendProcessService : BackgroundService try { await _backendProcess.WaitForExitAsync(stoppingToken); - LogStdOut($"Backend process exited with code {_backendProcess.ExitCode}."); - _logger.LogWarning("Backend process exited with code {ExitCode}.", _backendProcess.ExitCode); + var exitCode = _backendProcess.ExitCode; + LogStdErr($"Backend process exited unexpectedly with code {exitCode}."); + _logger.LogError("Backend process exited unexpectedly with code {ExitCode}.", exitCode); + Environment.ExitCode = exitCode == 0 ? 1 : exitCode; + throw new InvalidOperationException( + $"Backend process ended unexpectedly. Service host exit code: {Environment.ExitCode}."); } catch (OperationCanceledException) { diff --git a/start_backend.ps1 b/start_backend.ps1 index 4b1992a..cc149d8 100644 --- a/start_backend.ps1 +++ b/start_backend.ps1 @@ -15,24 +15,75 @@ function Test-EnvVarLine { return [bool](Select-String -Path $FilePath -Pattern ("^\s*" + [regex]::Escape($Name) + "=") -Quiet) } -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { +function Resolve-PythonExecutable { $venvPython = Join-Path $scriptDir ".venv\Scripts\python.exe" - if (-not (Test-Path -LiteralPath $venvPython)) { - throw "Python was not found in PATH and .venv\\Scripts\\python.exe is missing. Install Python 3.11+ or create .venv first." + if (Test-Path -LiteralPath $venvPython) { + return $venvPython } + + $pythonCmd = Get-Command python -ErrorAction SilentlyContinue + if ($null -ne $pythonCmd -and (Test-Path -LiteralPath $pythonCmd.Source)) { + return $pythonCmd.Source + } + + $candidatePyLaunchers = @() + $pyFromPath = Get-Command py -ErrorAction SilentlyContinue + if ($null -ne $pyFromPath -and (Test-Path -LiteralPath $pyFromPath.Source)) { + $candidatePyLaunchers += $pyFromPath.Source + } + $candidatePyLaunchers += "C:\Windows\py.exe" + + if ($scriptDir -match "^[A-Za-z]:\\Users\\[^\\]+") { + $repoUserHome = $Matches[0] + $candidatePyLaunchers += (Join-Path $repoUserHome "AppData\Local\Programs\Python\Launcher\py.exe") + } + + foreach ($pyLauncher in ($candidatePyLaunchers | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $pyLauncher)) { + continue + } + try { + $resolved = (& $pyLauncher -3 -c "import sys; print(sys.executable)" 2>$null | Select-Object -Last 1).Trim() + if ($resolved -and (Test-Path -LiteralPath $resolved)) { + return $resolved + } + } catch { + continue + } + } + + $candidatePythonPaths = @() + if ($scriptDir -match "^[A-Za-z]:\\Users\\[^\\]+") { + $repoUserHome = $Matches[0] + $pythonBase = Join-Path $repoUserHome "AppData\Local\Programs\Python" + if (Test-Path -LiteralPath $pythonBase) { + $candidatePythonPaths += (Get-ChildItem -LiteralPath $pythonBase -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName "python.exe" }) + } + } + + $candidatePythonPaths += @( + "C:\Python314\python.exe", + "C:\Python313\python.exe", + "C:\Python312\python.exe", + "C:\Python311\python.exe", + "C:\Program Files\Python314\python.exe", + "C:\Program Files\Python313\python.exe", + "C:\Program Files\Python312\python.exe", + "C:\Program Files\Python311\python.exe" + ) + + foreach ($candidate in ($candidatePythonPaths | Select-Object -Unique)) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + throw "Python was not found. Install Python 3.11+ system-wide, or create .venv in the repo root." } -$pythonExe = $null -$venvPython = Join-Path $scriptDir ".venv\Scripts\python.exe" -if (Test-Path -LiteralPath $venvPython) { - $pythonExe = $venvPython -} else { - $pythonCmd = Get-Command python -ErrorAction SilentlyContinue - if ($null -eq $pythonCmd) { - throw "Python was not found in PATH. Install Python 3.11+ or create .venv first." - } - $pythonExe = $pythonCmd.Source -} +$pythonExe = Resolve-PythonExecutable $envFile = Join-Path $scriptDir ".env" if (-not (Test-Path -LiteralPath $envFile)) { @@ -46,5 +97,5 @@ if (-not (Test-Path -LiteralPath $envFile)) { } } -Write-Host "Starting ScreenJob backend on configured host/port..." -ForegroundColor Cyan +Write-Host "Starting ScreenJob backend with Python: $pythonExe" -ForegroundColor Cyan & $pythonExe main.py server